Просмотр исходного кода

Let Pro and Enterprise workshops send SMS without their own provider (#584)

* Let Pro and Enterprise workshops send SMS without their own provider

A workshop with no SMS provider connected now texts through the platform's
Twilio account: 50 texts a day on Pro, 200 on Enterprise, none on the free
plan. A workshop's own provider always wins and has no limit. The account is
read from system_settings, written by the torqvoice.com admin, and is used on
the cloud only.

* Keep unsent texts and the shared-number notice in every SMS screen

The notify dialog stays open when the daily limit refuses its text, the two
compose dialogs say the text goes from a shared number, and a reminder refused
at the limit leaves no failed text in the thread and opens the upgrade prompt
from Send now. Also waits for the Translate button in a checklist test that
raced the end of its transition.

* Set platform SMS to 30 texts a day on Pro and 100 on Enterprise

* Count platform SMS by segment, the unit the provider bills in

A long text is sent as several SMS and now uses as many of the day's
allowance. The compose dialog's SMS count uses the same rule, so emoji and
non-Latin text show their real number.
Bernt Christian Egeland 14 часов назад
Родитель
Сommit
bf57407d5c
54 измененных файлов с 772 добавлено и 72 удалено
  1. 1 0
      messages/de/common.json
  2. 2 1
      messages/de/messages.json
  3. 1 0
      messages/en/common.json
  4. 2 1
      messages/en/messages.json
  5. 1 0
      messages/es/common.json
  6. 2 1
      messages/es/messages.json
  7. 1 0
      messages/fr/common.json
  8. 2 1
      messages/fr/messages.json
  9. 1 0
      messages/it/common.json
  10. 2 1
      messages/it/messages.json
  11. 1 0
      messages/lt/common.json
  12. 2 1
      messages/lt/messages.json
  13. 1 0
      messages/nb/common.json
  14. 2 1
      messages/nb/messages.json
  15. 1 0
      messages/nl/common.json
  16. 2 1
      messages/nl/messages.json
  17. 1 0
      messages/pl/common.json
  18. 2 1
      messages/pl/messages.json
  19. 1 0
      messages/pt-BR/common.json
  20. 2 1
      messages/pt-BR/messages.json
  21. 1 0
      messages/pt-PT/common.json
  22. 2 1
      messages/pt-PT/messages.json
  23. 1 0
      messages/ru/common.json
  24. 2 1
      messages/ru/messages.json
  25. 1 0
      messages/tr/common.json
  26. 2 1
      messages/tr/messages.json
  27. 8 0
      prisma/migrations/20261114110102_platform_sms_usage/migration.sql
  28. 13 0
      prisma/schema/organization.prisma
  29. 2 1
      src/__tests__/features/inspections/template-list-dropped-connection.test.tsx
  30. 3 0
      src/__tests__/features/sms/sms-send.test.ts
  31. 275 0
      src/__tests__/lib/platform-sms.test.ts
  32. 42 0
      src/__tests__/lib/sms-segments.test.ts
  33. 3 0
      src/app/(authenticated)/customers/[id]/customer-detail-client.tsx
  34. 3 0
      src/app/(authenticated)/customers/[id]/page.tsx
  35. 10 3
      src/components/notify-customer-dialog.tsx
  36. 3 1
      src/components/upgrade-gate.tsx
  37. 1 1
      src/features/inspections/Components/InspectionShareDialog.tsx
  38. 1 0
      src/features/messaging/Components/UnifiedInbox.tsx
  39. 1 1
      src/features/quotes/Components/QuoteShareDialog.tsx
  40. 2 1
      src/features/scheduled-messages/Components/ScheduledMessageList.tsx
  41. 6 0
      src/features/scheduled-messages/Lib/dispatchScheduledMessage.ts
  42. 23 0
      src/features/sms/Actions/getSmsSendingNotice.ts
  43. 24 5
      src/features/sms/Actions/smsActions.ts
  44. 6 12
      src/features/sms/Components/NewSmsDialog.tsx
  45. 4 1
      src/features/sms/Components/SendSmsDialog.tsx
  46. 36 0
      src/features/sms/Components/SharedNumberNotice.tsx
  47. 13 1
      src/features/sms/Components/SmsConversation.tsx
  48. 1 1
      src/features/vehicles/Components/service-detail/ShareDialog.tsx
  49. 2 0
      src/lib/backup/manifest.ts
  50. 5 0
      src/lib/backup/tenancy-schema.ts
  51. 9 0
      src/lib/features.ts
  52. 124 0
      src/lib/platform-sms.ts
  53. 41 0
      src/lib/sms-segments.ts
  54. 72 31
      src/lib/sms.ts

+ 1 - 0
messages/de/common.json

@@ -106,6 +106,7 @@
     "maxCustomers": "Ihr Tarif umfasst bis zu {limit} Kunden, und diese Zahl ist erreicht. Mit einem Upgrade legen Sie so viele an, wie Sie brauchen.",
     "maxUsers": "Ihr Tarif umfasst {limit, plural, one {ein Teammitglied} other {# Teammitglieder}}. Mit einem Upgrade laden Sie weitere Personen ein.",
     "maxPlatformMail": "Ihr Tarif versendet bis zu {limit} E-Mails pro Tag über die Torqvoice-Adresse, und die heutigen sind aufgebraucht. Upgraden Sie, um so viele zu senden, wie Sie brauchen, oder senden Sie den Rest morgen.",
+    "maxPlatformSms": "Ihr Tarif versendet bis zu {limit} SMS pro Tag über die Torqvoice-Nummer, und die heutigen sind aufgebraucht. Upgraden Sie für ein höheres Limit, verbinden Sie Ihren eigenen SMS-Anbieter, um ohne Limit zu senden, oder senden Sie den Rest morgen.",
     "generic": "Diese Funktion ist in Ihrem aktuellen Tarif nicht enthalten.",
     "featureNotIncluded": "{feature} ist in Ihrem aktuellen Tarif nicht enthalten.",
     "notNow": "Später"

+ 2 - 1
messages/de/messages.json

@@ -47,7 +47,8 @@
     "messageDeleted": "Nachricht gelöscht",
     "deleteError": "Nachricht konnte nicht gelöscht werden",
     "sendError": "SMS konnte nicht gesendet werden",
-    "sendMessage": "Nachricht senden"
+    "sendMessage": "Nachricht senden",
+    "sharedNumber": "Gesendet von einer gemeinsam genutzten Torqvoice-Nummer, bis zu {limit} SMS pro Tag. Eine lange SMS zählt als mehrere. Antworten darauf erreichen Sie nicht. Verbinden Sie in den Einstellungen Ihren eigenen SMS-Anbieter, um SMS in beide Richtungen auszutauschen."
   },
   "sendDialog": {
     "send": "{label} senden",

+ 1 - 0
messages/en/common.json

@@ -106,6 +106,7 @@
     "maxCustomers": "Your plan includes up to {limit} customers, and you have reached that number. Upgrade to add as many as you need.",
     "maxUsers": "Your plan includes {limit, plural, one {one team member} other {# team members}}. Upgrade to invite more people.",
     "maxPlatformMail": "Your plan sends up to {limit} emails a day from the Torqvoice address, and today's are used. Upgrade to send as many as you need, or send the rest tomorrow.",
+    "maxPlatformSms": "Your plan sends up to {limit} text messages a day from the Torqvoice number, and today's are used. Upgrade for a higher limit, connect your own SMS provider to send without one, or send the rest tomorrow.",
     "generic": "This feature is not included in your current plan.",
     "featureNotIncluded": "{feature} is not included in your current plan.",
     "notNow": "Not now"

+ 2 - 1
messages/en/messages.json

@@ -47,7 +47,8 @@
     "messageDeleted": "Message deleted",
     "deleteError": "Failed to delete message",
     "sendError": "Failed to send SMS",
-    "sendMessage": "Send message"
+    "sendMessage": "Send message",
+    "sharedNumber": "Sent from a shared Torqvoice number, up to {limit} texts a day. A long text counts as more than one. Replies to it will not reach you. Connect your own SMS provider in Settings for two-way texting."
   },
   "sendDialog": {
     "send": "Send {label}",

+ 1 - 0
messages/es/common.json

@@ -106,6 +106,7 @@
     "maxCustomers": "Su plan incluye hasta {limit} clientes y ya ha alcanzado ese número. Mejore el plan para añadir todos los que necesite.",
     "maxUsers": "Su plan incluye {limit, plural, one {un miembro del equipo} other {# miembros del equipo}}. Mejore el plan para invitar a más personas.",
     "maxPlatformMail": "Su plan envía hasta {limit} correos al día desde la dirección de Torqvoice, y los de hoy ya se han usado. Mejore su plan para enviar todos los que necesite, o envíe el resto mañana.",
+    "maxPlatformSms": "Su plan envía hasta {limit} SMS al día desde el número de Torqvoice, y los de hoy ya se han usado. Mejore su plan para tener un límite mayor, conecte su propio proveedor de SMS para enviar sin límite, o envíe el resto mañana.",
     "generic": "Esta función no está incluida en su plan actual.",
     "featureNotIncluded": "{feature} no está incluido en su plan actual.",
     "notNow": "Ahora no"

+ 2 - 1
messages/es/messages.json

@@ -47,7 +47,8 @@
     "messageDeleted": "Mensaje eliminado",
     "deleteError": "No se pudo eliminar el mensaje",
     "sendError": "No se pudo enviar el SMS",
-    "sendMessage": "Enviar mensaje"
+    "sendMessage": "Enviar mensaje",
+    "sharedNumber": "Enviado desde un número compartido de Torqvoice, hasta {limit} SMS al día. Un SMS largo cuenta como más de uno. Las respuestas a este número no le llegarán. Conecte su propio proveedor de SMS en Configuración para enviar y recibir mensajes."
   },
   "sendDialog": {
     "send": "Enviar {label}",

+ 1 - 0
messages/fr/common.json

@@ -106,6 +106,7 @@
     "maxCustomers": "Votre offre inclut jusqu'à {limit} clients et vous avez atteint ce nombre. Passez au niveau supérieur pour en ajouter autant que nécessaire.",
     "maxUsers": "Votre offre inclut {limit, plural, one {un membre d'équipe} other {# membres d'équipe}}. Passez au niveau supérieur pour inviter d'autres personnes.",
     "maxPlatformMail": "Votre offre envoie jusqu'à {limit} e-mails par jour depuis l'adresse Torqvoice, et ceux d'aujourd'hui sont épuisés. Passez à une offre supérieure pour en envoyer autant que nécessaire, ou envoyez le reste demain.",
+    "maxPlatformSms": "Votre offre envoie jusqu'à {limit} SMS par jour depuis le numéro Torqvoice, et ceux d'aujourd'hui sont épuisés. Passez à une offre supérieure pour une limite plus élevée, connectez votre propre fournisseur SMS pour envoyer sans limite, ou envoyez le reste demain.",
     "generic": "Cette fonctionnalité n'est pas incluse dans votre offre actuelle.",
     "featureNotIncluded": "{feature} n'est pas inclus dans votre offre actuelle.",
     "notNow": "Pas maintenant"

+ 2 - 1
messages/fr/messages.json

@@ -47,7 +47,8 @@
     "messageDeleted": "Message supprimé",
     "deleteError": "Impossible de supprimer le message",
     "sendError": "Impossible d'envoyer le SMS",
-    "sendMessage": "Envoyer le message"
+    "sendMessage": "Envoyer le message",
+    "sharedNumber": "Envoyé depuis un numéro Torqvoice partagé, jusqu'à {limit} SMS par jour. Un SMS long compte pour plusieurs. Les réponses à ce numéro ne vous parviendront pas. Connectez votre propre fournisseur SMS dans les Paramètres pour échanger des SMS dans les deux sens."
   },
   "sendDialog": {
     "send": "Envoyer {label}",

+ 1 - 0
messages/it/common.json

@@ -106,6 +106,7 @@
     "maxCustomers": "Il Suo piano include fino a {limit} clienti e ha raggiunto quel numero. Passi a un piano superiore per aggiungerne quanti ne servono.",
     "maxUsers": "Il Suo piano include {limit, plural, one {un membro del team} other {# membri del team}}. Passi a un piano superiore per invitare altre persone.",
     "maxPlatformMail": "Il Suo piano invia fino a {limit} email al giorno dall'indirizzo Torqvoice, e quelle di oggi sono esaurite. Passi a un piano superiore per inviarne quante ne servono, oppure invii le restanti domani.",
+    "maxPlatformSms": "Il Suo piano invia fino a {limit} SMS al giorno dal numero Torqvoice, e quelli di oggi sono esauriti. Passi a un piano superiore per un limite più alto, colleghi il Suo provider SMS per inviare senza limiti, oppure invii i restanti domani.",
     "generic": "Questa funzione non è inclusa nel Suo piano attuale.",
     "featureNotIncluded": "{feature} non è incluso nel Suo piano attuale.",
     "notNow": "Non ora"

+ 2 - 1
messages/it/messages.json

@@ -47,7 +47,8 @@
     "messageDeleted": "Messaggio eliminato",
     "deleteError": "Impossibile eliminare il messaggio",
     "sendError": "Impossibile inviare l'SMS",
-    "sendMessage": "Invia messaggio"
+    "sendMessage": "Invia messaggio",
+    "sharedNumber": "Inviato da un numero Torqvoice condiviso, fino a {limit} SMS al giorno. Un SMS lungo conta come più di uno. Le risposte a questo numero non ti arriveranno. Collega il tuo provider SMS in Impostazioni per inviare e ricevere messaggi."
   },
   "sendDialog": {
     "send": "Invia {label}",

+ 1 - 0
messages/lt/common.json

@@ -106,6 +106,7 @@
     "maxCustomers": "Jūsų planas apima iki {limit} klientų, ir šis skaičius pasiektas. Atnaujinkite planą, kad pridėtumėte tiek, kiek reikia.",
     "maxUsers": "Jūsų planas apima {limit, plural, one {# komandos narį} few {# komandos narius} many {# komandos nario} other {# komandos narių}}. Atnaujinkite planą, kad pakviestumėte daugiau žmonių.",
     "maxPlatformMail": "Jūsų planas leidžia išsiųsti iki {limit} el. laiškų per dieną iš Torqvoice adreso, o šiandienos limitas išnaudotas. Atnaujinkite planą, kad galėtumėte siųsti kiek reikia, arba išsiųskite likusius rytoj.",
+    "maxPlatformSms": "Jūsų planas leidžia išsiųsti iki {limit} SMS žinučių per dieną iš Torqvoice numerio, o šiandienos limitas išnaudotas. Atnaujinkite planą, kad gautumėte didesnį limitą, prijunkite savo SMS tiekėją, kad siųstumėte be limito, arba išsiųskite likusias rytoj.",
     "generic": "Ši funkcija neįtraukta į jūsų dabartinį planą.",
     "featureNotIncluded": "{feature} neįtraukta į jūsų dabartinį planą.",
     "notNow": "Ne dabar"

+ 2 - 1
messages/lt/messages.json

@@ -47,7 +47,8 @@
     "messageDeleted": "Žinutė ištrinta",
     "deleteError": "Nepavyko ištrinti žinutės",
     "sendError": "Nepavyko išsiųsti SMS",
-    "sendMessage": "Siųsti žinutę"
+    "sendMessage": "Siųsti žinutę",
+    "sharedNumber": "Išsiųsta iš bendro Torqvoice numerio, iki {limit} SMS per dieną. Ilga SMS žinutė skaičiuojama kaip kelios. Atsakymai į jį jūsų nepasieks. Prijunkite savo SMS tiekėją skiltyje Nustatymai, kad galėtumėte ir siųsti, ir gauti žinutes."
   },
   "sendDialog": {
     "send": "Siųsti {label}",

+ 1 - 0
messages/nb/common.json

@@ -106,6 +106,7 @@
     "maxCustomers": "Planen din inkluderer opptil {limit} kunder, og du har nådd det antallet. Oppgrader for å legge til så mange du trenger.",
     "maxUsers": "Planen din inkluderer {limit, plural, one {ett teammedlem} other {# teammedlemmer}}. Oppgrader for å invitere flere.",
     "maxPlatformMail": "Planen din sender opptil {limit} e-poster om dagen fra Torqvoice-adressen, og dagens er brukt opp. Oppgrader for å sende så mange du trenger, eller send resten i morgen.",
+    "maxPlatformSms": "Planen din sender opptil {limit} SMS-er om dagen fra Torqvoice-nummeret, og dagens er brukt opp. Oppgrader for en høyere grense, koble til din egen SMS-leverandør for å sende uten grense, eller send resten i morgen.",
     "generic": "Denne funksjonen er ikke inkludert i planen din.",
     "featureNotIncluded": "{feature} er ikke inkludert i planen din.",
     "notNow": "Ikke nå"

+ 2 - 1
messages/nb/messages.json

@@ -47,7 +47,8 @@
     "messageDeleted": "Melding slettet",
     "deleteError": "Kunne ikke slette meldingen",
     "sendError": "Kunne ikke sende SMS",
-    "sendMessage": "Send melding"
+    "sendMessage": "Send melding",
+    "sharedNumber": "Sendt fra et delt Torqvoice-nummer, opptil {limit} SMS om dagen. En lang SMS teller som flere. Svar til dette nummeret når ikke frem til deg. Koble til din egen SMS-leverandør under Innstillinger for å både sende og motta meldinger."
   },
   "sendDialog": {
     "send": "Send {label}",

+ 1 - 0
messages/nl/common.json

@@ -106,6 +106,7 @@
     "maxCustomers": "Uw abonnement omvat maximaal {limit} klanten en dat aantal is bereikt. Upgrade om er zoveel toe te voegen als u nodig heeft.",
     "maxUsers": "Uw abonnement omvat {limit, plural, one {één teamlid} other {# teamleden}}. Upgrade om meer mensen uit te nodigen.",
     "maxPlatformMail": "Uw abonnement verstuurt tot {limit} e-mails per dag vanaf het Torqvoice-adres, en die van vandaag zijn op. Upgrade om er zoveel te versturen als u nodig hebt, of verstuur de rest morgen.",
+    "maxPlatformSms": "Uw abonnement verstuurt tot {limit} sms-berichten per dag vanaf het Torqvoice-nummer, en die van vandaag zijn op. Upgrade voor een hogere limiet, koppel uw eigen sms-provider om zonder limiet te versturen, of verstuur de rest morgen.",
     "generic": "Deze functie is niet inbegrepen in uw huidige abonnement.",
     "featureNotIncluded": "{feature} is niet inbegrepen in uw huidige abonnement.",
     "notNow": "Niet nu"

+ 2 - 1
messages/nl/messages.json

@@ -47,7 +47,8 @@
     "messageDeleted": "Bericht verwijderd",
     "deleteError": "Bericht kon niet worden verwijderd",
     "sendError": "SMS kon niet worden verzonden",
-    "sendMessage": "Bericht versturen"
+    "sendMessage": "Bericht versturen",
+    "sharedNumber": "Verstuurd vanaf een gedeeld Torqvoice-nummer, tot {limit} sms-berichten per dag. Een lang bericht telt als meer dan één. Antwoorden hierop komen niet bij u aan. Koppel uw eigen sms-provider in Instellingen om berichten te versturen en te ontvangen."
   },
   "sendDialog": {
     "send": "{label} verzenden",

+ 1 - 0
messages/pl/common.json

@@ -106,6 +106,7 @@
     "maxCustomers": "Twój plan obejmuje do {limit} klientów i ta liczba została osiągnięta. Zmień plan, aby dodać tylu, ilu potrzebujesz.",
     "maxUsers": "Twój plan obejmuje {limit, plural, one {jednego członka zespołu} few {# członków zespołu} many {# członków zespołu} other {# członka zespołu}}. Zmień plan, aby zaprosić więcej osób.",
     "maxPlatformMail": "Twój plan wysyła do {limit} e-maili dziennie z adresu Torqvoice, a dzisiejszy limit został wykorzystany. Przejdź na wyższy plan, aby wysyłać bez ograniczeń, lub wyślij resztę jutro.",
+    "maxPlatformSms": "Twój plan wysyła do {limit} SMS-ów dziennie z numeru Torqvoice, a dzisiejszy limit został wykorzystany. Przejdź na wyższy plan, aby zwiększyć limit, podłącz własnego dostawcę SMS, aby wysyłać bez ograniczeń, lub wyślij resztę jutro.",
     "generic": "Ta funkcja nie jest dostępna w Twoim obecnym planie.",
     "featureNotIncluded": "{feature} nie jest dostępne w Twoim obecnym planie.",
     "notNow": "Nie teraz"

+ 2 - 1
messages/pl/messages.json

@@ -47,7 +47,8 @@
     "messageDeleted": "Wiadomość usunięta",
     "deleteError": "Nie udało się usunąć wiadomości",
     "sendError": "Nie udało się wysłać SMS",
-    "sendMessage": "Wyślij wiadomość"
+    "sendMessage": "Wyślij wiadomość",
+    "sharedNumber": "Wysłano ze wspólnego numeru Torqvoice, do {limit} SMS-ów dziennie. Długi SMS liczy się jako kilka. Odpowiedzi na ten numer do Ciebie nie dotrą. Podłącz własnego dostawcę SMS w Ustawieniach, aby wysyłać i odbierać wiadomości."
   },
   "sendDialog": {
     "send": "Wyślij {label}",

+ 1 - 0
messages/pt-BR/common.json

@@ -106,6 +106,7 @@
     "maxCustomers": "Seu plano inclui até {limit} clientes, e você chegou a esse número. Faça upgrade para adicionar quantos precisar.",
     "maxUsers": "Seu plano inclui {limit, plural, one {um membro na equipe} other {# membros na equipe}}. Faça upgrade para convidar mais pessoas.",
     "maxPlatformMail": "Seu plano envia até {limit} e-mails por dia pelo endereço da Torqvoice, e os de hoje já foram usados. Faça upgrade para enviar quantos precisar ou envie o restante amanhã.",
+    "maxPlatformSms": "Seu plano envia até {limit} SMS por dia pelo número da Torqvoice, e os de hoje já foram usados. Faça upgrade para ter um limite maior, conecte seu próprio provedor de SMS para enviar sem limite ou envie o restante amanhã.",
     "generic": "Este recurso não está incluído no seu plano atual.",
     "featureNotIncluded": "{feature} não está incluído no seu plano atual.",
     "notNow": "Agora não"

+ 2 - 1
messages/pt-BR/messages.json

@@ -47,7 +47,8 @@
     "messageDeleted": "Mensagem excluída",
     "deleteError": "Falha ao excluir a mensagem",
     "sendError": "Falha ao enviar o SMS",
-    "sendMessage": "Enviar mensagem"
+    "sendMessage": "Enviar mensagem",
+    "sharedNumber": "Enviado de um número compartilhado da Torqvoice, até {limit} SMS por dia. Um SMS longo conta como mais de um. As respostas a ele não chegarão até você. Conecte seu próprio provedor de SMS em Configurações para enviar e receber mensagens."
   },
   "sendDialog": {
     "send": "Enviar {label}",

+ 1 - 0
messages/pt-PT/common.json

@@ -106,6 +106,7 @@
     "maxCustomers": "O seu plano inclui até {limit} clientes e já atingiu esse número. Mude de plano para adicionar todos os clientes de que precisa.",
     "maxUsers": "O seu plano inclui {limit, plural, one {um membro da equipa} other {# membros da equipa}}. Mude de plano para convidar mais pessoas.",
     "maxPlatformMail": "O seu plano envia até {limit} e-mails por dia a partir do endereço da Torqvoice, e os de hoje já foram utilizados. Atualize o plano para enviar quantos precisar ou envie os restantes amanhã.",
+    "maxPlatformSms": "O seu plano envia até {limit} SMS por dia a partir do número da Torqvoice, e os de hoje já foram utilizados. Atualize o plano para ter um limite maior, ligue o seu próprio fornecedor de SMS para enviar sem limite ou envie os restantes amanhã.",
     "generic": "Esta funcionalidade não está incluída no seu plano atual.",
     "featureNotIncluded": "{feature} não está incluído no seu plano atual.",
     "notNow": "Agora não"

+ 2 - 1
messages/pt-PT/messages.json

@@ -47,7 +47,8 @@
     "messageDeleted": "Mensagem eliminada",
     "deleteError": "Não foi possível eliminar a mensagem",
     "sendError": "Não foi possível enviar o SMS",
-    "sendMessage": "Enviar mensagem"
+    "sendMessage": "Enviar mensagem",
+    "sharedNumber": "Enviado a partir de um número partilhado da Torqvoice, até {limit} SMS por dia. Uma SMS longa conta como mais do que uma. As respostas a este número não lhe chegarão. Ligue o seu próprio fornecedor de SMS nas Definições para enviar e receber mensagens."
   },
   "sendDialog": {
     "send": "Enviar {label}",

+ 1 - 0
messages/ru/common.json

@@ -106,6 +106,7 @@
     "maxCustomers": "Ваш тариф включает до {limit} клиентов, и это число достигнуто. Перейдите на другой тариф, чтобы добавлять столько, сколько нужно.",
     "maxUsers": "Ваш тариф включает {limit, plural, one {# участника команды} few {# участника команды} many {# участников команды} other {# участника команды}}. Перейдите на другой тариф, чтобы пригласить больше людей.",
     "maxPlatformMail": "Ваш тариф позволяет отправлять до {limit} писем в день с адреса Torqvoice, и сегодняшний лимит исчерпан. Перейдите на тариф выше, чтобы отправлять сколько нужно, или отправьте остальные завтра.",
+    "maxPlatformSms": "Ваш тариф позволяет отправлять до {limit} SMS в день с номера Torqvoice, и сегодняшний лимит исчерпан. Перейдите на тариф выше, чтобы увеличить лимит, подключите своего SMS-провайдера, чтобы отправлять без ограничений, или отправьте остальные завтра.",
     "generic": "Эта функция не входит в ваш текущий тариф.",
     "featureNotIncluded": "{feature} не входит в ваш текущий тариф.",
     "notNow": "Не сейчас"

+ 2 - 1
messages/ru/messages.json

@@ -47,7 +47,8 @@
     "messageDeleted": "Сообщение удалено",
     "deleteError": "Не удалось удалить сообщение",
     "sendError": "Не удалось отправить SMS",
-    "sendMessage": "Отправить сообщение"
+    "sendMessage": "Отправить сообщение",
+    "sharedNumber": "Отправлено с общего номера Torqvoice, до {limit} SMS в день. Длинное SMS считается за несколько. Ответы на этот номер до вас не дойдут. Подключите своего SMS-провайдера в разделе «Настройки», чтобы отправлять и получать сообщения."
   },
   "sendDialog": {
     "send": "Отправить {label}",

+ 1 - 0
messages/tr/common.json

@@ -106,6 +106,7 @@
     "maxCustomers": "Planınız en fazla {limit} müşteri içeriyor ve bu sayıya ulaştınız. İhtiyacınız kadar müşteri eklemek için planınızı yükseltin.",
     "maxUsers": "Planınız {limit, plural, one {bir ekip üyesi} other {# ekip üyesi}} içeriyor. Daha fazla kişi davet etmek için planınızı yükseltin.",
     "maxPlatformMail": "Planınız Torqvoice adresinden günde en fazla {limit} e-posta gönderir ve bugünkü hakkınız doldu. İhtiyacınız kadar göndermek için planınızı yükseltin veya kalanları yarın gönderin.",
+    "maxPlatformSms": "Planınız Torqvoice numarasından günde en fazla {limit} SMS gönderir ve bugünkü hakkınız doldu. Daha yüksek bir limit için planınızı yükseltin, sınırsız göndermek için kendi SMS sağlayıcınızı bağlayın veya kalanları yarın gönderin.",
     "generic": "Bu özellik mevcut planınıza dahil değil.",
     "featureNotIncluded": "{feature} mevcut planınıza dahil değil.",
     "notNow": "Şimdi değil"

+ 2 - 1
messages/tr/messages.json

@@ -47,7 +47,8 @@
     "messageDeleted": "Mesaj silindi",
     "deleteError": "Mesaj silinemedi",
     "sendError": "SMS gönderilemedi",
-    "sendMessage": "Mesaj gönder"
+    "sendMessage": "Mesaj gönder",
+    "sharedNumber": "Paylaşılan bir Torqvoice numarasından gönderildi, günde en fazla {limit} SMS. Uzun bir SMS birden fazla sayılır. Bu numaraya gelen yanıtlar size ulaşmaz. Çift yönlü mesajlaşma için Ayarlar'dan kendi SMS sağlayıcınızı bağlayın."
   },
   "sendDialog": {
     "send": "{label} gönder",

+ 8 - 0
prisma/migrations/20261114110102_platform_sms_usage/migration.sql

@@ -0,0 +1,8 @@
+-- CreateTable
+CREATE TABLE "platform_sms_usage" (
+    "organizationId" TEXT NOT NULL,
+    "day" TEXT NOT NULL,
+    "count" INTEGER NOT NULL DEFAULT 0,
+
+    CONSTRAINT "platform_sms_usage_pkey" PRIMARY KEY ("organizationId","day")
+);

+ 13 - 0
prisma/schema/organization.prisma

@@ -215,3 +215,16 @@ model PlatformMailUsage {
   @@id([organizationId, day])
   @@map("platform_mail_usage")
 }
+
+/// How many texts a workshop has sent through the platform's SMS account on
+/// one day. Same shape and the same reason as the mail table above: the count
+/// has to survive a restart, and a slot is given back when a send fails.
+model PlatformSmsUsage {
+  organizationId String
+  /// The UTC day, YYYY-MM-DD
+  day            String
+  count          Int    @default(0)
+
+  @@id([organizationId, day])
+  @@map("platform_sms_usage")
+}

+ 2 - 1
src/__tests__/features/inspections/template-list-dropped-connection.test.tsx

@@ -88,7 +88,8 @@ describe('checklist list on a dropped connection', () => {
       expect(toast.error).toHaveBeenCalledWith('Could not translate the checklists')
     )
     expectPageStillThere()
-    expect(screen.getByRole('button', { name: 'Translate' })).toBeEnabled()
+    // The button frees up when the transition ends, a moment after the toast.
+    await waitFor(() => expect(screen.getByRole('button', { name: 'Translate' })).toBeEnabled())
     expect(router.refresh).not.toHaveBeenCalled()
   })
 

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

