Explorar o código

Telegram QR code on invoices (#369)

* Print the Telegram code the design asks for, on every copy of the invoice

* Quiet the logged error without an empty block
Bernt Christian Egeland hai 3 semanas
pai
achega
dd8f35b0a1

+ 4 - 0
messages/de/settings.json

@@ -1935,6 +1935,10 @@
     "paymentTermsPlaceholderHint": "Es sind keine Zahlungsbedingungen hinterlegt, daher zeigt das Blatt einen Platzhalter, der nicht gedruckt wird.",
     "paymentTermsSetHint": "Ihre Zahlungsbedingungen stammen aus den Zahlungseinstellungen.",
     "paymentTermsLink": "Zahlungseinstellungen",
+    "telegramQr": "Telegram-Code",
+    "telegramQrConnectedHint": "Druckt einen Code, der {link} öffnet, Ihren verbundenen Telegram-Bot. Er erscheint nur auf Rechnungen, die nach dem Einschalten des Blocks ausgestellt wurden.",
+    "telegramQrPlaceholderHint": "Kein Telegram-Bot ist verbunden, daher zeigt das Blatt einen Beispielcode, der nicht gedruckt wird.",
+    "telegramQrLink": "Integrationen",
     "titleText": "Titeltext",
     "titleTextHint": "Wie sich dieses Dokument nennt. Leer gelassen, druckt es seinen eigenen Namen in der Sprache des Lesers.",
     "orgNumberLabel": "Bezeichnung der Organisationsnummer",

+ 4 - 0
messages/en/settings.json

@@ -1935,6 +1935,10 @@
     "paymentTermsPlaceholderHint": "No payment terms are set, so the sheet shows a stand-in that will not print.",
     "paymentTermsSetHint": "Your payment terms come from your payment settings.",
     "paymentTermsLink": "Payment settings",
+    "telegramQr": "Telegram code",
+    "telegramQrConnectedHint": "Prints a code that opens {link}, your connected Telegram bot. It only prints on invoices issued after the block was switched on.",
+    "telegramQrPlaceholderHint": "No Telegram bot is connected, so the sheet shows a sample code that will not print.",
+    "telegramQrLink": "Integrations",
     "titleText": "Title text",
     "titleTextHint": "What this document calls itself. Left empty, it prints its own name in the reader's language.",
     "orgNumberLabel": "Organization number label",

+ 4 - 0
messages/es/settings.json

@@ -1935,6 +1935,10 @@
     "paymentTermsPlaceholderHint": "No hay condiciones de pago definidas, así que la hoja muestra un texto de ejemplo que no se imprimirá.",
     "paymentTermsSetHint": "Tus condiciones de pago provienen de los ajustes de pago.",
     "paymentTermsLink": "Ajustes de pago",
+    "telegramQr": "Código de Telegram",
+    "telegramQrConnectedHint": "Imprime un código que abre {link}, tu bot de Telegram conectado. Solo aparece en facturas emitidas después de activar el bloque.",
+    "telegramQrPlaceholderHint": "No hay ningún bot de Telegram conectado, así que la hoja muestra un código de ejemplo que no se imprimirá.",
+    "telegramQrLink": "Integraciones",
     "titleText": "Texto del título",
     "titleTextHint": "Cómo se llama este documento. Si lo dejas vacío, imprime su propio nombre en el idioma del lector.",
     "orgNumberLabel": "Etiqueta del número de organización",

+ 4 - 0
messages/fr/settings.json

@@ -1935,6 +1935,10 @@
     "paymentTermsPlaceholderHint": "Aucune condition de paiement n'est définie, la feuille affiche donc un texte d'exemple qui ne sera pas imprimé.",
     "paymentTermsSetHint": "Vos conditions de paiement proviennent des paramètres de paiement.",
     "paymentTermsLink": "Paramètres de paiement",
+    "telegramQr": "Code Telegram",
+    "telegramQrConnectedHint": "Imprime un code qui ouvre {link}, votre bot Telegram connecté. Il n'apparaît que sur les factures émises après l'activation du bloc.",
+    "telegramQrPlaceholderHint": "Aucun bot Telegram n'est connecté, la feuille affiche donc un code d'exemple qui ne sera pas imprimé.",
+    "telegramQrLink": "Intégrations",
     "titleText": "Texte du titre",
     "titleTextHint": "Le nom que porte ce document. Laissé vide, il imprime son propre nom dans la langue du lecteur.",
     "orgNumberLabel": "Libellé du numéro d'organisation",

+ 4 - 0
messages/it/settings.json

@@ -1935,6 +1935,10 @@
     "paymentTermsPlaceholderHint": "Nessun termine di pagamento impostato, quindi il foglio mostra un segnaposto che non verrà stampato.",
     "paymentTermsSetHint": "I termini di pagamento provengono dalle impostazioni di pagamento.",
     "paymentTermsLink": "Impostazioni di pagamento",
+    "telegramQr": "Codice Telegram",
+    "telegramQrConnectedHint": "Stampa un codice che apre {link}, il tuo bot Telegram collegato. Compare solo sulle fatture emesse dopo l'attivazione del blocco.",
+    "telegramQrPlaceholderHint": "Nessun bot Telegram è collegato, quindi il foglio mostra un codice di esempio che non verrà stampato.",
+    "telegramQrLink": "Integrazioni",
     "titleText": "Testo del titolo",
     "titleTextHint": "Come si chiama questo documento. Se lasciato vuoto, stampa il proprio nome nella lingua del lettore.",
     "orgNumberLabel": "Etichetta del numero di organizzazione",

+ 4 - 0
messages/lt/settings.json

@@ -1935,6 +1935,10 @@
     "paymentTermsPlaceholderHint": "Mokėjimo sąlygos nenustatytos, todėl lape rodomas pavyzdinis tekstas, kuris nebus spausdinamas.",
     "paymentTermsSetHint": "Jūsų mokėjimo sąlygos imamos iš mokėjimų nustatymų.",
     "paymentTermsLink": "Mokėjimų nustatymai",
+    "telegramQr": "Telegram kodas",
+    "telegramQrConnectedHint": "Spausdina kodą, atidarantį {link}, jūsų prijungtą Telegram botą. Jis rodomas tik sąskaitose, išrašytose įjungus bloką.",
+    "telegramQrPlaceholderHint": "Telegram botas neprijungtas, todėl lape rodomas pavyzdinis kodas, kuris nebus spausdinamas.",
+    "telegramQrLink": "Integracijos",
     "titleText": "Pavadinimo tekstas",
     "titleTextHint": "Kaip vadinasi šis dokumentas. Palikus tuščią, spausdinamas jo paties pavadinimas skaitytojo kalba.",
     "orgNumberLabel": "Organizacijos numerio pavadinimas",

+ 4 - 0
messages/nb/settings.json

@@ -1935,6 +1935,10 @@
     "paymentTermsPlaceholderHint": "Det er ikke satt noen betalingsvilkår, så arket viser en plassholder som ikke skrives ut.",
     "paymentTermsSetHint": "Betalingsvilkårene hentes fra betalingsinnstillingene dine.",
     "paymentTermsLink": "Betalingsinnstillinger",
+    "telegramQr": "Telegram-kode",
+    "telegramQrConnectedHint": "Skriver ut en kode som åpner {link}, Telegram-boten din. Den vises bare på fakturaer utstedt etter at blokken ble slått på.",
+    "telegramQrPlaceholderHint": "Ingen Telegram-bot er koblet til, så arket viser en eksempelkode som ikke blir skrevet ut.",
+    "telegramQrLink": "Integrasjoner",
     "titleText": "Titteltekst",
     "titleTextHint": "Hva dette dokumentet kaller seg. Står feltet tomt, skrives dokumentets eget navn på leserens språk.",
     "orgNumberLabel": "Betegnelse på organisasjonsnummer",

+ 4 - 0
messages/nl/settings.json

@@ -1935,6 +1935,10 @@
     "paymentTermsPlaceholderHint": "Er zijn geen betalingsvoorwaarden ingesteld, dus het blad toont een voorbeeldtekst die niet wordt afgedrukt.",
     "paymentTermsSetHint": "Je betalingsvoorwaarden komen uit je betaalinstellingen.",
     "paymentTermsLink": "Betaalinstellingen",
+    "telegramQr": "Telegram-code",
+    "telegramQrConnectedHint": "Drukt een code af die {link} opent, uw gekoppelde Telegram-bot. Hij verschijnt alleen op facturen die zijn uitgegeven nadat het blok is ingeschakeld.",
+    "telegramQrPlaceholderHint": "Er is geen Telegram-bot gekoppeld, dus het blad toont een voorbeeldcode die niet wordt afgedrukt.",
+    "telegramQrLink": "Integraties",
     "titleText": "Titeltekst",
     "titleTextHint": "Hoe dit document zichzelf noemt. Laat je het leeg, dan drukt het zijn eigen naam af in de taal van de lezer.",
     "orgNumberLabel": "Naam van het organisatienummer",

+ 4 - 0
messages/pl/settings.json

@@ -1935,6 +1935,10 @@
     "paymentTermsPlaceholderHint": "Nie ustawiono warunków płatności, więc arkusz pokazuje tekst zastępczy, który nie zostanie wydrukowany.",
     "paymentTermsSetHint": "Twoje warunki płatności pochodzą z ustawień płatności.",
     "paymentTermsLink": "Ustawienia płatności",
+    "telegramQr": "Kod Telegram",
+    "telegramQrConnectedHint": "Drukuje kod otwierający {link}, Twojego podłączonego bota Telegram. Pojawia się tylko na fakturach wystawionych po włączeniu bloku.",
+    "telegramQrPlaceholderHint": "Żaden bot Telegram nie jest podłączony, więc arkusz pokazuje przykładowy kod, który nie zostanie wydrukowany.",
+    "telegramQrLink": "Integracje",
     "titleText": "Tekst tytułu",
     "titleTextHint": "Jak nazywa się ten dokument. Pozostawione puste, drukuje własną nazwę w języku odbiorcy.",
     "orgNumberLabel": "Nazwa numeru organizacji",

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

@@ -1935,6 +1935,10 @@
     "paymentTermsPlaceholderHint": "Nenhuma condição de pagamento definida, então a folha mostra um texto de exemplo que não será impresso.",
     "paymentTermsSetHint": "Suas condições de pagamento vêm das configurações de pagamento.",
     "paymentTermsLink": "Configurações de pagamento",
+    "telegramQr": "Código do Telegram",
+    "telegramQrConnectedHint": "Imprime um código que abre {link}, seu bot do Telegram conectado. Aparece apenas em faturas emitidas depois que o bloco foi ativado.",
+    "telegramQrPlaceholderHint": "Nenhum bot do Telegram está conectado, então a folha mostra um código de exemplo que não será impresso.",
+    "telegramQrLink": "Integrações",
     "titleText": "Texto do título",
     "titleTextHint": "Como este documento se chama. Deixado vazio, imprime o próprio nome no idioma do leitor.",
     "orgNumberLabel": "Rótulo do número da organização",

+ 4 - 0
messages/ru/settings.json

@@ -1935,6 +1935,10 @@
     "paymentTermsPlaceholderHint": "Условия оплаты не заданы, поэтому на листе показан образец, который не будет напечатан.",
     "paymentTermsSetHint": "Условия оплаты берутся из настроек оплаты.",
     "paymentTermsLink": "Настройки оплаты",
+    "telegramQr": "Код Telegram",
+    "telegramQrConnectedHint": "Печатает код, открывающий {link}, ваш подключённый бот Telegram. Появляется только на счетах, выставленных после включения блока.",
+    "telegramQrPlaceholderHint": "Бот Telegram не подключён, поэтому на листе показан образец кода, который не будет напечатан.",
+    "telegramQrLink": "Интеграции",
     "titleText": "Текст заголовка",
     "titleTextHint": "Как называется этот документ. Если оставить пустым, печатается его собственное название на языке читателя.",
     "orgNumberLabel": "Название номера организации",

+ 4 - 0
messages/tr/settings.json

@@ -1935,6 +1935,10 @@
     "paymentTermsPlaceholderHint": "Ödeme koşulları tanımlanmadığı için sayfada yazdırılmayacak bir örnek metin gösteriliyor.",
     "paymentTermsSetHint": "Ödeme koşullarınız ödeme ayarlarınızdan gelir.",
     "paymentTermsLink": "Ödeme ayarları",
+    "telegramQr": "Telegram kodu",
+    "telegramQrConnectedHint": "{link} adresini açan bir kod basar; bu bağlı Telegram botunuzdur. Yalnızca blok açıldıktan sonra kesilen faturalarda görünür.",
+    "telegramQrPlaceholderHint": "Bağlı bir Telegram botu yok, bu yüzden sayfa basılmayacak bir örnek kod gösterir.",
+    "telegramQrLink": "Entegrasyonlar",
     "titleText": "Başlık metni",
     "titleTextHint": "Bu belgenin kendine verdiği ad. Boş bırakılırsa kendi adını okuyucunun dilinde yazdırır.",
     "orgNumberLabel": "Organizasyon numarası etiketi",

+ 142 - 0
src/__tests__/features/invoices/telegram-qr.test.ts

@@ -0,0 +1,142 @@
+// @vitest-environment node
+/**
+ * The Telegram code on a sheet: printed only when the design has the block
+ * on and a bot is connected, resolved the same way for every copy, and
+ * placed inside the sheet by the spec builder so the PDF, the shared page
+ * and the portal all carry it in the same spot.
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+// A plain function behind the mock rather than a vi.fn: a spy whose
+// implementation throws is re-reported by the runner after a reset, which
+// is not what this file is testing.
+let lookup: (orgId: string) => Promise<string | null> = async () => null
+const lookups: string[] = []
+vi.mock('@/lib/telegram', () => ({
+  getOrgTelegramBotUsername: (orgId: string) => {
+    lookups.push(orgId)
+    return lookup(orgId)
+  },
+}))
+
+import { buildInvoicePrintSpec } from '@/features/invoice-designer/Pdf/buildInvoicePrint'
+import {
+  telegramBotLink,
+  telegramQrForPrint,
+  telegramQrWanted,
+} from '@/features/invoices/Lib/telegramQr'
+import { getDefaultInvoiceLayout } from '@/features/settings/Schema/invoiceLayoutSchema'
+import type { InvoiceData } from '@/features/vehicles/Components/invoice-pdf/types'
+
+function layoutWithTelegram(visible: boolean) {
+  const layout = getDefaultInvoiceLayout()
+  return {
+    ...layout,
+    sections: layout.sections.map((s) => (s.id === 'telegram_qr' ? { ...s, visible } : s)),
+  }
+}
+
+describe('telegramQrForPrint', () => {
+  beforeEach(() => {
+    lookup = async () => null
+    lookups.length = 0
+  })
+
+  it('is off by default: the block starts hidden', () => {
+    expect(telegramQrWanted(getDefaultInvoiceLayout())).toBe(false)
+    expect(telegramQrWanted(layoutWithTelegram(true))).toBe(true)
+  })
+
+  it('prints nothing when the design leaves the block off, without asking for the bot', async () => {
+    expect(await telegramQrForPrint('org1', layoutWithTelegram(false))).toBeNull()
+    expect(lookups).toEqual([])
+  })
+
+  it('prints nothing when no bot is connected', async () => {
+    lookup = async () => null
+    expect(await telegramQrForPrint('org1', layoutWithTelegram(true))).toBeNull()
+  })
+
+  it('encodes the connected bot link when the block is on', async () => {
+    lookup = async () => 'eigeland_bot'
+    const qr = await telegramQrForPrint('org1', layoutWithTelegram(true))
+    expect(qr?.link).toBe('https://t.me/eigeland_bot')
+    expect(qr?.dataUri.startsWith('data:image/png;base64,')).toBe(true)
+    expect(lookups).toEqual(['org1'])
+  })
+
+  it('never lets a broken integration cost the document', async () => {
+    const quiet = vi.spyOn(console, 'error').mockImplementation(() => undefined)
+    lookup = async () => {
+      throw new Error('catalogue down')
+    }
+    expect(await telegramQrForPrint('org1', layoutWithTelegram(true))).toBeNull()
+    expect(quiet).toHaveBeenCalledOnce()
+    quiet.mockRestore()
+  })
+
+  it('reads a username with or without the @', () => {
+    expect(telegramBotLink('@shop_bot')).toBe('https://t.me/shop_bot')
+    expect(telegramBotLink('shop_bot')).toBe('https://t.me/shop_bot')
+  })
+})
+
+const invoice = {
+  id: 'svc_1',
+  title: 'Oil change',
+  description: null,
+  type: 'repair',
+  serviceDate: new Date('2026-09-01'),
+  shopName: 'Testshop',
+  techName: null,
+  mileage: null,
+  diagnosticNotes: null,
+  invoiceNotes: null,
+  subtotal: 100,
+  taxRate: 25,
+  taxAmount: 25,
+  totalAmount: 125,
+  cost: 125,
+  invoiceNumber: 'INV-1',
+  partItems: [],
+  laborItems: [{ description: 'Oil change', hours: 1, rate: 100, total: 100 }],
+  customFields: [],
+} as unknown as InvoiceData
+
+/** Every image node in a spec, wherever it is nested. */
+function images(node: unknown, out: string[] = []): string[] {
+  if (!node || typeof node !== 'object') return out
+  const n = node as Record<string, unknown>
+  if (n.kind === 'image' && typeof n.src === 'string') out.push(n.src)
+  for (const value of Object.values(n)) {
+    if (Array.isArray(value)) for (const item of value) images(item, out)
+    else if (value && typeof value === 'object') images(value, out)
+  }
+  return out
+}
+
+describe('the code in the sheet', () => {
+  const qr = 'data:image/png;base64,QR'
+
+  it('is drawn inside the sheet when the block is on and a code is given', () => {
+    const spec = buildInvoicePrintSpec({
+      data: invoice,
+      workshop: { name: 'Testshop', address: '', phone: '', email: '' },
+      template: { layoutConfig: layoutWithTelegram(true) },
+      telegramQrDataUri: qr,
+      telegramLabel: 'Snakk med oss på Telegram',
+    })
+    expect(images(spec)).toContain(qr)
+    expect(JSON.stringify(spec)).toContain('Snakk med oss på Telegram')
+  })
+
+  it('is left out when the block is off, even with a code in hand', () => {
+    const spec = buildInvoicePrintSpec({
+      data: invoice,
+      workshop: { name: 'Testshop', address: '', phone: '', email: '' },
+      template: { layoutConfig: layoutWithTelegram(false) },
+      telegramQrDataUri: qr,
+    })
+    expect(images(spec)).not.toContain(qr)
+  })
+})

+ 5 - 0
src/app/(designer)/invoice-designer/page.tsx

@@ -14,6 +14,8 @@ import { InvoiceDesigner } from '@/features/invoice-designer/Components/InvoiceD
 import { DismissOnArrival } from '@/components/feature-hint'
 import { INVOICE_DESIGNER_ANNOUNCEMENT, parseHintIds } from '@/features/settings/Lib/featureHints'
 import type { SavedDesign } from '@/features/invoice-designer/Components/types'
+import { telegramBotLink as botLinkOf } from '@/features/invoices/Lib/telegramQr'
+import { getOrgTelegramBotUsername } from '@/lib/telegram'
 
 export default async function InvoiceDesignerPage({
   searchParams,
@@ -38,6 +40,7 @@ export default async function InvoiceDesignerPage({
     seenRow,
     invoiceDesigns,
     quoteDesigns,
+    telegramBotUsername,
   ] = await Promise.all([
     getSettings(),
     getInvoiceLayoutConfig(),
@@ -58,6 +61,7 @@ export default async function InvoiceDesignerPage({
     }),
     listDocumentDesigns('invoice'),
     listDocumentDesigns('quote'),
+    getOrgTelegramBotUsername(data.organizationId),
   ])
 
   // Somebody is looking at the designer, so the workshop knows it exists. Only
@@ -111,6 +115,7 @@ export default async function InvoiceDesignerPage({
         invoiceTemplate={templateFor('invoice')}
         quoteTemplate={templateFor('quote')}
         initialSavedDesigns={savedDesigns}
+        telegramBotLink={telegramBotUsername ? botLinkOf(telegramBotUsername) : undefined}
         workshop={{
           name: organization?.name || '',
           address: settings[SETTING_KEYS.WORKSHOP_ADDRESS] || '',

+ 0 - 19
src/app/(public)/share/invoice/[orgId]/[token]/invoice-view.tsx

@@ -13,7 +13,6 @@ import {
   Paperclip,
   X,
 } from 'lucide-react'
-import { QRCodeSVG } from 'qrcode.react'
 import { formatCurrency, formatDate as fmtDate, DEFAULT_DATE_FORMAT } from '@/lib/format'
 import { calculateTotals, netLineTotal } from '@/lib/tax'
 import { useLocale, useTranslations } from 'next-intl'
@@ -138,12 +137,6 @@ interface CustomField {
   fieldId?: string
 }
 
-function isSectionVisible(config: InvoiceLayoutConfig | undefined, sectionId: string): boolean {
-  if (!config) return true
-  const section = config.sections.find((s) => s.id === sectionId)
-  return section?.visible ?? true
-}
-
 function isFieldVisible(
   config: InvoiceLayoutConfig | undefined,
   sectionId: string,
@@ -238,7 +231,6 @@ export function InvoiceView({
   layoutConfig,
   customFields = [],
   findings = [],
-  telegramBotLink,
   serviceType = 'automotive',
   taxLabel,
 }: {
@@ -266,7 +258,6 @@ export function InvoiceView({
   layoutConfig?: InvoiceLayoutConfig
   customFields?: CustomField[]
   findings?: Array<{ description: string; severity: string; notes: string | null }>
-  telegramBotLink?: string
   serviceType?: 'automotive' | 'marine'
   taxLabel?: string
 }) {
@@ -697,16 +688,6 @@ export function InvoiceView({
       <SpecSheet spec={spec} />
 
       <div className="mt-6 space-y-6 empty:hidden">
-        {telegramBotLink && isSectionVisible(layoutConfig, 'telegram_qr') && (
-          <div className="flex flex-col items-center gap-2 rounded-xl border bg-white p-5 shadow-sm dark:bg-gray-900">
-            <p className="text-sm font-medium text-foreground">{tc('telegramConnect')}</p>
-            <div className="rounded-lg bg-white p-2">
-              <QRCodeSVG value={telegramBotLink} size={100} />
-            </div>
-            <p className="text-center text-xs text-muted-foreground">{tc('telegramScan')}</p>
-          </div>
-        )}
-
         {/* Service Images (not part of layout config sections) */}
         {imageAttachments.length > 0 && (
           <div className="rounded-xl border bg-white p-5 shadow-sm dark:bg-gray-900">

+ 9 - 10
src/app/(public)/share/invoice/[orgId]/[token]/page.tsx

@@ -10,7 +10,7 @@ import { resolveCustomerLocale } from '@/i18n/locale-from-request'
 import { getTorqvoiceLogoDataUri } from '@/lib/torqvoice-branding'
 import { headers } from 'next/headers'
 import type { Metadata } from 'next'
-import { getOrgTelegramBotUsername } from '@/lib/telegram'
+import { telegramQrForPrint } from '@/features/invoices/Lib/telegramQr'
 import { offeredPaymentProviders } from '@/features/integrations/Lib/payments'
 import { getAppBaseUrl } from '@/lib/app-url'
 
@@ -112,9 +112,12 @@ export default async function PublicInvoicePage({
   const locale = await resolveCustomerLocale(orgId, acceptLanguage)
   const labels = await loadPrintLabels(locale, assembly.labelSettings)
 
-  const torqvoiceLogoDataUri = features.brandingRemoved
-    ? undefined
-    : await getTorqvoiceLogoDataUri()
+  // The Telegram code sits in the sheet, where the PDF prints it, so the
+  // shared copy and the download are the same document.
+  const [torqvoiceLogoDataUri, telegramQr] = await Promise.all([
+    features.brandingRemoved ? undefined : getTorqvoiceLogoDataUri(),
+    telegramQrForPrint(orgId, assembly.layoutConfig),
+  ])
 
   const spec = buildInvoicePrintSpec({
     data: assembly.data,
@@ -124,6 +127,8 @@ export default async function PublicInvoicePage({
     logoDataUri: assembly.logoDataUri,
     template: assembly.template,
     torqvoiceLogoDataUri,
+    telegramQrDataUri: telegramQr?.dataUri,
+    telegramLabel: labels?.telegramConnect,
     labels,
   })
 
@@ -136,11 +141,6 @@ export default async function PublicInvoicePage({
   const portalEnabled = settingsMap['portal.enabled'] === 'true'
   const portalUrl = portalEnabled ? `${appUrl}/portal/${portalSlug || orgId}` : undefined
 
-  // The bot follows the Telegram integration, whichever side of the move it
-  // was connected on.
-  const telegramBotUsername = (await getOrgTelegramBotUsername(orgId)) || ''
-  const telegramBotLink = telegramBotUsername ? `https://t.me/${telegramBotUsername}` : undefined
-
   return (
     <InvoiceView
       spec={spec}
@@ -166,7 +166,6 @@ export default async function PublicInvoicePage({
       layoutConfig={assembly.layoutConfig}
       customFields={assembly.data.customFields}
       findings={assembly.data.findings}
-      telegramBotLink={telegramBotLink}
       serviceType={assembly.serviceType}
       taxLabel={assembly.taxLabel}
     />

+ 3 - 10
src/app/api/protected/services/[id]/pdf/route.ts

@@ -12,7 +12,7 @@ import { resolveUploadPath } from '@/lib/resolve-upload-path'
 import { getFeatures } from '@/lib/features'
 import { getTorqvoiceLogoDataUri } from '@/lib/torqvoice-branding'
 import { markInvoiceIssued } from '@/features/onboarding/Lib/markInvoiceIssued'
-import { getOrgTelegramBotUsername } from '@/lib/telegram'
+import { telegramQrForPrint } from '@/features/invoices/Lib/telegramQr'
 import { loadPrintLabels } from '@/features/invoice-designer/Pdf/printLabels'
 import { assembleInvoicePrint, invoiceNumberOf } from '@/features/invoices/Lib/assembleInvoicePrint'
 import { getAppBaseUrl } from '@/lib/app-url'
@@ -109,14 +109,7 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
       ? `${appUrl}/portal/${portalSlug || ctx.organizationId}`
       : undefined
 
-    // Generate Telegram QR if the telegram_qr section is visible in layout
-    let telegramQrDataUri: string | undefined
-    const telegramBotUsername = await getOrgTelegramBotUsername(ctx.organizationId)
-    const telegramQrVisible = layoutConfig.sections.some((s) => s.id === 'telegram_qr' && s.visible)
-    if (telegramBotUsername && telegramQrVisible) {
-      const { generateQrDataUri } = await import('@/lib/qr')
-      telegramQrDataUri = await generateQrDataUri(`https://t.me/${telegramBotUsername}`, 200)
-    }
+    const telegramQr = await telegramQrForPrint(ctx.organizationId, layoutConfig)
 
     const element = React.createElement(InvoicePDF, {
       data: assembly.data,
@@ -130,7 +123,7 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
       template: assembly.template,
       torqvoiceLogoDataUri,
       portalUrl,
-      telegramQrDataUri,
+      telegramQrDataUri: telegramQr?.dataUri,
       telegramLabel: labels?.telegramConnect || 'Chat with us on Telegram',
       labels,
       // eslint-disable-next-line @typescript-eslint/no-explicit-any

+ 26 - 0
src/features/invoice-designer/Components/DesignerInspector.tsx

@@ -326,6 +326,7 @@ export function DesignerInspector({
   orgNumberLabel,
   orgNumberLabelDefault,
   onOrgNumberLabel,
+  telegramBotLink,
 }: {
   layout: InvoiceLayoutConfig
   template: DesignerTemplate
@@ -354,6 +355,8 @@ export function DesignerInspector({
   /** The default caption in the reader's language, shown as the placeholder. */
   orgNumberLabelDefault: string
   onOrgNumberLabel: (value: string) => void
+  /** The connected Telegram bot's link; the block is a stand-in without one. */
+  telegramBotLink?: string
 }) {
   const t = useTranslations('settings.designer')
   const tSection = useTranslations('settings.layoutEditor.sections')
@@ -705,6 +708,29 @@ export function DesignerInspector({
           )}
           {section.id === 'bank_account' && orgNumberLabelGroup}
 
+          {/* The code is only as real as the bot behind it. Connected, the
+              sheet shows the workshop's own link; otherwise a stand-in that
+              prints as nothing, and the way to the integration. */}
+          {section.id === 'telegram_qr' && (
+            <Group title={t('telegramQr')}>
+              <p className="text-[11.5px] leading-snug text-[#8a8f97]">
+                {telegramBotLink
+                  ? t('telegramQrConnectedHint', {
+                      link: telegramBotLink.replace(/^https?:\/\//, ''),
+                    })
+                  : t('telegramQrPlaceholderHint')}{' '}
+                <a
+                  href="/settings/integrations"
+                  target="_blank"
+                  rel="noreferrer"
+                  className="font-medium text-[#2563eb] underline underline-offset-2"
+                >
+                  {t('telegramQrLink')}
+                </a>
+              </p>
+            </Group>
+          )}
+
           {section.id === 'header' && (
             <Group title={t('logo')}>
               <LogoUpload value={logoUrl} own={ownLogo} onChange={onLogo} />

+ 34 - 3
src/features/invoice-designer/Components/InvoiceDesigner.tsx

@@ -77,6 +77,7 @@ export function InvoiceDesigner({
   workshop: companyWorkshop,
   customFields: serviceCustomFields,
   quoteCustomFields = [],
+  telegramBotLink,
 }: {
   initialDocumentType: DocumentType
   initialView: 'gallery' | 'designer'
@@ -94,6 +95,8 @@ export function InvoiceDesigner({
   workshop: DesignerWorkshop
   customFields: DesignerFieldDef[]
   quoteCustomFields?: DesignerFieldDef[]
+  /** The connected Telegram bot's t.me link, or nothing when no bot is set up. */
+  telegramBotLink?: string
 }) {
   const router = useRouter()
   const t = useTranslations('settings.designer')
@@ -204,8 +207,9 @@ export function InvoiceDesigner({
     const ids: string[] = []
     if (!companyWorkshop.slogan?.trim()) ids.push('slogan')
     if (!companyWorkshop.paymentTerms?.trim()) ids.push('payment_terms')
+    if (!telegramBotLink) ids.push('telegram_qr')
     return new Set(ids)
-  }, [companyWorkshop.slogan, companyWorkshop.paymentTerms])
+  }, [companyWorkshop.slogan, companyWorkshop.paymentTerms, telegramBotLink])
   // What this document actually prints: its own mark when it has one, the
   // company logo otherwise. The same fallback the print routes apply, so the
   // canvas cannot promise a picture the paper will not carry.
@@ -274,10 +278,36 @@ export function InvoiceDesigner({
     [docType]
   )
 
+  // The Telegram block draws a real code: the workshop's own bot when one is
+  // connected, a stand-in otherwise, marked as such. Without it the block
+  // drew nothing at all and looked broken the moment it was switched on.
+  // Encoded in the browser, only while the block is on the sheet.
+  const telegramOn = layout.sections.some((s) => s.id === 'telegram_qr' && s.visible)
+  const [telegramQrDataUri, setTelegramQrDataUri] = useState<string>()
+  useEffect(() => {
+    if (!telegramOn) return
+    let cancelled = false
+    const link = telegramBotLink || 'https://t.me/torqvoice'
+    import('qrcode').then((QRCode) =>
+      QRCode.toDataURL(link, { width: 200, margin: 1 }).then((uri) => {
+        if (!cancelled) setTelegramQrDataUri(uri)
+      })
+    )
+    return () => {
+      cancelled = true
+    }
+  }, [telegramOn, telegramBotLink])
+
   /** What a workshop's own sheet says, with the sample standing in for a job. */
   const data: DocumentData = useMemo(
-    () => buildSampleData(workshop, customFields, t, printLabels, docType),
-    [workshop, customFields, t, printLabels, docType]
+    () => ({
+      ...buildSampleData(workshop, customFields, t, printLabels, docType),
+      telegramQr:
+        telegramOn && telegramQrDataUri
+          ? { dataUri: telegramQrDataUri, label: L('telegramConnect', 'Chat with us on Telegram') }
+          : undefined,
+    }),
+    [workshop, customFields, t, printLabels, docType, telegramOn, telegramQrDataUri, L]
   )
 
   const spec = useMemo(
@@ -1037,6 +1067,7 @@ export function InvoiceDesigner({
           orgNumberLabel={orgNumberLabel}
           orgNumberLabelDefault={messages.pdf?.invoice?.orgNumberLabel ?? 'Org. Number'}
           onOrgNumberLabel={setOrgNumberLabel}
+          telegramBotLink={telegramBotLink}
           onLogo={(url) => setTemplate({ logoUrl: url })}
         />
       </div>

+ 54 - 0
src/features/invoices/Lib/telegramQr.ts

@@ -0,0 +1,54 @@
+import type { InvoiceLayoutConfig } from '@/features/settings/Schema/invoiceLayoutSchema'
+import { generateQrDataUri } from '@/lib/qr'
+import { getOrgTelegramBotUsername } from '@/lib/telegram'
+
+/**
+ * The Telegram code a sheet prints, resolved the same way for every copy.
+ *
+ * Whether it prints is the design's decision: the Telegram QR block is
+ * switched on or off in the designer, and the layout the sheet is built from
+ * (an issued invoice's frozen one, a draft's live one) is what is asked.
+ * What it links to is the connected bot's, read through the integration so
+ * a bot set up before the catalogue existed is found as well. One place for
+ * both questions, because the PDF, the shared page and the portal used to
+ * answer them separately and drifted: one still read the bot's name from a
+ * row the integration no longer writes, and printed nothing.
+ */
+
+export const TELEGRAM_QR_SECTION = 'telegram_qr'
+
+/** Whether the design has the Telegram block switched on. */
+export function telegramQrWanted(layout: Pick<InvoiceLayoutConfig, 'sections'>): boolean {
+  return layout.sections.some((s) => s.id === TELEGRAM_QR_SECTION && s.visible)
+}
+
+/** The t.me link a bot's username resolves to. */
+export function telegramBotLink(username: string): string {
+  return `https://t.me/${username.replace(/^@/, '')}`
+}
+
+export interface TelegramQr {
+  link: string
+  dataUri: string
+}
+
+/**
+ * The code to print for this organisation's sheet, or null when the design
+ * leaves the block off or no bot is connected. A failing lookup or encoder
+ * costs the code, never the document.
+ */
+export async function telegramQrForPrint(
+  organizationId: string,
+  layout: Pick<InvoiceLayoutConfig, 'sections'>
+): Promise<TelegramQr | null> {
+  if (!telegramQrWanted(layout)) return null
+  try {
+    const username = await getOrgTelegramBotUsername(organizationId)
+    if (!username) return null
+    const link = telegramBotLink(username)
+    return { link, dataUri: await generateQrDataUri(link, 200) }
+  } catch (error) {
+    console.error('[telegram] Could not build the invoice QR code:', error)
+    return null
+  }
+}

+ 3 - 11
src/features/invoices/Pdf/buildInvoicePdfBuffer.ts

@@ -21,6 +21,7 @@ import { getTorqvoiceLogoDataUri } from '@/lib/torqvoice-branding'
 import { resolveCustomerLocale } from '@/i18n/locale-from-request'
 import { loadPrintLabels } from '@/features/invoice-designer/Pdf/printLabels'
 import { assembleInvoicePrint, invoiceNumberOf } from '../Lib/assembleInvoicePrint'
+import { telegramQrForPrint } from '@/features/invoices/Lib/telegramQr'
 import { getAppBaseUrl } from '@/lib/app-url'
 
 export async function buildInvoicePdfBuffer(
@@ -46,16 +47,7 @@ export async function buildInvoicePdfBuffer(
   const portalEnabled = settingsMap['portal.enabled'] === 'true'
   const portalUrl = portalEnabled ? `${appUrl}/portal/${portalSlug || orgId}` : undefined
 
-  // Generate Telegram QR if the telegram_qr section is visible in layout
-  let telegramQrDataUri: string | undefined
-  const telegramBotUsername = settingsMap['telegram.botUsername']
-  const telegramQrVisible = layoutConfig.sections.some(
-    (s: { id: string; visible: boolean }) => s.id === 'telegram_qr' && s.visible
-  )
-  if (telegramBotUsername && telegramQrVisible) {
-    const { generateQrDataUri } = await import('@/lib/qr')
-    telegramQrDataUri = await generateQrDataUri(`https://t.me/${telegramBotUsername}`, 200)
-  }
+  const telegramQr = await telegramQrForPrint(orgId, layoutConfig)
 
   const element = React.createElement(InvoicePDF, {
     data: assembly.data,
@@ -66,7 +58,7 @@ export async function buildInvoicePdfBuffer(
     template: assembly.template,
     torqvoiceLogoDataUri,
     portalUrl,
-    telegramQrDataUri,
+    telegramQrDataUri: telegramQr?.dataUri,
     telegramLabel: labels?.telegramConnect || 'Chat with us on Telegram',
     labels,
     // eslint-disable-next-line @typescript-eslint/no-explicit-any

+ 5 - 1
src/features/portal/Actions/portalActions.ts

@@ -13,6 +13,7 @@ import { headers } from 'next/headers'
 import { buildInvoicePrintSpec } from '@/features/invoice-designer/Pdf/buildInvoicePrint'
 import { loadPrintLabels } from '@/features/invoice-designer/Pdf/printLabels'
 import { assembleInvoicePrint } from '@/features/invoices/Lib/assembleInvoicePrint'
+import { telegramQrForPrint } from '@/features/invoices/Lib/telegramQr'
 import { resolveCustomerLocale } from '@/i18n/locale-from-request'
 import { getFeatures } from '@/lib/features'
 import { getTorqvoiceLogoDataUri } from '@/lib/torqvoice-branding'
@@ -303,9 +304,10 @@ export async function getPortalInvoiceSheet(invoiceId: string) {
 
     const acceptLanguage = (await headers()).get('accept-language')
     const locale = await resolveCustomerLocale(organizationId, acceptLanguage)
-    const [labels, features] = await Promise.all([
+    const [labels, features, telegramQr] = await Promise.all([
       loadPrintLabels(locale, assembly.labelSettings),
       getFeatures(organizationId),
+      telegramQrForPrint(organizationId, assembly.layoutConfig),
     ])
     const torqvoiceLogoDataUri = features.brandingRemoved
       ? undefined
@@ -319,6 +321,8 @@ export async function getPortalInvoiceSheet(invoiceId: string) {
       logoDataUri: assembly.logoDataUri,
       template: assembly.template,
       torqvoiceLogoDataUri,
+      telegramQrDataUri: telegramQr?.dataUri,
+      telegramLabel: labels?.telegramConnect,
       labels,
     })
 

+ 0 - 1
src/features/settings/Schema/settingsSchema.ts

@@ -158,7 +158,6 @@ export const SETTING_KEYS = {
   TELEGRAM_TEMPLATE_STATUS_IN_PROGRESS: 'telegram.template.statusInProgress',
   TELEGRAM_TEMPLATE_STATUS_COMPLETED: 'telegram.template.statusCompleted',
   TELEGRAM_TEMPLATE_PAYMENT_RECEIVED: 'telegram.template.paymentReceived',
-  TELEGRAM_SHOW_QR_ON_INVOICE: 'telegram.showQrOnInvoice',
   PORTAL_ENABLED: 'portal.enabled',
   PORTAL_DESCRIPTION: 'portal.description',
   PORTAL_HOURS: 'portal.hours',