@@ -20,6 +20,9 @@ vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
 
 vi.mock('@/lib/features', () => ({
   requireFeature: vi.fn(),
+  getFeatures: vi.fn(),
+  // Self-hosted, so there is no platform SMS account to fall back on.
+  isCloudMode: () => false,
 }))
 
 vi.mock('@/lib/db', () => ({

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

@@ -0,0 +1,275 @@
+/**
+ * Texting without an SMS provider of your own.
+ *
+ * A workshop used to need a Twilio, Vonage or Telnyx account before its first
+ * text could go out. On the cloud, a paid workshop that has not connected one
+ * now sends through the platform's Twilio account: 30 texts a day on Pro, 100
+ * on Enterprise, none on the free plan. A workshop with its own provider is
+ * untouched and has no limit. The count lives in the database and a text that
+ * fails to send does not use up the day.
+ */
+
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const getFeatures = vi.fn()
+const isCloudMode = vi.fn()
+vi.mock('@/lib/features', async (importOriginal) => ({
+  ...(await importOriginal<typeof import('@/lib/features')>()),
+  getFeatures: (...args: unknown[]) => getFeatures(...args),
+  isCloudMode: () => isCloudMode(),
+}))
+
+const channelSettings = vi.fn()
+vi.mock('@/features/integrations/Lib/messaging', () => ({
+  channelSettings: (...args: unknown[]) => channelSettings(...args),
+}))
+
+// What the torqvoice.com admin wrote into system_settings.
+let systemSettings: { key: string; value: string }[] = []
+
+// The usage table, keyed by workshop and day, with the two operations the
+// allowance uses: create-if-missing and a conditional increment or decrement.
+const usage = new Map<string, number>()
+const keyOf = (w: { organizationId: string; day: string }) => `${w.organizationId}:${w.day}`
+const platformSmsUsage = {
+  createMany: vi.fn(async ({ data }: { data: { organizationId: string; day: string }[] }) => {
+    for (const row of data) if (!usage.has(keyOf(row))) usage.set(keyOf(row), 0)
+    return { count: data.length }
+  }),
+  updateMany: vi.fn(
+    async ({
+      where,
+      data,
+    }: {
+      where: { organizationId: string; day: string; count: { lte?: number; gte?: number } }
+      data: { count: { increment?: number; decrement?: number } }
+    }) => {
+      const current = usage.get(keyOf(where))
+      if (current === undefined) return { count: 0 }
+      if (where.count.lte !== undefined && !(current <= where.count.lte)) return { count: 0 }
+      if (where.count.gte !== undefined && !(current >= where.count.gte)) return { count: 0 }
+      usage.set(keyOf(where), current + (data.count.increment ?? 0) - (data.count.decrement ?? 0))
+      return { count: 1 }
+    }
+  ),
+}
+
+vi.mock('@/lib/db', () => ({
+  db: {
+    platformSmsUsage,
+    systemSetting: { findMany: vi.fn(async () => systemSettings) },
+    appSetting: { findUnique: vi.fn(async () => null) },
+  },
+}))
+
+const { sendOrgSms, getOrgSmsProvider, getOrgSmsPhoneNumber, getPlatformSmsAllowance } =
+  await import('@/lib/sms')
+const { withPlatformSmsAllowance } = await import('@/lib/platform-sms')
+// The real plan table, so the numbers below are the ones a workshop gets.
+const { PLAN_FEATURES } = await import('@/lib/features')
+
+const PLATFORM_NUMBER = '+15550001111'
+const OWN_NUMBER = '+4790000000'
+const CUSTOMER = '+4799999999'
+
+const platformAccount = [
+  { key: 'platformSms.twilio.accountSid', value: 'ACplatform' },
+  { key: 'platformSms.twilio.authToken', value: 'platform-token' },
+  { key: 'platformSms.phoneNumber', value: PLATFORM_NUMBER },
+]
+
+const ownTwilio = new Map([
+  ['sms.provider', 'twilio'],
+  ['sms.phoneNumber', OWN_NUMBER],
+  ['sms.twilio.accountSid', 'ACown'],
+  ['sms.twilio.authToken', 'own-token'],
+])
+
+/** Every text Twilio was asked to send: which account, from which number, to whom. */
+let sent: { account: string; from: string; to: string; body: string }[] = []
+let twilioDown = false
+
+const fetchMock = vi.fn(async (url: string, init: { body: string }) => {
+  if (twilioDown) {
+    return { ok: false, statusText: 'Service Unavailable', json: async () => ({}) }
+  }
+  const params = new URLSearchParams(init.body)
+  sent.push({
+    account: url.split('/Accounts/')[1].split('/')[0],
+    from: params.get('From') ?? '',
+    to: params.get('To') ?? '',
+    body: params.get('Body') ?? '',
+  })
+  return { ok: true, json: async () => ({ sid: `SM${sent.length}`, status: 'queued' }) }
+})
+vi.stubGlobal('fetch', fetchMock)
+
+const text = (org = 'org') => sendOrgSms(org, { to: CUSTOMER, body: 'Your car is ready' })
+
+async function textTimes(times: number, org = 'org') {
+  for (let i = 0; i < times; i++) await text(org)
+}
+
+beforeEach(() => {
+  usage.clear()
+  sent = []
+  twilioDown = false
+  systemSettings = platformAccount
+  isCloudMode.mockReset().mockReturnValue(true)
+  getFeatures.mockReset().mockResolvedValue(PLAN_FEATURES.pro)
+  channelSettings.mockReset().mockResolvedValue(new Map())
+})
+
+describe('a paid workshop with no SMS provider of its own', () => {
+  it('texts its customer from the platform number', async () => {
+    const result = await text()
+
+    expect(result.to).toBe(CUSTOMER)
+    expect(sent).toEqual([
+      { account: 'ACplatform', from: PLATFORM_NUMBER, to: CUSTOMER, body: 'Your car is ready' },
+    ])
+  })
+
+  it('is offered SMS, sending from the platform number', async () => {
+    expect(await getOrgSmsProvider('org')).toBe('twilio')
+    expect(await getOrgSmsPhoneNumber('org')).toBe(PLATFORM_NUMBER)
+    expect(await getPlatformSmsAllowance('org')).toBe(30)
+  })
+
+  it('can send 30 texts a day on Pro and is asked to upgrade on the 31st', async () => {
+    await textTimes(30)
+
+    await expect(text()).rejects.toMatchObject({
+      name: 'FeatureGatedError',
+      feature: 'platformSmsDaily',
+      limit: 30,
+    })
+    expect(sent).toHaveLength(30)
+  })
+
+  it('can send 100 texts a day on Enterprise', async () => {
+    getFeatures.mockResolvedValue(PLAN_FEATURES.enterprise)
+
+    await textTimes(100)
+
+    await expect(text()).rejects.toMatchObject({ feature: 'platformSmsDaily', limit: 100 })
+    expect(sent).toHaveLength(100)
+  })
+
+  it('keeps its own count, whatever another workshop has sent', async () => {
+    await textTimes(30, 'busy')
+
+    await expect(text('quiet')).resolves.toMatchObject({ to: CUSTOMER })
+  })
+
+  it('does not lose a text from the day when the provider is down', async () => {
+    twilioDown = true
+    await expect(text()).rejects.toThrow(/Twilio/)
+    twilioDown = false
+
+    await textTimes(30)
+
+    expect(sent).toHaveLength(30)
+  })
+
+  it('counts a long text as the several SMS it is sent as', async () => {
+    // 400 plain characters travel as three SMS, so ten of them are the day.
+    const long = 'a'.repeat(400)
+    for (let i = 0; i < 10; i++) await sendOrgSms('org', { to: CUSTOMER, body: long })
+
+    await expect(text()).rejects.toMatchObject({ feature: 'platformSmsDaily', limit: 30 })
+    expect(sent).toHaveLength(10)
+  })
+
+  it('counts a text with an emoji by the shorter segments it is sent in', async () => {
+    // 71 characters fit one plain SMS, but an emoji makes it two.
+    const body = `${'a'.repeat(69)}🚗`
+    for (let i = 0; i < 15; i++) await sendOrgSms('org', { to: CUSTOMER, body })
+
+    await expect(text()).rejects.toMatchObject({ feature: 'platformSmsDaily' })
+  })
+
+  it('refuses a long text that no longer fits, and still sends a short one', async () => {
+    await textTimes(29)
+
+    await expect(sendOrgSms('org', { to: CUSTOMER, body: 'a'.repeat(400) })).rejects.toMatchObject({
+      feature: 'platformSmsDaily',
+    })
+    await expect(text()).resolves.toMatchObject({ to: CUSTOMER })
+  })
+
+  it('gives every part of a long text back when it fails to send', async () => {
+    twilioDown = true
+    await expect(sendOrgSms('org', { to: CUSTOMER, body: 'a'.repeat(400) })).rejects.toThrow()
+    twilioDown = false
+
+    await textTimes(30)
+
+    expect(sent).toHaveLength(30)
+  })
+
+  it('starts again the next day', async () => {
+    const send = () => Promise.resolve('sent')
+    const monday = new Date('2026-11-16T12:00:00Z')
+    const tuesday = new Date('2026-11-17T00:00:01Z')
+    for (let i = 0; i < 30; i++) await withPlatformSmsAllowance('org', 30, 'Hi', send, monday)
+    await expect(withPlatformSmsAllowance('org', 30, 'Hi', send, monday)).rejects.toThrow()
+
+    await expect(withPlatformSmsAllowance('org', 30, 'Hi', send, tuesday)).resolves.toBe('sent')
+  })
+})
+
+describe('a workshop on the free plan', () => {
+  beforeEach(() => {
+    getFeatures.mockResolvedValue(PLAN_FEATURES.free)
+  })
+
+  it('is not offered SMS', async () => {
+    expect(await getOrgSmsProvider('org')).toBeNull()
+    expect(await getOrgSmsPhoneNumber('org')).toBeNull()
+  })
+
+  it('cannot send through the platform account', async () => {
+    await expect(text()).rejects.toThrow('SMS is not configured')
+    expect(sent).toHaveLength(0)
+  })
+})
+
+describe('a workshop that connected its own provider', () => {
+  beforeEach(() => {
+    channelSettings.mockResolvedValue(ownTwilio)
+  })
+
+  it('keeps sending from its own number', async () => {
+    await text()
+
+    expect(sent).toEqual([
+      { account: 'ACown', from: OWN_NUMBER, to: CUSTOMER, body: 'Your car is ready' },
+    ])
+    expect(await getPlatformSmsAllowance('org')).toBeNull()
+  })
+
+  it('has no daily limit', async () => {
+    await textTimes(60)
+
+    expect(sent).toHaveLength(60)
+    expect(usage.size).toBe(0)
+  })
+})
+
+describe('when there is no platform account to fall back on', () => {
+  it('does not offer SMS until the account is complete', async () => {
+    systemSettings = platformAccount.filter((row) => !row.key.endsWith('authToken'))
+
+    expect(await getOrgSmsProvider('org')).toBeNull()
+    await expect(text()).rejects.toThrow('SMS is not configured')
+  })
+
+  it('ignores a platform account on a self-hosted install', async () => {
+    isCloudMode.mockReturnValue(false)
+
+    expect(await getOrgSmsProvider('org')).toBeNull()
+    await expect(text()).rejects.toThrow('SMS is not configured')
+    expect(sent).toHaveLength(0)
+  })
+})

+ 42 - 0
src/__tests__/lib/sms-segments.test.ts

@@ -0,0 +1,42 @@
+/**
+ * How many SMS a text is sent, and billed, as. The compose dialog shows this
+ * number and the platform SMS allowance counts in it.
+ */
+
+import { describe, expect, it } from 'vitest'
+import { smsSegmentCount } from '@/lib/sms-segments'
+
+describe('how many SMS a text is sent as', () => {
+  it('is none for an empty text', () => {
+    expect(smsSegmentCount('')).toBe(0)
+  })
+
+  it('is one for up to 160 plain characters', () => {
+    expect(smsSegmentCount('Your car is ready for pickup.')).toBe(1)
+    expect(smsSegmentCount('a'.repeat(160))).toBe(1)
+  })
+
+  it('splits a longer plain text into parts of 153', () => {
+    expect(smsSegmentCount('a'.repeat(161))).toBe(2)
+    expect(smsSegmentCount('a'.repeat(306))).toBe(2)
+    expect(smsSegmentCount('a'.repeat(307))).toBe(3)
+  })
+
+  it('treats Norwegian and German letters as plain', () => {
+    expect(smsSegmentCount(`Bilen er klar på verkstedet, hilsen Øyvind ${'å'.repeat(110)}`)).toBe(1)
+    expect(smsSegmentCount('ä'.repeat(160))).toBe(1)
+  })
+
+  it('counts the euro sign and brackets as two characters each', () => {
+    expect(smsSegmentCount('€'.repeat(80))).toBe(1)
+    expect(smsSegmentCount('€'.repeat(81))).toBe(2)
+  })
+
+  it('drops to 70 characters once the text has an emoji or another script', () => {
+    expect(smsSegmentCount(`${'a'.repeat(68)}🚗`)).toBe(1)
+    expect(smsSegmentCount(`${'a'.repeat(69)}🚗`)).toBe(2)
+    expect(smsSegmentCount('Ваш автомобиль готов')).toBe(1)
+    expect(smsSegmentCount('я'.repeat(71))).toBe(2)
+    expect(smsSegmentCount('я'.repeat(135))).toBe(3)
+  })
+})

+ 3 - 0
src/app/(authenticated)/customers/[id]/customer-detail-client.tsx

@@ -161,6 +161,7 @@ export function CustomerDetailClient({
   smsEnabled = false,
   smsMessages = [],
   smsNextCursor = null,
+  platformSmsPerDay = null,
   telegramEnabled = false,
   whatsappEnabled = false,
   telegramBotUsername = null,
@@ -178,6 +179,7 @@ export function CustomerDetailClient({
   smsEnabled?: boolean
   smsMessages?: SmsMessage[]
   smsNextCursor?: string | null
+  platformSmsPerDay?: number | null
   telegramEnabled?: boolean
   whatsappEnabled?: boolean
   telegramBotUsername?: string | null
@@ -700,6 +702,7 @@ export function CustomerDetailClient({
                 customerPhone={customer.phone}
                 initialMessages={smsMessages}
                 initialNextCursor={smsNextCursor}
+                platformSmsPerDay={platformSmsPerDay}
               />
             </CardContent>
           </Card>

+ 3 - 0
src/app/(authenticated)/customers/[id]/page.tsx

@@ -54,6 +54,7 @@ export default async function CustomerDetailPage({ params }: { params: Promise<{
     toNumber: string
   }[] = []
   let smsNextCursor: string | null = null
+  let platformSmsPerDay: number | null = null
   let telegramMessages: {
     id: string
     direction: string
@@ -86,6 +87,7 @@ export default async function CustomerDetailPage({ params }: { params: Promise<{
       if (convResult.success && convResult.data) {
         smsMessages = convResult.data.messages
         smsNextCursor = convResult.data.nextCursor
+        platformSmsPerDay = convResult.data.platformSmsPerDay
       }
     }
 
@@ -112,6 +114,7 @@ export default async function CustomerDetailPage({ params }: { params: Promise<{
           smsEnabled={smsEnabled}
           smsMessages={smsMessages}
           smsNextCursor={smsNextCursor}
+          platformSmsPerDay={platformSmsPerDay}
           telegramEnabled={telegramEnabled}
           whatsappEnabled={whatsappEnabled}
           telegramBotUsername={telegramBotUsername}

+ 10 - 3
src/components/notify-customer-dialog.tsx

@@ -88,6 +88,9 @@ export function NotifyCustomerDialog({
     setSending(true)
 
     const results: string[] = []
+    // Set when the plan refused the text: the upgrade prompt is up, and the
+    // dialog stays open behind it so the message is still there to send.
+    let smsGated = false
 
     if (sendSms && hasPhone) {
       const res = await sendSmsToCustomer({
@@ -97,6 +100,7 @@ export function NotifyCustomerDialog({
         relatedEntityId,
       })
       if (res.success) results.push(t('smsSent'))
+      else if (handleGated(res)) smsGated = true
       else toast.error(res.error || t('failedSms'))
     }
 
@@ -116,8 +120,11 @@ export function NotifyCustomerDialog({
             }
           : null,
       })
-      if (res.success) results.push(t('emailSent'))
-      else if (!handleGated(res)) toast.error(res.error || t('failedEmail'))
+      if (res.success) {
+        results.push(t('emailSent'))
+        // Gone already, so trying the text again does not mail it twice.
+        if (smsGated) setSendEmail(false)
+      } else if (!handleGated(res)) toast.error(res.error || t('failedEmail'))
     }
 
     if (results.length > 0) {
@@ -125,7 +132,7 @@ export function NotifyCustomerDialog({
     }
 
     setSending(false)
-    onOpenChange(false)
+    if (!smsGated) onOpenChange(false)
   }
 
   const canSend = (sendSms || sendEmail) && message.trim()

+ 3 - 1
src/components/upgrade-gate.tsx

@@ -63,7 +63,9 @@ export function UpgradeGateDialog() {
         ? t('maxUsers', { limit: gate.limit })
         : gate?.feature === 'platformMailDaily' && gate.limit != null
           ? t('maxPlatformMail', { limit: gate.limit })
-          : t('generic')
+          : gate?.feature === 'platformSmsDaily' && gate.limit != null
+            ? t('maxPlatformSms', { limit: gate.limit })
+            : t('generic')
 
   return (
     <Dialog open={isOpen} onOpenChange={close}>

+ 1 - 1
src/features/inspections/Components/InspectionShareDialog.tsx

@@ -155,7 +155,7 @@ export function InspectionShareDialog({
         setNotifySms(false)
       } else {
         failed = true
-        toast.error(res?.error || t('failedSms'))
+        if (!res || !handleGated(res)) toast.error(res?.error || t('failedSms'))
       }
     }
 

+ 1 - 0
src/features/messaging/Components/UnifiedInbox.tsx

@@ -653,6 +653,7 @@ export function UnifiedInbox({
                 customerPhone={smsData.customerPhone}
                 initialMessages={smsData.messages}
                 initialNextCursor={smsData.nextCursor}
+                platformSmsPerDay={smsData.platformSmsPerDay}
                 className="h-auto min-h-0 flex-1"
               />
             ) : selected.channel === 'telegram' && telegramData && selected.customerId ? (

+ 1 - 1
src/features/quotes/Components/QuoteShareDialog.tsx

@@ -153,7 +153,7 @@ export function QuoteShareDialog({
         setNotifySms(false)
       } else {
         failed = true
-        toast.error(res.error || t('shareDialog.failedSms'))
+        if (!handleGated(res)) toast.error(res.error || t('shareDialog.failedSms'))
       }
     }
 

+ 2 - 1
src/features/scheduled-messages/Components/ScheduledMessageList.tsx

@@ -1,5 +1,6 @@
 'use client'
 
+import { handleGated } from '@/components/upgrade-gate'
 import { useMemo, useState } from 'react'
 import { useTranslations } from 'next-intl'
 import { toast } from 'sonner'
@@ -109,7 +110,7 @@ export function ScheduledMessageList({
     if (result?.success) {
       toast.success(t('sentNow'))
       onChanged()
-    } else {
+    } else if (!result || !handleGated(result)) {
       toast.error(result?.error || t('sendError'))
     }
     setBusyId(null)

+ 6 - 0
src/features/scheduled-messages/Lib/dispatchScheduledMessage.ts

@@ -92,6 +92,12 @@ export async function dispatchScheduledMessage(message: DispatchableMessage): Pr
           data: { status: 'sent', providerMsgId: result?.providerMsgId ?? null },
         })
       } catch (error) {
+        // The plan saying no: nothing was attempted, so no failed text is
+        // left in the customer's thread. The reminder itself keeps the reason.
+        if (error instanceof Error && error.name === 'FeatureGatedError') {
+          await db.smsMessage.delete({ where: { id: logged.id } }).catch(() => null)
+          throw error
+        }
         const errorMessage = error instanceof Error ? error.message : 'Unknown error'
         await db.smsMessage.update({
           where: { id: logged.id },

+ 23 - 0
src/features/sms/Actions/getSmsSendingNotice.ts

@@ -0,0 +1,23 @@
+'use server'
+
+import { getPlatformSmsAllowance } from '@/lib/sms'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { withAuth } from '@/lib/with-auth'
+
+/**
+ * What a compose dialog should say before a text goes out: when the workshop
+ * sends from the platform's shared number, how many texts a day that is.
+ * Null when it has its own provider, or no SMS at all.
+ */
+export async function getSmsSendingNotice() {
+  return withAuth(
+    async ({ organizationId }) => ({
+      platformSmsPerDay: await getPlatformSmsAllowance(organizationId),
+    }),
+    {
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.CUSTOMERS },
+      ],
+    }
+  )
+}

+ 24 - 5
src/features/sms/Actions/smsActions.ts

@@ -2,7 +2,12 @@
 
 import { db } from '@/lib/db'
 import { withAuth } from '@/lib/with-auth'
-import { sendOrgSms, getOrgSmsPhoneNumber, normalizeOrgPhone } from '@/lib/sms'
+import {
+  sendOrgSms,
+  getOrgSmsPhoneNumber,
+  getPlatformSmsAllowance,
+  normalizeOrgPhone,
+} from '@/lib/sms'
 import { requireFeature } from '@/lib/features'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
@@ -91,6 +96,14 @@ export async function sendSmsToCustomer(input: {
           customerName: customer.name,
         }
       } catch (error) {
+        // The plan saying no is not a failed send: nothing was attempted, so
+        // no failed message is left in the thread, and the refusal goes up as
+        // it is so the screen can offer the upgrade instead of an error.
+        if (error instanceof Error && error.name === 'FeatureGatedError') {
+          await db.smsMessage.delete({ where: { id: message.id } }).catch(() => null)
+          throw error
+        }
+
         const errorMessage = error instanceof Error ? error.message : 'Unknown error'
 
         await db.smsMessage.update({
@@ -161,10 +174,13 @@ export async function getConversation(customerId: string, cursor?: string, limit
       // The view needs the person as well as the thread: their name for the
       // header and their number to send to. Returning only messages left the
       // caller to invent both.
-      const customer = await db.customer.findFirst({
-        where: { id: customerId, organizationId },
-        select: { name: true, phone: true },
-      })
+      const [customer, platformAllowance] = await Promise.all([
+        db.customer.findFirst({
+          where: { id: customerId, organizationId },
+          select: { name: true, phone: true },
+        }),
+        getPlatformSmsAllowance(organizationId).catch(() => null),
+      ])
 
       const messages = await db.smsMessage.findMany({
         where: {
@@ -193,6 +209,9 @@ export async function getConversation(customerId: string, cursor?: string, limit
         nextCursor: hasMore ? items[items.length - 1]?.id : null,
         customerName: customer?.name ?? '',
         customerPhone: customer?.phone ?? null,
+        // Set when the workshop texts from the platform's shared number: the
+        // composer says replies will not arrive and how many a day go out.
+        platformSmsPerDay: platformAllowance,
       }
     },
     {

+ 6 - 12
src/features/sms/Components/NewSmsDialog.tsx

@@ -9,9 +9,12 @@ import { Input } from '@/components/ui/input'
 import { Label } from '@/components/ui/label'
 import { Textarea } from '@/components/ui/textarea'
 import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
+import { handleGated } from '@/components/upgrade-gate'
 import { DocsLink } from '@/components/docs-link'
 import { useDebouncedSearch } from '@/hooks/use-debounced-search'
+import { smsSegmentCount } from '@/lib/sms-segments'
 import { searchSmsRecipients, sendSmsToCustomer } from '../Actions/smsActions'
+import { SharedNumberNotice } from './SharedNumberNotice'
 import { SmsTemplateMenu } from './SmsTemplateMenu'
 
 interface Recipient {
@@ -21,16 +24,6 @@ interface Recipient {
   phone: string | null
 }
 
-/** GSM-7 segment sizes; a single message, then concatenated ones. */
-const SINGLE_SEGMENT = 160
-const MULTI_SEGMENT = 153
-
-function segmentCount(length: number): number {
-  if (length === 0) return 0
-  if (length <= SINGLE_SEGMENT) return 1
-  return Math.ceil(length / MULTI_SEGMENT)
-}
-
 interface NewSmsDialogProps {
   open: boolean
   onOpenChange: (open: boolean) => void
@@ -90,14 +83,14 @@ export function NewSmsDialog({
       toast.success(t('sent', { name: recipient.name }))
       onOpenChange(false)
       onSent?.(recipient.id)
-    } else {
+    } else if (!handleGated(result)) {
       toast.error(result.error || t('sendError'))
     }
     setSending(false)
   }
 
   const chars = body.length
-  const segments = segmentCount(chars)
+  const segments = smsSegmentCount(body)
 
   return (
     <Dialog open={open} onOpenChange={onOpenChange}>
@@ -204,6 +197,7 @@ export function NewSmsDialog({
               <p className="text-right text-xs text-muted-foreground tabular-nums">
                 {t('segments', { chars, segments })}
               </p>
+              <SharedNumberNotice enabled={open} />
             </div>
 
             <div className="flex flex-wrap justify-end gap-2 pt-1">

+ 4 - 1
src/features/sms/Components/SendSmsDialog.tsx

@@ -7,8 +7,10 @@ import { Textarea } from '@/components/ui/textarea'
 import { Label } from '@/components/ui/label'
 import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
 import { Loader2, MessageSquare, Send } from 'lucide-react'
+import { handleGated } from '@/components/upgrade-gate'
 import { useGlassModal } from '@/components/glass-modal'
 import { sendSmsToCustomer } from '../Actions/smsActions'
+import { SharedNumberNotice } from './SharedNumberNotice'
 
 interface SendSmsDialogProps {
   open: boolean
@@ -60,7 +62,7 @@ export function SendSmsDialog({
       )
       onOpenChange(false)
       setMessage('')
-    } else {
+    } else if (!handleGated(result)) {
       modal.open('error', te('error'), result.error || t('sendError'))
     }
     setSending(false)
@@ -93,6 +95,7 @@ export function SendSmsDialog({
             <p className="text-xs text-muted-foreground">
               {t('characters', { count: message.length })}
             </p>
+            <SharedNumberNotice enabled={open} />
           </div>
           <div className="flex justify-end gap-2">
             <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>

+ 36 - 0
src/features/sms/Components/SharedNumberNotice.tsx

@@ -0,0 +1,36 @@
+'use client'
+
+import { useQuery } from '@tanstack/react-query'
+import { useTranslations } from 'next-intl'
+import { z } from 'zod'
+import { getSmsSendingNotice } from '../Actions/getSmsSendingNotice'
+
+const noticeSchema = z.object({ platformSmsPerDay: z.number().nullable() })
+
+/**
+ * Says, in a compose dialog, that the text goes from the platform's shared
+ * number and that replies will not arrive. Renders nothing for a workshop
+ * with its own provider, and nothing while it does not know: a notice that
+ * fails to load must not get in the way of sending.
+ */
+export function SharedNumberNotice({ enabled }: { enabled: boolean }) {
+  const t = useTranslations('messages.conversation')
+  const { data } = useQuery({
+    queryKey: ['sms-sending-notice'],
+    queryFn: async () => {
+      const res = await getSmsSendingNotice()
+      if (!res.success) throw new Error(res.error)
+      return noticeSchema.parse(res.data)
+    },
+    enabled,
+    staleTime: 60_000,
+    retry: false,
+  })
+
+  if (data?.platformSmsPerDay == null) return null
+  return (
+    <p className="text-xs text-muted-foreground">
+      {t('sharedNumber', { limit: data.platformSmsPerDay })}
+    </p>
+  )
+}

+ 13 - 1
src/features/sms/Components/SmsConversation.tsx

@@ -4,6 +4,7 @@ import { useState, useTransition, useRef, useEffect, useCallback } from 'react'
 import { useTranslations } from 'next-intl'
 import { Button } from '@/components/ui/button'
 import { Textarea } from '@/components/ui/textarea'
+import { handleGated } from '@/components/upgrade-gate'
 import { SmsTemplateMenu } from './SmsTemplateMenu'
 import {
   AlertDialog,
@@ -39,6 +40,11 @@ interface SmsConversationProps {
   customerPhone: string | null
   initialMessages: Message[]
   initialNextCursor: string | null
+  /**
+   * Texts a day from the platform's shared number, when the workshop has no
+   * provider of its own. Replies to that number do not arrive.
+   */
+  platformSmsPerDay?: number | null
   className?: string
 }
 
@@ -48,6 +54,7 @@ export function SmsConversation({
   customerPhone,
   initialMessages,
   initialNextCursor,
+  platformSmsPerDay = null,
   className,
 }: SmsConversationProps) {
   const t = useTranslations('messages.conversation')
@@ -154,7 +161,7 @@ export function SmsConversation({
           setNextCursor(conv.data.nextCursor)
         }
       } else {
-        toast.error(result?.error ?? t('sendError'))
+        if (!handleGated(result)) toast.error(result?.error ?? t('sendError'))
         setNewMessage(body)
       }
     })
@@ -325,6 +332,11 @@ export function SmsConversation({
             )}
           </Button>
         </div>
+        {platformSmsPerDay != null && (
+          <p className="mt-2 text-xs text-muted-foreground">
+            {t('sharedNumber', { limit: platformSmsPerDay })}
+          </p>
+        )}
       </div>
 
       {/* Delete confirmation dialog */}

+ 1 - 1
src/features/vehicles/Components/service-detail/ShareDialog.tsx

@@ -150,7 +150,7 @@ export function ShareDialog({
         setNotifySms(false)
       } else {
         failed = true
-        toast.error(res.error || t('failedSms'))
+        if (!handleGated(res)) toast.error(res.error || t('failedSms'))
       }
     }
 

+ 2 - 0
src/lib/backup/manifest.ts

@@ -436,6 +436,8 @@ export const EXCLUDED_MODELS: Readonly<Record<string, string>> = {
   OrganizationMember: 'Membership of user accounts; people are restored by inviting them.',
   PlatformMailUsage:
     "Today's count of mail sent through the platform address, for the daily cap; it starts again every day.",
+  PlatformSmsUsage:
+    "Today's count of texts sent through the platform's SMS account, for the daily cap; it starts again every day.",
   PushDevice:
     'Push token bound to one phone and one user account, and a backup carries neither. The app registers a new one on next launch.',
   Session:

+ 5 - 0
src/lib/backup/tenancy-schema.ts

@@ -950,6 +950,11 @@ export const TENANCY_SCHEMA: SchemaGraph = {
     foreignKeys: {},
     ownerPaths: [],
   },
+  PlatformSmsUsage: {
+    organizationId: true,
+    foreignKeys: {},
+    ownerPaths: [],
+  },
   Quote: {
     organizationId: true,
     foreignKeys: {

+ 9 - 0
src/lib/features.ts

@@ -19,6 +19,11 @@ export type PlanFeatures = {
   payments: boolean
   customFields: boolean
   sms: boolean
+  /**
+   * Texts a day through the platform's own SMS account, for a workshop that
+   * has not connected a provider. Zero means the plan has no platform SMS.
+   */
+  platformSmsPerDay: number
   telegram: boolean
   whatsapp: boolean
   brandingRemoved: boolean
@@ -67,6 +72,7 @@ export const PLAN_FEATURES: Record<Plan, PlanFeatures> = {
     payments: false,
     customFields: false,
     sms: false,
+    platformSmsPerDay: 0,
     telegram: false,
     whatsapp: false,
     brandingRemoved: false,
@@ -98,6 +104,7 @@ export const PLAN_FEATURES: Record<Plan, PlanFeatures> = {
     payments: true,
     customFields: true,
     sms: true,
+    platformSmsPerDay: 30,
     telegram: true,
     whatsapp: true,
     brandingRemoved: true,
@@ -125,6 +132,7 @@ export const PLAN_FEATURES: Record<Plan, PlanFeatures> = {
     payments: true,
     customFields: true,
     sms: true,
+    platformSmsPerDay: 100,
     telegram: true,
     whatsapp: true,
     brandingRemoved: true,
@@ -152,6 +160,7 @@ export const PLAN_FEATURES: Record<Plan, PlanFeatures> = {
     payments: true,
     customFields: true,
     sms: true,
+    platformSmsPerDay: 0,
     telegram: true,
     whatsapp: true,
     brandingRemoved: true,

+ 124 - 0
src/lib/platform-sms.ts

@@ -0,0 +1,124 @@
+/**
+ * The platform's own SMS account, for workshops that have not connected one.
+ *
+ * Mail has always worked this way: a workshop with no provider sends from the
+ * platform's address. Texting needed a Twilio, Vonage or Telnyx account before
+ * the first message could go out, which is a lot to ask of a workshop that
+ * only wants to tell a customer the car is ready. A paid plan now sends
+ * through the platform's Twilio account until the workshop connects its own,
+ * up to a number of texts a day that the plan sets.
+ *
+ * The account is entered in the torqvoice.com admin, which writes the three
+ * rows below into `system_settings`; this app only reads them. They are kept
+ * out of SYSTEM_SETTING_KEYS on purpose, so the app's own admin page neither
+ * shows nor overwrites the token.
+ *
+ * Replies do not come back: the number is shared by every workshop using it,
+ * so an answer cannot be tied to one of them. Two-way texting is what
+ * connecting a provider is for.
+ */
+
+import { ORG_SMS_KEYS } from '@/features/sms/Schema/smsSettingsSchema'
+import { db } from './db'
+import { FeatureGatedError, isCloudMode } from './features'
+import { smsSegmentCount } from './sms-segments'
+
+/** Keep in step with the torqvoice.com admin, which writes these rows. */
+export const PLATFORM_SMS_KEYS = {
+  TWILIO_ACCOUNT_SID: 'platformSms.twilio.accountSid',
+  TWILIO_AUTH_TOKEN: 'platformSms.twilio.authToken',
+  PHONE_NUMBER: 'platformSms.phoneNumber',
+} as const
+
+/**
+ * The platform account under the keys the send path already reads, or null
+ * when there is none. Cloud only: a self-hosted install has no platform
+ * paying for its texts, so rows that arrived in a restored backup are ignored.
+ */
+export async function platformSmsAccount(): Promise<Map<string, string> | null> {
+  if (!isCloudMode()) return null
+
+  const rows = await db.systemSetting.findMany({
+    where: { key: { in: Object.values(PLATFORM_SMS_KEYS) } },
+    select: { key: true, value: true },
+  })
+  const stored = new Map(rows.map((row) => [row.key, row.value.trim()]))
+
+  const accountSid = stored.get(PLATFORM_SMS_KEYS.TWILIO_ACCOUNT_SID)
+  const authToken = stored.get(PLATFORM_SMS_KEYS.TWILIO_AUTH_TOKEN)
+  const phoneNumber = stored.get(PLATFORM_SMS_KEYS.PHONE_NUMBER)
+  // All three or nothing. Half an account cannot send, and offering SMS on
+  // the strength of it would only queue messages that fail.
+  if (!accountSid || !authToken || !phoneNumber) return null
+
+  return new Map([
+    [ORG_SMS_KEYS.SMS_PROVIDER, 'twilio'],
+    [ORG_SMS_KEYS.SMS_PHONE_NUMBER, phoneNumber],
+    [ORG_SMS_KEYS.SMS_TWILIO_ACCOUNT_SID, accountSid],
+    [ORG_SMS_KEYS.SMS_TWILIO_AUTH_TOKEN, authToken],
+  ])
+}
+
+function today(now: Date): string {
+  return now.toISOString().slice(0, 10)
+}
+
+/**
+ * Takes a text's share of today's allowance. Atomic, the same way the mail
+ * allowance is: the increment only happens while the whole text still fits
+ * under the limit, so two sends racing for the last of it cannot both get it.
+ */
+async function takeSlots(
+  organizationId: string,
+  day: string,
+  limit: number,
+  segments: number
+): Promise<boolean> {
+  await db.platformSmsUsage.createMany({
+    data: [{ organizationId, day, count: 0 }],
+    skipDuplicates: true,
+  })
+  const taken = await db.platformSmsUsage.updateMany({
+    where: { organizationId, day, count: { lte: limit - segments } },
+    data: { count: { increment: segments } },
+  })
+  return taken.count === 1
+}
+
+async function giveBack(organizationId: string, day: string, segments: number) {
+  await db.platformSmsUsage.updateMany({
+    where: { organizationId, day, count: { gte: segments } },
+    data: { count: { decrement: segments } },
+  })
+}
+
+/**
+ * Runs one send through the platform account under the plan's daily
+ * allowance. The allowance is counted in segments, the unit the provider
+ * bills in: a long text is sent as several SMS and uses as many of the day's.
+ * A send that throws gives them back, so a wrong number or a provider outage
+ * does not use up the day.
+ */
+export async function withPlatformSmsAllowance<T>(
+  organizationId: string,
+  limit: number,
+  body: string,
+  send: () => Promise<T>,
+  now = new Date()
+): Promise<T> {
+  const day = today(now)
+  const segments = Math.max(1, smsSegmentCount(body))
+  if (!(await takeSlots(organizationId, day, limit, segments))) {
+    throw new FeatureGatedError(
+      'platformSmsDaily',
+      `Daily limit of ${limit} text messages reached. Connect your own SMS provider to send more.`,
+      limit
+    )
+  }
+  try {
+    return await send()
+  } catch (error) {
+    await giveBack(organizationId, day, segments)
+    throw error
+  }
+}

+ 41 - 0
src/lib/sms-segments.ts

@@ -0,0 +1,41 @@
+/**
+ * How many SMS a text is billed as.
+ *
+ * A text travels in segments. One written entirely in the GSM-7 alphabet
+ * (Latin letters, digits, common punctuation and a handful of accented
+ * letters, æ ø å among them) fits 160 characters, or 153 per segment once it
+ * needs more than one. Anything outside that alphabet, an emoji or Cyrillic
+ * say, switches the whole text to UCS-2: 70 characters, or 67 per segment.
+ * Every provider bills per segment, so this is the unit a cost limit has to
+ * count in.
+ */
+
+const GSM7_BASIC =
+  '@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞÆæßÉ !"#¤%&\'()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà'
+/** In the alphabet, but each takes two places: an escape and the character. */
+const GSM7_EXTENDED = '\f^{}\\[~]|€'
+
+const BASIC = new Set(GSM7_BASIC)
+const EXTENDED = new Set(GSM7_EXTENDED)
+
+/** Places the text takes in GSM-7, or null when it cannot be written in it. */
+function gsm7Length(text: string): number | null {
+  let length = 0
+  for (const char of text) {
+    if (BASIC.has(char)) length += 1
+    else if (EXTENDED.has(char)) length += 2
+    else return null
+  }
+  return length
+}
+
+export function smsSegmentCount(text: string): number {
+  if (text.length === 0) return 0
+
+  const gsm = gsm7Length(text)
+  if (gsm !== null) return gsm <= 160 ? 1 : Math.ceil(gsm / 153)
+
+  // UCS-2 counts UTF-16 code units, which is what `length` already is: an
+  // emoji outside the basic plane takes two.
+  return text.length <= 70 ? 1 : Math.ceil(text.length / 67)
+}

+ 72 - 31
src/lib/sms.ts

@@ -1,7 +1,9 @@
 import { db } from './db'
-import { channelProvider, channelSettings } from '@/features/integrations/Lib/messaging'
+import { channelSettings } from '@/features/integrations/Lib/messaging'
 import { ORG_SMS_KEYS } from '@/features/sms/Schema/smsSettingsSchema'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import { getFeatures } from './features'
+import { platformSmsAccount, withPlatformSmsAllowance } from './platform-sms'
 import { normalizePortalPhone } from './portal-phone'
 
 export type SmsProvider = 'twilio' | 'vonage' | 'telnyx'
@@ -14,25 +16,53 @@ export interface SendSmsOptions {
 type SettingsMap = Map<string, string>
 
 /**
- * The workshop's SMS vendor and its keys, from the integration it connected.
- * A setup made before SMS moved into the catalog is adopted on the way past,
- * so this keeps answering for workshops that never touched the new screen.
+ * What a workshop's texts go out through.
+ *
+ * Its own provider when it has connected one. A setup made before SMS moved
+ * into the catalog is adopted on the way past, so this keeps answering for
+ * workshops that never touched the new screen. Otherwise the platform's
+ * account, when there is one and the plan includes texts from it, with the
+ * number of texts a day the plan allows. Null when neither applies, which is
+ * how every screen learns that SMS is not available here.
  */
-async function smsSettings(organizationId: string): Promise<SettingsMap> {
-  return channelSettings(organizationId, 'sms')
+type SmsRoute =
+  | { source: 'own'; settings: SettingsMap }
+  | { source: 'platform'; settings: SettingsMap; dailyLimit: number }
+
+async function smsRoute(organizationId: string): Promise<SmsRoute | null> {
+  const own = await channelSettings(organizationId, 'sms')
+  if (own.get(ORG_SMS_KEYS.SMS_PROVIDER)) return { source: 'own', settings: own }
+
+  const platform = await platformSmsAccount()
+  if (!platform) return null
+
+  const { platformSmsPerDay } = await getFeatures(organizationId)
+  if (!(platformSmsPerDay > 0)) return null
+  return { source: 'platform', settings: platform, dailyLimit: platformSmsPerDay }
+}
+
+function providerOf(settings: SettingsMap): SmsProvider | null {
+  const value = settings.get(ORG_SMS_KEYS.SMS_PROVIDER)
+  return value === 'twilio' || value === 'vonage' || value === 'telnyx' ? value : null
 }
 
 export async function getOrgSmsProvider(organizationId: string): Promise<SmsProvider | null> {
-  const value = await channelProvider(organizationId, 'sms')
-  if (value === 'twilio' || value === 'vonage' || value === 'telnyx') {
-    return value
-  }
-  return null
+  const route = await smsRoute(organizationId)
+  return route ? providerOf(route.settings) : null
 }
 
 export async function getOrgSmsPhoneNumber(organizationId: string): Promise<string | null> {
-  const settings = await smsSettings(organizationId)
-  return settings.get(ORG_SMS_KEYS.SMS_PHONE_NUMBER) || null
+  const route = await smsRoute(organizationId)
+  return route?.settings.get(ORG_SMS_KEYS.SMS_PHONE_NUMBER) || null
+}
+
+/**
+ * Whether this workshop texts from the platform's shared number, and how many
+ * a day. The screens use it to say that replies will not arrive.
+ */
+export async function getPlatformSmsAllowance(organizationId: string): Promise<number | null> {
+  const route = await smsRoute(organizationId)
+  return route?.source === 'platform' ? route.dailyLimit : null
 }
 
 // ─── Twilio ──────────────────────────────────────────────────────────────────
@@ -216,10 +246,10 @@ export async function sendOrgSms(
   organizationId: string,
   options: SendSmsOptions
 ): Promise<SendSmsResult> {
-  // One read of the connection covers the vendor, its keys and the number,
-  // and the country code is a workshop setting rather than the vendor's.
-  const [settings, countryRow] = await Promise.all([
-    smsSettings(organizationId),
+  // One read of the route covers the vendor, its keys and the number, and
+  // the country code is a workshop setting rather than the vendor's.
+  const [route, countryRow] = await Promise.all([
+    smsRoute(organizationId),
     db.appSetting.findUnique({
       where: {
         organizationId_key: {
@@ -231,8 +261,9 @@ export async function sendOrgSms(
     }),
   ])
 
-  const provider = settings.get(ORG_SMS_KEYS.SMS_PROVIDER)
-  if (provider !== 'twilio' && provider !== 'vonage' && provider !== 'telnyx') {
+  const settings = route?.settings ?? new Map<string, string>()
+  const provider = providerOf(settings)
+  if (!route || !provider) {
     throw new Error('SMS is not configured. Set up an SMS provider in Settings.')
   }
 
@@ -251,18 +282,28 @@ export async function sendOrgSms(
     )
   }
 
-  switch (provider) {
-    case 'twilio': {
-      const result = await sendViaTwilio(settings, from, normalizedTo, options.body)
-      return { providerMsgId: result.sid, to: normalizedTo }
-    }
-    case 'vonage': {
-      const result = await sendViaVonage(settings, from, normalizedTo, options.body)
-      return { providerMsgId: result.messageId, to: normalizedTo }
-    }
-    case 'telnyx': {
-      const result = await sendViaTelnyx(settings, from, normalizedTo, options.body)
-      return { providerMsgId: result.id, to: normalizedTo }
+  const send = async (): Promise<SendSmsResult> => {
+    switch (provider) {
+      case 'twilio': {
+        const result = await sendViaTwilio(settings, from, normalizedTo, options.body)
+        return { providerMsgId: result.sid, to: normalizedTo }
+      }
+      case 'vonage': {
+        const result = await sendViaVonage(settings, from, normalizedTo, options.body)
+        return { providerMsgId: result.messageId, to: normalizedTo }
+      }
+      case 'telnyx': {
+        const result = await sendViaTelnyx(settings, from, normalizedTo, options.body)
+        return { providerMsgId: result.id, to: normalizedTo }
+      }
     }
   }
+
+  // Counted here rather than in each caller, so the sign-in codes, the
+  // 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.
+  if (route.source === 'platform') {
+    return withPlatformSmsAllowance(organizationId, route.dailyLimit, options.body, send)
+  }
+  return send()
 }