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

Move online payments into the integrations catalog (#319)

* Move online payments into the integrations catalog

Stripe, Vipps and PayPal were three cards on the payment settings page,
their keys in AppSetting rows and the ones switched on listed in
payment.providersEnabled, which is what every vendor with a key looked like
before the catalog existed. They are connectors now: keys sealed in the
vault, test and sandbox mode as settings, a connection test that proves the
keys before a customer finds out, and the notification URL shown on the
connection page. Unlike AI or messaging, payment vendors run side by side,
so nothing stands another vendor down. Each keeps an "offer on invoice
links" switch so a workshop can pause one without losing its keys.

A workshop that had any vendor switched on keeps taking payments without
touching anything. The first checkout, verification or vendor webhook after
the deploy adopts every enabled vendor with complete keys into an active
connection, as does opening the catalog, a vendor's page or the payment
settings page. One marker, integrations.payments.adoptedAt, records the
move so a later disconnect is not undone. The old rows are read and left
alone, so a rollback finds them where it left them. The shared invoice page
reads the old rows without adopting, so the pay buttons are there on the
first view after the deploy exactly as they were on the last one before.

The payment settings page keeps bank details, payment terms and terms of
sale, and lists where each vendor stands with a link to its connector page.
Webhook URLs are unchanged. The connectors are gated by the payments plan
feature, as the page was.

* Payment logos: Stripe's parallelogram and the Vipps MobilePay smile

* Keep browser autofill out of connector credential fields

* PayPal checkout: a return URL PayPal accepts, and vendor errors go to the connection log

* AI test: silence the console spy without an empty block, which lint refuses
Bernt Christian Egeland 3 недель назад
Родитель
Сommit
55342d81ab
54 измененных файлов с 2090 добавлено и 767 удалено
  1. 49 0
      messages/de/integrations.json
  2. 7 22
      messages/de/settings.json
  3. 49 0
      messages/en/integrations.json
  4. 7 22
      messages/en/settings.json
  5. 49 0
      messages/es/integrations.json
  6. 7 22
      messages/es/settings.json
  7. 49 0
      messages/fr/integrations.json
  8. 7 22
      messages/fr/settings.json
  9. 49 0
      messages/it/integrations.json
  10. 7 22
      messages/it/settings.json
  11. 49 0
      messages/lt/integrations.json
  12. 6 21
      messages/lt/settings.json
  13. 49 0
      messages/nb/integrations.json
  14. 7 22
      messages/nb/settings.json
  15. 49 0
      messages/nl/integrations.json
  16. 7 22
      messages/nl/settings.json
  17. 49 0
      messages/pl/integrations.json
  18. 7 22
      messages/pl/settings.json
  19. 49 0
      messages/pt-BR/integrations.json
  20. 7 22
      messages/pt-BR/settings.json
  21. 49 0
      messages/ru/integrations.json
  22. 7 22
      messages/ru/settings.json
  23. 49 0
      messages/tr/integrations.json
  24. 7 22
      messages/tr/settings.json
  25. 1 0
      public/images/integrations/paypal.svg
  26. 1 0
      public/images/integrations/stripe.svg
  27. 1 0
      public/images/integrations/vipps.svg
  28. 3 1
      src/__tests__/features/integrations/ai.test.ts
  29. 327 0
      src/__tests__/features/integrations/payments.test.ts
  30. 16 1
      src/app/(authenticated)/settings/integrations/[connector]/connection-settings.tsx
  31. 9 2
      src/app/(authenticated)/settings/payment/page.tsx
  32. 96 337
      src/app/(authenticated)/settings/payment/payment-settings.tsx
  33. 7 2
      src/app/(public)/share/invoice/[orgId]/[token]/invoice-view.tsx
  34. 4 6
      src/app/(public)/share/invoice/[orgId]/[token]/page.tsx
  35. 43 27
      src/app/api/public/share/invoice/[orgId]/[token]/checkout/route.ts
  36. 9 15
      src/app/api/public/share/invoice/[orgId]/[token]/verify/route.ts
  37. 4 12
      src/app/api/webhooks/paypal/route.ts
  38. 11 24
      src/app/api/webhooks/stripe/route.ts
  39. 4 12
      src/app/api/webhooks/vipps/route.ts
  40. 77 12
      src/features/integrations/Actions/integrationActions.ts
  41. 2 1
      src/features/integrations/Lib/messaging.ts
  42. 343 0
      src/features/integrations/Lib/payments.ts
  43. 43 0
      src/integrations/payments/build.ts
  44. 199 0
      src/integrations/payments/catalog.ts
  45. 7 0
      src/integrations/paypal/manifest.ts
  46. 31 0
      src/integrations/paypal/server.ts
  47. 6 0
      src/integrations/registry.ts
  48. 11 0
      src/integrations/stripe/manifest.ts
  49. 48 0
      src/integrations/stripe/server.ts
  50. 10 0
      src/integrations/vipps/manifest.ts
  51. 35 0
      src/integrations/vipps/server.ts
  52. 22 47
      src/lib/payment-providers/index.ts
  53. 21 3
      src/lib/payment-providers/paypal.ts
  54. 28 2
      src/lib/payment-providers/vipps.ts

+ 49 - 0
messages/de/integrations.json

@@ -55,6 +55,9 @@
     },
     "ai": {
       "chat": "Texte und Zusammenfassungen"
+    },
+    "payments": {
+      "checkout": "Bezahlen über den Rechnungslink"
     }
   },
   "statuses": {
@@ -105,6 +108,9 @@
     "inboundUrl": "Inbound webhook URL",
     "inboundHintSecret": "Paste this URL into the vendor's inbound message settings. It carries a secret that identifies your workshop, so keep it private.",
     "inboundHintMeta": "Paste this URL as the callback URL in your Meta app, with the verify token you entered above, and subscribe to messages.",
+    "inboundHintStripe": "Fügen Sie diese URL im Stripe-Dashboard unter Developers, Webhooks hinzu, abonniert auf checkout.session.completed. Tragen Sie dann das von Stripe angezeigte Signaturgeheimnis hier im Feld Webhook-Geheimnis ein, damit Benachrichtigungen geprüft werden können.",
+    "inboundHintVipps": "Tragen Sie diese URL als Callback-URL im Vipps-MobilePay-Portal ein. Vipps verlangt außerdem veröffentlichte Verkaufsbedingungen, die unter Einstellungen, Zahlung hinterlegt werden.",
+    "inboundHintPaypal": "Fügen Sie diese URL im PayPal-Entwicklerdashboard unter Webhooks hinzu, abonniert auf PAYMENT.CAPTURE.COMPLETED.",
     "copy": "Copy",
     "copied": "Copied",
     "updateKeys": "Update keys",
@@ -426,6 +432,49 @@
         "model": "Modell",
         "modelHelp": "Welches Modell antwortet. Die Liste kommt aus Ihrem Konto, neueste zuerst. Kleinere Modelle kosten pro Aufruf weniger und reichen für die meisten Werkstatttexte."
       }
+    },
+    "stripe": {
+      "description": "Kartenzahlungen über den Rechnungslink auf Ihr eigenes Stripe-Konto. Der Kunde zahlt, Stripe meldet es Torqvoice, und die Rechnung wird von selbst als bezahlt markiert.",
+      "fields": {
+        "secretKey": "Geheimer Schlüssel",
+        "secretKeyHelp": "Aus dem Stripe-Dashboard unter Developers, API keys. Testschlüssel verarbeiten nichts Echtes, probieren Sie also zuerst eine Zahlung damit und wechseln Sie danach zu Live-Schlüsseln.",
+        "publishableKey": "Veröffentlichbarer Schlüssel",
+        "webhookSecret": "Webhook-Signaturgeheimnis",
+        "webhookSecretHelp": "Wird von Stripe angezeigt, sobald Sie die Benachrichtigungs-URL dieser Seite als Webhook hinzufügen. Damit wird jede Benachrichtigung geprüft, bevor eine Zahlung erfasst wird."
+      },
+      "settings": {
+        "enabled": "Auf Rechnungslinks anbieten",
+        "enabledHelp": "Ausschalten, um den Bezahlknopf eine Weile von geteilten Rechnungen zu nehmen, ohne die Verbindung zu trennen. Bereits begonnene Zahlungen werden weiterhin erfasst."
+      }
+    },
+    "vipps": {
+      "description": "Vipps MobilePay über den Rechnungslink. Der Kunde zahlt in der App, die er ohnehin hat, und die Rechnung wird von selbst als bezahlt markiert.",
+      "fields": {
+        "clientId": "Client-ID",
+        "clientSecret": "Client-Secret",
+        "subscriptionKey": "Subscription Key",
+        "subscriptionKeyHelp": "Der Ocp-Apim-Subscription-Key aus dem Vipps-MobilePay-Portal, unter den API-Schlüsseln der Verkaufseinheit.",
+        "merchantSerialNumber": "Merchant Serial Number"
+      },
+      "settings": {
+        "enabled": "Auf Rechnungslinks anbieten",
+        "enabledHelp": "Ausschalten, um den Bezahlknopf eine Weile von geteilten Rechnungen zu nehmen, ohne die Verbindung zu trennen. Bereits begonnene Zahlungen werden weiterhin erfasst.",
+        "testMode": "Testmodus",
+        "testModeHelp": "Die Vipps-Testumgebung mit Testschlüsseln verwenden. Lassen Sie ihn an, bis eine vollständige Zahlung durchgelaufen ist, und schalten Sie dann um auf die Live-Schlüssel."
+      }
+    },
+    "paypal": {
+      "description": "PayPal-Checkout über den Rechnungslink mit Ihrer eigenen PayPal-App. Der Kunde zahlt mit PayPal-Konto oder Karte, und die Rechnung wird von selbst als bezahlt markiert.",
+      "fields": {
+        "clientId": "Client-ID",
+        "clientSecret": "Client-Secret"
+      },
+      "settings": {
+        "enabled": "Auf Rechnungslinks anbieten",
+        "enabledHelp": "Ausschalten, um den Bezahlknopf eine Weile von geteilten Rechnungen zu nehmen, ohne die Verbindung zu trennen. Bereits begonnene Zahlungen werden weiterhin erfasst.",
+        "sandbox": "Sandbox-Modus",
+        "sandboxHelp": "Die PayPal-Sandbox mit Sandbox-App-Schlüsseln verwenden. Lassen Sie ihn an, bis eine vollständige Zahlung durchgelaufen ist, und tragen Sie dann die Schlüssel der Live-App ein."
+      }
     }
   },
   "jobKinds": {

+ 7 - 22
messages/de/settings.json

@@ -831,32 +831,17 @@
     "paymentTermsPlaceholder": "Netto 14 Tage",
     "paymentTermsHint": "Zahlungsbedingungen auf Rechnungen (z.B. \"Netto 14 Tage\", \"Zahlbar bei Erhalt\")",
     "onlinePayments": "Online-Zahlungen",
-    "onlinePaymentsDescription": "Ermoglichen Sie Kunden, Rechnungen online uber geteilte Rechnungslinks zu bezahlen.",
-    "stripe": "Stripe",
-    "stripeDescription": "Kredit-/Debitkartenzahlungen akzeptieren",
-    "secretKey": "Geheimer Schlussel",
-    "publishableKey": "Offentlicher Schlussel",
-    "webhookSecret": "Webhook-Geheimnis",
-    "webhookUrl": "Webhook-URL",
-    "stripeWebhookHint": "Fugen Sie diese URL in Ihrem Stripe-Dashboard unter Webhooks hinzu. Abonnieren Sie <code>checkout.session.completed</code>.",
-    "vipps": "Vipps",
-    "vippsDescription": "Vipps-Mobilzahlungen akzeptieren (Norwegen)",
-    "clientId": "Client-ID",
-    "clientSecret": "Client-Geheimnis",
-    "subscriptionKey": "Abonnement-Schlussel",
-    "merchantSerialNumber": "Handler-Seriennummer",
-    "testMode": "Testmodus",
-    "callbackUrl": "Callback-URL",
-    "termsOfSale": "Verkaufsbedingungen (Salgsvilkar)",
-    "termsOfSaleDescription": "Von Vipps vorgeschrieben. Kunden konnen diese auf der Rechnungsseite einsehen.",
+    "onlinePaymentsDescription": "Kunden bezahlen Rechnungen über den Link, den Sie ihnen schicken. Stripe, Vipps und PayPal werden unter Integrationen verbunden und können nebeneinander laufen.",
+    "onlinePaymentsHint": "Bezahlt ein Kunde, meldet der Anbieter es Torqvoice und die Rechnung wird von selbst als bezahlt markiert. Die Seite jedes Anbieters zeigt die Benachrichtigungs-URL, die er braucht.",
+    "offered": "Auf Rechnungslinks",
+    "paused": "Verbunden, nicht angeboten",
+    "termsOfSale": "Verkaufsbedingungen",
+    "termsOfSaleDescription": "Was ein Kunde bei der Online-Zahlung akzeptiert. Vipps verlangt veröffentlichte Bedingungen; die Rechnung verlinkt sie in jedem Fall.",
+    "termsOfSaleText": "Text der Bedingungen",
     "externalTermsUrl": "Externe Bedingungs-URL (optional)",
     "externalTermsUrlHint": "Link zu einer externen Seite mit Ihren Bedingungen. Falls gesetzt, verlinkt die Rechnung hierhin statt zur integrierten Bedingungsseite.",
     "publicTermsUrl": "Offentliche Bedingungs-URL",
     "publicTermsUrlHint": "Teilen Sie diese URL mit Vipps oder verlinken Sie sie auf Ihrer Website.",
-    "paypal": "PayPal",
-    "paypalDescription": "PayPal-Zahlungen akzeptieren",
-    "sandboxMode": "Sandbox-Modus",
-    "paypalWebhookHint": "Fugen Sie diese URL in Ihrem PayPal-Entwickler-Dashboard unter Webhooks hinzu. Abonnieren Sie <code>PAYMENT.CAPTURE.COMPLETED</code>.",
     "savePayment": "Zahlungseinstellungen speichern",
     "saved": "Zahlungseinstellungen gespeichert"
   },

+ 49 - 0
messages/en/integrations.json

@@ -55,6 +55,9 @@
     },
     "ai": {
       "chat": "Writing and summaries"
+    },
+    "payments": {
+      "checkout": "Pay from the invoice link"
     }
   },
   "statuses": {
@@ -105,6 +108,9 @@
     "inboundUrl": "Inbound webhook URL",
     "inboundHintSecret": "Paste this URL into the vendor's inbound message settings. It carries a secret that identifies your workshop, so keep it private.",
     "inboundHintMeta": "Paste this URL as the callback URL in your Meta app, with the verify token you entered above, and subscribe to messages.",
+    "inboundHintStripe": "Add this URL in your Stripe dashboard under Developers, Webhooks, subscribed to checkout.session.completed. Then paste the signing secret Stripe shows into the webhook secret field here, so notifications can be verified.",
+    "inboundHintVipps": "Enter this URL as the callback URL in the Vipps MobilePay portal. Vipps also requires published terms of sale, which are set under Settings, Payment.",
+    "inboundHintPaypal": "Add this URL in the PayPal developer dashboard under Webhooks, subscribed to PAYMENT.CAPTURE.COMPLETED.",
     "copy": "Copy",
     "copied": "Copied",
     "updateKeys": "Update keys",
@@ -426,6 +432,49 @@
         "model": "Model",
         "modelHelp": "Which model answers. The list is read from your account, newest first. Smaller models cost less per call and are enough for most workshop text."
       }
+    },
+    "stripe": {
+      "description": "Card payments from the invoice link, into your own Stripe account. The customer pays, Stripe tells Torqvoice, and the invoice marks itself paid.",
+      "fields": {
+        "secretKey": "Secret key",
+        "secretKeyHelp": "From the Stripe dashboard under Developers, API keys. Test keys process nothing real, so try a payment on them first and swap to live keys after.",
+        "publishableKey": "Publishable key",
+        "webhookSecret": "Webhook signing secret",
+        "webhookSecretHelp": "Shown by Stripe once you add the notification URL from this page as a webhook. With it, every notification is verified before a payment is recorded."
+      },
+      "settings": {
+        "enabled": "Offer on invoice links",
+        "enabledHelp": "Switch off to take the pay button off shared invoices for a while, without disconnecting. Payments already started are still recorded."
+      }
+    },
+    "vipps": {
+      "description": "Vipps MobilePay from the invoice link. The customer pays in the app they already have, and the invoice marks itself paid.",
+      "fields": {
+        "clientId": "Client ID",
+        "clientSecret": "Client secret",
+        "subscriptionKey": "Subscription key",
+        "subscriptionKeyHelp": "The Ocp-Apim-Subscription-Key from the Vipps MobilePay portal, under the sales unit's API keys.",
+        "merchantSerialNumber": "Merchant serial number"
+      },
+      "settings": {
+        "enabled": "Offer on invoice links",
+        "enabledHelp": "Switch off to take the pay button off shared invoices for a while, without disconnecting. Payments already started are still recorded.",
+        "testMode": "Test mode",
+        "testModeHelp": "Use the Vipps test environment with test keys. Leave it on until a full payment has gone through, then turn it off and enter the live keys."
+      }
+    },
+    "paypal": {
+      "description": "PayPal checkout from the invoice link, through your own PayPal app. The customer pays with a PayPal account or a card, and the invoice marks itself paid.",
+      "fields": {
+        "clientId": "Client ID",
+        "clientSecret": "Client secret"
+      },
+      "settings": {
+        "enabled": "Offer on invoice links",
+        "enabledHelp": "Switch off to take the pay button off shared invoices for a while, without disconnecting. Payments already started are still recorded.",
+        "sandbox": "Sandbox mode",
+        "sandboxHelp": "Use PayPal's sandbox with sandbox app keys. Leave it on until a full payment has gone through, then turn it off and enter the live app's keys."
+      }
     }
   },
   "jobKinds": {

+ 7 - 22
messages/en/settings.json

@@ -831,32 +831,17 @@
     "paymentTermsPlaceholder": "Net 14 days",
     "paymentTermsHint": "Payment terms text displayed on invoices (e.g. \"Net 14 days\", \"Due on receipt\")",
     "onlinePayments": "Online Payments",
-    "onlinePaymentsDescription": "Allow customers to pay invoices online via shared invoice links.",
-    "stripe": "Stripe",
-    "stripeDescription": "Accept credit/debit card payments",
-    "secretKey": "Secret Key",
-    "publishableKey": "Publishable Key",
-    "webhookSecret": "Webhook Secret",
-    "webhookUrl": "Webhook URL",
-    "stripeWebhookHint": "Add this URL in your Stripe Dashboard under Webhooks. Subscribe to <code>checkout.session.completed</code>.",
-    "vipps": "Vipps",
-    "vippsDescription": "Accept Vipps mobile payments (Norway)",
-    "clientId": "Client ID",
-    "clientSecret": "Client Secret",
-    "subscriptionKey": "Subscription Key",
-    "merchantSerialNumber": "Merchant Serial Number",
-    "testMode": "Test Mode",
-    "callbackUrl": "Callback URL",
-    "termsOfSale": "Terms of Sale (Salgsvilkar)",
-    "termsOfSaleDescription": "Required by Vipps. Customers can view these from the invoice page.",
+    "onlinePaymentsDescription": "Let customers pay invoices from the link you send them. Stripe, Vipps and PayPal are connected under Integrations, and can run side by side.",
+    "onlinePaymentsHint": "When a customer pays, the vendor notifies Torqvoice and the invoice marks itself paid. Each vendor's page shows the notification URL it needs.",
+    "offered": "On invoice links",
+    "paused": "Connected, not offered",
+    "termsOfSale": "Terms of sale",
+    "termsOfSaleDescription": "What a customer agrees to when paying online. Vipps requires published terms; the invoice links to them either way.",
+    "termsOfSaleText": "Terms text",
     "externalTermsUrl": "External Terms URL (optional)",
     "externalTermsUrlHint": "Link to an external page with your terms. If set, the invoice will link here instead of the built-in terms page.",
     "publicTermsUrl": "Public Terms URL",
     "publicTermsUrlHint": "Share this URL with Vipps or link it from your website.",
-    "paypal": "PayPal",
-    "paypalDescription": "Accept PayPal payments",
-    "sandboxMode": "Sandbox Mode",
-    "paypalWebhookHint": "Add this URL in your PayPal Developer Dashboard under Webhooks. Subscribe to <code>PAYMENT.CAPTURE.COMPLETED</code>.",
     "savePayment": "Save Payment Settings",
     "saved": "Payment settings saved"
   },

+ 49 - 0
messages/es/integrations.json

@@ -55,6 +55,9 @@
     },
     "ai": {
       "chat": "Textos y resúmenes"
+    },
+    "payments": {
+      "checkout": "Pagar desde el enlace de la factura"
     }
   },
   "statuses": {
@@ -105,6 +108,9 @@
     "inboundUrl": "Inbound webhook URL",
     "inboundHintSecret": "Paste this URL into the vendor's inbound message settings. It carries a secret that identifies your workshop, so keep it private.",
     "inboundHintMeta": "Paste this URL as the callback URL in your Meta app, with the verify token you entered above, and subscribe to messages.",
+    "inboundHintStripe": "Añade esta URL en el panel de Stripe en Developers, Webhooks, suscrita a checkout.session.completed. Luego pega el secreto de firma que muestra Stripe en el campo de secreto del webhook aquí, para que las notificaciones puedan verificarse.",
+    "inboundHintVipps": "Introduce esta URL como URL de callback en el portal de Vipps MobilePay. Vipps también exige condiciones de venta publicadas, que se configuran en Ajustes, Pago.",
+    "inboundHintPaypal": "Añade esta URL en el panel de desarrollador de PayPal en Webhooks, suscrita a PAYMENT.CAPTURE.COMPLETED.",
     "copy": "Copy",
     "copied": "Copied",
     "updateKeys": "Update keys",
@@ -426,6 +432,49 @@
         "model": "Modelo",
         "modelHelp": "Qué modelo responde. La lista se lee de tu cuenta, primero los más nuevos. Los modelos pequeños cuestan menos por llamada y bastan para la mayoría de los textos del taller."
       }
+    },
+    "stripe": {
+      "description": "Pagos con tarjeta desde el enlace de la factura, a tu propia cuenta de Stripe. El cliente paga, Stripe avisa a Torqvoice y la factura se marca como pagada sola.",
+      "fields": {
+        "secretKey": "Clave secreta",
+        "secretKeyHelp": "Del panel de Stripe en Developers, API keys. Las claves de prueba no procesan nada real, así que prueba un pago con ellas primero y cambia a las claves reales después.",
+        "publishableKey": "Clave publicable",
+        "webhookSecret": "Secreto de firma del webhook",
+        "webhookSecretHelp": "Stripe lo muestra al añadir como webhook la URL de notificación de esta página. Con él, cada notificación se verifica antes de registrar un pago."
+      },
+      "settings": {
+        "enabled": "Ofrecer en los enlaces de factura",
+        "enabledHelp": "Desactívalo para quitar el botón de pago de las facturas compartidas durante un tiempo, sin desconectar. Los pagos ya iniciados se siguen registrando."
+      }
+    },
+    "vipps": {
+      "description": "Vipps MobilePay desde el enlace de la factura. El cliente paga en la app que ya tiene y la factura se marca como pagada sola.",
+      "fields": {
+        "clientId": "ID de cliente",
+        "clientSecret": "Secreto de cliente",
+        "subscriptionKey": "Clave de suscripción",
+        "subscriptionKeyHelp": "La Ocp-Apim-Subscription-Key del portal de Vipps MobilePay, en las claves API de la unidad de venta.",
+        "merchantSerialNumber": "Número de serie de comerciante"
+      },
+      "settings": {
+        "enabled": "Ofrecer en los enlaces de factura",
+        "enabledHelp": "Desactívalo para quitar el botón de pago de las facturas compartidas durante un tiempo, sin desconectar. Los pagos ya iniciados se siguen registrando.",
+        "testMode": "Modo de prueba",
+        "testModeHelp": "Usar el entorno de prueba de Vipps con claves de prueba. Déjalo activado hasta que un pago completo haya funcionado, luego desactívalo e introduce las claves reales."
+      }
+    },
+    "paypal": {
+      "description": "Pago con PayPal desde el enlace de la factura, a través de tu propia app de PayPal. El cliente paga con cuenta de PayPal o tarjeta y la factura se marca como pagada sola.",
+      "fields": {
+        "clientId": "ID de cliente",
+        "clientSecret": "Secreto de cliente"
+      },
+      "settings": {
+        "enabled": "Ofrecer en los enlaces de factura",
+        "enabledHelp": "Desactívalo para quitar el botón de pago de las facturas compartidas durante un tiempo, sin desconectar. Los pagos ya iniciados se siguen registrando.",
+        "sandbox": "Modo sandbox",
+        "sandboxHelp": "Usar el sandbox de PayPal con las claves de la app de sandbox. Déjalo activado hasta que un pago completo haya funcionado, luego desactívalo e introduce las claves de la app real."
+      }
     }
   },
   "jobKinds": {

+ 7 - 22
messages/es/settings.json

@@ -831,32 +831,17 @@
     "paymentTermsPlaceholder": "Neto 14 días",
     "paymentTermsHint": "Texto de condiciones de pago en las facturas (ej. \"Neto 14 días\", \"Pago al recibir\")",
     "onlinePayments": "Pagos en línea",
-    "onlinePaymentsDescription": "Permita a los clientes pagar facturas en línea a través de enlaces de factura compartidos.",
-    "stripe": "Stripe",
-    "stripeDescription": "Aceptar pagos con tarjeta de credito/debito",
-    "secretKey": "Clave secreta",
-    "publishableKey": "Clave publica",
-    "webhookSecret": "Secreto del webhook",
-    "webhookUrl": "URL del webhook",
-    "stripeWebhookHint": "Agregue esta URL en su Panel de Stripe en Webhooks. Suscribase a <code>checkout.session.completed</code>.",
-    "vipps": "Vipps",
-    "vippsDescription": "Aceptar pagos moviles Vipps (Noruega)",
-    "clientId": "ID de cliente",
-    "clientSecret": "Secreto del cliente",
-    "subscriptionKey": "Clave de suscripción",
-    "merchantSerialNumber": "Número de serie del comerciante",
-    "testMode": "Modo de prueba",
-    "callbackUrl": "URL de callback",
-    "termsOfSale": "Condiciones de venta (Salgsvilkår)",
-    "termsOfSaleDescription": "Requerido por Vipps. Los clientes pueden verlas desde la página de factura.",
+    "onlinePaymentsDescription": "Deja que los clientes paguen facturas desde el enlace que les envías. Stripe, Vipps y PayPal se conectan en Integraciones y pueden funcionar a la vez.",
+    "onlinePaymentsHint": "Cuando un cliente paga, el proveedor avisa a Torqvoice y la factura se marca como pagada sola. La página de cada proveedor muestra la URL de notificación que necesita.",
+    "offered": "En enlaces de factura",
+    "paused": "Conectado, no ofrecido",
+    "termsOfSale": "Condiciones de venta",
+    "termsOfSaleDescription": "Lo que un cliente acepta al pagar en línea. Vipps exige condiciones publicadas; la factura enlaza a ellas en cualquier caso.",
+    "termsOfSaleText": "Texto de las condiciones",
     "externalTermsUrl": "URL de condiciones externas (opcional)",
     "externalTermsUrlHint": "Enlace a una página externa con sus condiciones. Si se establece, la factura enlazara aquí.",
     "publicTermsUrl": "URL publica de condiciones",
     "publicTermsUrlHint": "Comparta esta URL con Vipps o enlacela desde su sitio web.",
-    "paypal": "PayPal",
-    "paypalDescription": "Aceptar pagos PayPal",
-    "sandboxMode": "Modo sandbox",
-    "paypalWebhookHint": "Agregue esta URL en su Panel de Desarrollador de PayPal en Webhooks. Suscribase a <code>PAYMENT.CAPTURE.COMPLETED</code>.",
     "savePayment": "Guardar configuración de pago",
     "saved": "Configuración de pago guardada"
   },

+ 49 - 0
messages/fr/integrations.json

@@ -55,6 +55,9 @@
     },
     "ai": {
       "chat": "Textes et résumés"
+    },
+    "payments": {
+      "checkout": "Payer depuis le lien de la facture"
     }
   },
   "statuses": {
@@ -105,6 +108,9 @@
     "inboundUrl": "Inbound webhook URL",
     "inboundHintSecret": "Paste this URL into the vendor's inbound message settings. It carries a secret that identifies your workshop, so keep it private.",
     "inboundHintMeta": "Paste this URL as the callback URL in your Meta app, with the verify token you entered above, and subscribe to messages.",
+    "inboundHintStripe": "Ajoutez cette URL dans le tableau de bord Stripe sous Developers, Webhooks, abonnée à checkout.session.completed. Collez ensuite le secret de signature affiché par Stripe dans le champ secret du webhook ici, pour que les notifications puissent être vérifiées.",
+    "inboundHintVipps": "Saisissez cette URL comme URL de rappel dans le portail Vipps MobilePay. Vipps exige aussi des conditions de vente publiées, à renseigner sous Paramètres, Paiement.",
+    "inboundHintPaypal": "Ajoutez cette URL dans le tableau de bord développeur PayPal sous Webhooks, abonnée à PAYMENT.CAPTURE.COMPLETED.",
     "copy": "Copy",
     "copied": "Copied",
     "updateKeys": "Update keys",
@@ -426,6 +432,49 @@
         "model": "Modèle",
         "modelHelp": "Le modèle qui répond. La liste vient de votre compte, les plus récents en premier. Les petits modèles coûtent moins par appel et suffisent à la plupart des textes d'atelier."
       }
+    },
+    "stripe": {
+      "description": "Paiements par carte depuis le lien de la facture, sur votre propre compte Stripe. Le client paie, Stripe prévient Torqvoice et la facture se marque payée toute seule.",
+      "fields": {
+        "secretKey": "Clé secrète",
+        "secretKeyHelp": "Depuis le tableau de bord Stripe sous Developers, API keys. Les clés de test ne traitent rien de réel : essayez d'abord un paiement avec elles, puis passez aux clés réelles.",
+        "publishableKey": "Clé publiable",
+        "webhookSecret": "Secret de signature du webhook",
+        "webhookSecretHelp": "Affiché par Stripe une fois l'URL de notification de cette page ajoutée comme webhook. Avec lui, chaque notification est vérifiée avant qu'un paiement soit enregistré."
+      },
+      "settings": {
+        "enabled": "Proposer sur les liens de facture",
+        "enabledHelp": "Désactivez pour retirer le bouton de paiement des factures partagées pendant un temps, sans déconnecter. Les paiements déjà lancés sont toujours enregistrés."
+      }
+    },
+    "vipps": {
+      "description": "Vipps MobilePay depuis le lien de la facture. Le client paie dans l'application qu'il a déjà et la facture se marque payée toute seule.",
+      "fields": {
+        "clientId": "ID client",
+        "clientSecret": "Secret client",
+        "subscriptionKey": "Clé d'abonnement",
+        "subscriptionKeyHelp": "La Ocp-Apim-Subscription-Key du portail Vipps MobilePay, dans les clés API de l'unité de vente.",
+        "merchantSerialNumber": "Numéro de série marchand"
+      },
+      "settings": {
+        "enabled": "Proposer sur les liens de facture",
+        "enabledHelp": "Désactivez pour retirer le bouton de paiement des factures partagées pendant un temps, sans déconnecter. Les paiements déjà lancés sont toujours enregistrés.",
+        "testMode": "Mode test",
+        "testModeHelp": "Utiliser l'environnement de test Vipps avec des clés de test. Laissez-le activé jusqu'à ce qu'un paiement complet ait abouti, puis désactivez-le et saisissez les clés réelles."
+      }
+    },
+    "paypal": {
+      "description": "Paiement PayPal depuis le lien de la facture, via votre propre application PayPal. Le client paie avec un compte PayPal ou une carte et la facture se marque payée toute seule.",
+      "fields": {
+        "clientId": "ID client",
+        "clientSecret": "Secret client"
+      },
+      "settings": {
+        "enabled": "Proposer sur les liens de facture",
+        "enabledHelp": "Désactivez pour retirer le bouton de paiement des factures partagées pendant un temps, sans déconnecter. Les paiements déjà lancés sont toujours enregistrés.",
+        "sandbox": "Mode sandbox",
+        "sandboxHelp": "Utiliser le sandbox PayPal avec les clés de l'application sandbox. Laissez-le activé jusqu'à ce qu'un paiement complet ait abouti, puis désactivez-le et saisissez les clés de l'application réelle."
+      }
     }
   },
   "jobKinds": {

+ 7 - 22
messages/fr/settings.json

@@ -831,32 +831,17 @@
     "paymentTermsPlaceholder": "Net 14 jours",
     "paymentTermsHint": "Texte des conditions de paiement sur les factures (ex. \"Net 14 jours\", \"A réception\")",
     "onlinePayments": "Paiements en ligne",
-    "onlinePaymentsDescription": "Permettez aux clients de payer les factures en ligne via des liens de facture partages.",
-    "stripe": "Stripe",
-    "stripeDescription": "Accepter les paiements par carte de credit/debit",
-    "secretKey": "Cle secrète",
-    "publishableKey": "Cle publique",
-    "webhookSecret": "Secret du webhook",
-    "webhookUrl": "URL du webhook",
-    "stripeWebhookHint": "Ajoutez cette URL dans votre tableau de bord Stripe sous Webhooks. Abonnez-vous a <code>checkout.session.completed</code>.",
-    "vipps": "Vipps",
-    "vippsDescription": "Accepter les paiements mobiles Vipps (Norvege)",
-    "clientId": "ID client",
-    "clientSecret": "Secret client",
-    "subscriptionKey": "Cle d'abonnement",
-    "merchantSerialNumber": "Numéro de serie du commercant",
-    "testMode": "Mode test",
-    "callbackUrl": "URL de callback",
-    "termsOfSale": "Conditions de vente (Salgsvilkår)",
-    "termsOfSaleDescription": "Requis par Vipps. Les clients peuvent les consulter depuis la page de facture.",
+    "onlinePaymentsDescription": "Laissez les clients payer les factures depuis le lien que vous leur envoyez. Stripe, Vipps et PayPal se connectent sous Intégrations et peuvent fonctionner côte à côte.",
+    "onlinePaymentsHint": "Quand un client paie, le prestataire prévient Torqvoice et la facture se marque payée toute seule. La page de chaque prestataire affiche l'URL de notification dont il a besoin.",
+    "offered": "Sur les liens de facture",
+    "paused": "Connecté, non proposé",
+    "termsOfSale": "Conditions de vente",
+    "termsOfSaleDescription": "Ce qu'un client accepte en payant en ligne. Vipps exige des conditions publiées ; la facture y renvoie dans tous les cas.",
+    "termsOfSaleText": "Texte des conditions",
     "externalTermsUrl": "URL des conditions externes (optionnel)",
     "externalTermsUrlHint": "Lien vers une page externe avec vos conditions. Si defini, la facture redirigera ici.",
     "publicTermsUrl": "URL publique des conditions",
     "publicTermsUrlHint": "Partagez cette URL avec Vipps ou liez-la depuis votre site web.",
-    "paypal": "PayPal",
-    "paypalDescription": "Accepter les paiements PayPal",
-    "sandboxMode": "Mode sandbox",
-    "paypalWebhookHint": "Ajoutez cette URL dans votre tableau de bord PayPal sous Webhooks. Abonnez-vous a <code>PAYMENT.CAPTURE.COMPLETED</code>.",
     "savePayment": "Enregistrer les paramètres de paiement",
     "saved": "Paramètres de paiement enregistrés"
   },

+ 49 - 0
messages/it/integrations.json

@@ -55,6 +55,9 @@
     },
     "ai": {
       "chat": "Testi e riepiloghi"
+    },
+    "payments": {
+      "checkout": "Paga dal link della fattura"
     }
   },
   "statuses": {
@@ -105,6 +108,9 @@
     "inboundUrl": "Inbound webhook URL",
     "inboundHintSecret": "Paste this URL into the vendor's inbound message settings. It carries a secret that identifies your workshop, so keep it private.",
     "inboundHintMeta": "Paste this URL as the callback URL in your Meta app, with the verify token you entered above, and subscribe to messages.",
+    "inboundHintStripe": "Aggiungi questo URL nella dashboard di Stripe sotto Developers, Webhooks, iscritto a checkout.session.completed. Poi incolla il segreto di firma mostrato da Stripe nel campo segreto del webhook qui, così le notifiche possono essere verificate.",
+    "inboundHintVipps": "Inserisci questo URL come URL di callback nel portale Vipps MobilePay. Vipps richiede anche condizioni di vendita pubblicate, impostate in Impostazioni, Pagamento.",
+    "inboundHintPaypal": "Aggiungi questo URL nella dashboard sviluppatori di PayPal sotto Webhooks, iscritto a PAYMENT.CAPTURE.COMPLETED.",
     "copy": "Copy",
     "copied": "Copied",
     "updateKeys": "Update keys",
@@ -426,6 +432,49 @@
         "model": "Modello",
         "modelHelp": "Quale modello risponde. L'elenco arriva dal vostro account, prima i più recenti. I modelli piccoli costano meno a chiamata e bastano per quasi tutti i testi dell'officina."
       }
+    },
+    "stripe": {
+      "description": "Pagamenti con carta dal link della fattura, sul tuo account Stripe. Il cliente paga, Stripe avvisa Torqvoice e la fattura si segna pagata da sola.",
+      "fields": {
+        "secretKey": "Chiave segreta",
+        "secretKeyHelp": "Dalla dashboard di Stripe sotto Developers, API keys. Le chiavi di test non elaborano nulla di reale, quindi prova prima un pagamento con quelle e passa poi alle chiavi live.",
+        "publishableKey": "Chiave pubblicabile",
+        "webhookSecret": "Segreto di firma del webhook",
+        "webhookSecretHelp": "Mostrato da Stripe quando aggiungi come webhook l'URL di notifica di questa pagina. Con esso ogni notifica viene verificata prima di registrare un pagamento."
+      },
+      "settings": {
+        "enabled": "Offri nei link delle fatture",
+        "enabledHelp": "Disattiva per togliere il pulsante di pagamento dalle fatture condivise per un po', senza disconnettere. I pagamenti già avviati vengono comunque registrati."
+      }
+    },
+    "vipps": {
+      "description": "Vipps MobilePay dal link della fattura. Il cliente paga nell'app che ha già e la fattura si segna pagata da sola.",
+      "fields": {
+        "clientId": "ID client",
+        "clientSecret": "Segreto client",
+        "subscriptionKey": "Chiave di sottoscrizione",
+        "subscriptionKeyHelp": "La Ocp-Apim-Subscription-Key del portale Vipps MobilePay, tra le chiavi API dell'unità di vendita.",
+        "merchantSerialNumber": "Numero di serie del commerciante"
+      },
+      "settings": {
+        "enabled": "Offri nei link delle fatture",
+        "enabledHelp": "Disattiva per togliere il pulsante di pagamento dalle fatture condivise per un po', senza disconnettere. I pagamenti già avviati vengono comunque registrati.",
+        "testMode": "Modalità test",
+        "testModeHelp": "Usa l'ambiente di test di Vipps con chiavi di test. Lasciala attiva finché un pagamento completo non è andato a buon fine, poi disattivala e inserisci le chiavi live."
+      }
+    },
+    "paypal": {
+      "description": "Checkout PayPal dal link della fattura, tramite la tua app PayPal. Il cliente paga con un account PayPal o una carta e la fattura si segna pagata da sola.",
+      "fields": {
+        "clientId": "ID client",
+        "clientSecret": "Segreto client"
+      },
+      "settings": {
+        "enabled": "Offri nei link delle fatture",
+        "enabledHelp": "Disattiva per togliere il pulsante di pagamento dalle fatture condivise per un po', senza disconnettere. I pagamenti già avviati vengono comunque registrati.",
+        "sandbox": "Modalità sandbox",
+        "sandboxHelp": "Usa la sandbox di PayPal con le chiavi dell'app sandbox. Lasciala attiva finché un pagamento completo non è andato a buon fine, poi disattivala e inserisci le chiavi dell'app live."
+      }
     }
   },
   "jobKinds": {

+ 7 - 22
messages/it/settings.json

@@ -831,32 +831,17 @@
     "paymentTermsPlaceholder": "A 14 giorni",
     "paymentTermsHint": "Testo dei termini di pagamento mostrato sulle fatture (es. \"A 14 giorni\", \"Alla consegna\")",
     "onlinePayments": "Pagamenti online",
-    "onlinePaymentsDescription": "Permetti ai clienti di pagare le fatture online tramite link di fattura condivisi.",
-    "stripe": "Stripe",
-    "stripeDescription": "Accetta pagamenti con carta di credito/debito",
-    "secretKey": "Chiave segreta",
-    "publishableKey": "Chiave pubblica",
-    "webhookSecret": "Segreto webhook",
-    "webhookUrl": "URL webhook",
-    "stripeWebhookHint": "Aggiungi questo URL nel tuo Stripe Dashboard sotto Webhooks. Iscriviti a <code>checkout.session.completed</code>.",
-    "vipps": "Vipps",
-    "vippsDescription": "Accetta pagamenti mobili Vipps (Norvegia)",
-    "clientId": "Client ID",
-    "clientSecret": "Client Secret",
-    "subscriptionKey": "Chiave di abbonamento",
-    "merchantSerialNumber": "Numero seriale commerciante",
-    "testMode": "Modalita test",
-    "callbackUrl": "URL callback",
-    "termsOfSale": "Condizioni di vendita (Salgsvilkar)",
-    "termsOfSaleDescription": "Richiesto da Vipps. I clienti possono visualizzarle dalla pagina della fattura.",
+    "onlinePaymentsDescription": "Lascia che i clienti paghino le fatture dal link che invii loro. Stripe, Vipps e PayPal si collegano in Integrazioni e possono funzionare insieme.",
+    "onlinePaymentsHint": "Quando un cliente paga, il fornitore avvisa Torqvoice e la fattura si segna pagata da sola. La pagina di ogni fornitore mostra l'URL di notifica di cui ha bisogno.",
+    "offered": "Nei link delle fatture",
+    "paused": "Collegato, non offerto",
+    "termsOfSale": "Condizioni di vendita",
+    "termsOfSaleDescription": "Ciò che un cliente accetta pagando online. Vipps richiede condizioni pubblicate; la fattura le collega in ogni caso.",
+    "termsOfSaleText": "Testo delle condizioni",
     "externalTermsUrl": "URL condizioni esterno (opzionale)",
     "externalTermsUrlHint": "Link a una pagina esterna con le tue condizioni. Se impostato, la fattura collegherà qui invece della pagina condizioni integrata.",
     "publicTermsUrl": "URL condizioni pubblico",
     "publicTermsUrlHint": "Condividi questo URL con Vipps o collegalo dal tuo sito web.",
-    "paypal": "PayPal",
-    "paypalDescription": "Accetta pagamenti PayPal",
-    "sandboxMode": "Modalita sandbox",
-    "paypalWebhookHint": "Aggiungi questo URL nel tuo PayPal Developer Dashboard sotto Webhooks. Iscriviti a <code>PAYMENT.CAPTURE.COMPLETED</code>.",
     "savePayment": "Salva impostazioni pagamento",
     "saved": "Impostazioni pagamento salvate"
   },

+ 49 - 0
messages/lt/integrations.json

@@ -55,6 +55,9 @@
     },
     "ai": {
       "chat": "Tekstai ir santraukos"
+    },
+    "payments": {
+      "checkout": "Mokėti iš sąskaitos nuorodos"
     }
   },
   "statuses": {
@@ -105,6 +108,9 @@
     "inboundUrl": "Inbound webhook URL",
     "inboundHintSecret": "Paste this URL into the vendor's inbound message settings. It carries a secret that identifies your workshop, so keep it private.",
     "inboundHintMeta": "Paste this URL as the callback URL in your Meta app, with the verify token you entered above, and subscribe to messages.",
+    "inboundHintStripe": "Pridėkite šį URL Stripe valdymo skydelyje, skiltyje Developers, Webhooks, prenumeruodami checkout.session.completed. Tada įklijuokite Stripe rodomą parašo paslaptį į webhook paslapties lauką čia, kad pranešimus būtų galima patikrinti.",
+    "inboundHintVipps": "Įveskite šį URL kaip atgalinio iškvietimo URL Vipps MobilePay portale. Vipps taip pat reikalauja paskelbtų pardavimo sąlygų, kurios nustatomos skiltyje Nustatymai, Mokėjimas.",
+    "inboundHintPaypal": "Pridėkite šį URL PayPal kūrėjų skydelyje, skiltyje Webhooks, prenumeruodami PAYMENT.CAPTURE.COMPLETED.",
     "copy": "Copy",
     "copied": "Copied",
     "updateKeys": "Update keys",
@@ -426,6 +432,49 @@
         "model": "Modelis",
         "modelHelp": "Kuris modelis atsako. Sąrašas nuskaitomas iš jūsų paskyros, naujausi viršuje. Mažesni modeliai kainuoja mažiau ir daugumai serviso tekstų visiškai pakanka."
       }
+    },
+    "stripe": {
+      "description": "Mokėjimai kortele iš sąskaitos nuorodos į jūsų Stripe paskyrą. Klientas sumoka, Stripe praneša Torqvoice, ir sąskaita pati pažymima kaip apmokėta.",
+      "fields": {
+        "secretKey": "Slaptasis raktas",
+        "secretKeyHelp": "Iš Stripe valdymo skydelio, Developers, API keys. Bandomieji raktai nieko tikro neapdoroja, todėl pirmiausia išbandykite mokėjimą su jais, o paskui pereikite prie tikrų raktų.",
+        "publishableKey": "Viešasis raktas",
+        "webhookSecret": "Webhook parašo paslaptis",
+        "webhookSecretHelp": "Stripe ją parodo, kai šio puslapio pranešimų URL pridedate kaip webhook. Su ja kiekvienas pranešimas patikrinamas prieš užregistruojant mokėjimą."
+      },
+      "settings": {
+        "enabled": "Siūlyti sąskaitų nuorodose",
+        "enabledHelp": "Išjunkite, kad kuriam laikui pašalintumėte mokėjimo mygtuką iš bendrinamų sąskaitų neatjungdami. Jau pradėti mokėjimai vis tiek užregistruojami."
+      }
+    },
+    "vipps": {
+      "description": "Vipps MobilePay iš sąskaitos nuorodos. Klientas sumoka programėlėje, kurią jau turi, ir sąskaita pati pažymima kaip apmokėta.",
+      "fields": {
+        "clientId": "Kliento ID",
+        "clientSecret": "Kliento paslaptis",
+        "subscriptionKey": "Prenumeratos raktas",
+        "subscriptionKeyHelp": "Ocp-Apim-Subscription-Key iš Vipps MobilePay portalo, pardavimo vieneto API raktuose.",
+        "merchantSerialNumber": "Prekybininko serijos numeris"
+      },
+      "settings": {
+        "enabled": "Siūlyti sąskaitų nuorodose",
+        "enabledHelp": "Išjunkite, kad kuriam laikui pašalintumėte mokėjimo mygtuką iš bendrinamų sąskaitų neatjungdami. Jau pradėti mokėjimai vis tiek užregistruojami.",
+        "testMode": "Bandomasis režimas",
+        "testModeHelp": "Naudoti Vipps bandomąją aplinką su bandomaisiais raktais. Palikite įjungtą, kol pavyks visas mokėjimas, tada išjunkite ir įveskite tikrus raktus."
+      }
+    },
+    "paypal": {
+      "description": "PayPal atsiskaitymas iš sąskaitos nuorodos per jūsų PayPal programėlę. Klientas sumoka PayPal paskyra arba kortele, ir sąskaita pati pažymima kaip apmokėta.",
+      "fields": {
+        "clientId": "Kliento ID",
+        "clientSecret": "Kliento paslaptis"
+      },
+      "settings": {
+        "enabled": "Siūlyti sąskaitų nuorodose",
+        "enabledHelp": "Išjunkite, kad kuriam laikui pašalintumėte mokėjimo mygtuką iš bendrinamų sąskaitų neatjungdami. Jau pradėti mokėjimai vis tiek užregistruojami.",
+        "sandbox": "Smėlio dėžės režimas",
+        "sandboxHelp": "Naudoti PayPal smėlio dėžę su smėlio dėžės programėlės raktais. Palikite įjungtą, kol pavyks visas mokėjimas, tada išjunkite ir įveskite tikros programėlės raktus."
+      }
     }
   },
   "jobKinds": {

+ 6 - 21
messages/lt/settings.json

@@ -831,32 +831,17 @@
     "paymentTermsPlaceholder": "Per 14 dienų",
     "paymentTermsHint": "Mokėjimo sąlygų tekstas, rodomas sąskaitose (pvz., \"Per 14 dienų\", \"Gavus sąskaitą\")",
     "onlinePayments": "Mokėjimai internetu",
-    "onlinePaymentsDescription": "Leiskite klientams apmokėti sąskaitas internetu per bendrinamas sąskaitų nuorodas.",
-    "stripe": "Stripe",
-    "stripeDescription": "Priimti kredito/debeto kortelių mokėjimus",
-    "secretKey": "Slaptas raktas",
-    "publishableKey": "Viešas raktas",
-    "webhookSecret": "Webhook slaptas raktas",
-    "webhookUrl": "Webhook URL",
-    "stripeWebhookHint": "Pridėkite šį URL savo Stripe valdymo skydelyje po Webhooks. Prenumeruokite <code>checkout.session.completed</code>.",
-    "vipps": "Vipps",
-    "vippsDescription": "Priimti Vipps mobiliuosius mokėjimus (Norvegija)",
-    "clientId": "Kliento ID",
-    "clientSecret": "Kliento slaptas raktas",
-    "subscriptionKey": "Prenumeratos raktas",
-    "merchantSerialNumber": "Prekybininko serijos numeris",
-    "testMode": "Testavimo režimas",
-    "callbackUrl": "Grįžtamojo ryšio URL",
+    "onlinePaymentsDescription": "Leiskite klientams apmokėti sąskaitas iš nuorodos, kurią jiems siunčiate. Stripe, Vipps ir PayPal prijungiami skiltyje Integracijos ir gali veikti kartu.",
+    "onlinePaymentsHint": "Kai klientas sumoka, tiekėjas praneša Torqvoice ir sąskaita pati pažymima kaip apmokėta. Kiekvieno tiekėjo puslapyje rodomas jam reikalingas pranešimų URL.",
+    "offered": "Sąskaitų nuorodose",
+    "paused": "Prijungta, nesiūloma",
     "termsOfSale": "Pardavimo sąlygos",
-    "termsOfSaleDescription": "Reikalaujama Vipps. Klientai gali jas peržiūrėti iš sąskaitos puslapio.",
+    "termsOfSaleDescription": "Su kuo klientas sutinka mokėdamas internetu. Vipps reikalauja paskelbtų sąlygų; sąskaita bet kuriuo atveju pateikia nuorodą į jas.",
+    "termsOfSaleText": "Sąlygų tekstas",
     "externalTermsUrl": "Išorinis sąlygų URL (neprivaloma)",
     "externalTermsUrlHint": "Nuoroda į išorinį puslapį su jūsų sąlygomis. Jei nustatyta, sąskaita nukreips čia, o ne į įtaisytą sąlygų puslapį.",
     "publicTermsUrl": "Viešas sąlygų URL",
     "publicTermsUrlHint": "Pasidalinkite šiuo URL su Vipps arba nurodykite iš savo svetainės.",
-    "paypal": "PayPal",
-    "paypalDescription": "Priimti PayPal mokėjimus",
-    "sandboxMode": "Sandbox režimas",
-    "paypalWebhookHint": "Pridėkite šį URL savo PayPal Developer valdymo skydelyje po Webhooks. Prenumeruokite <code>PAYMENT.CAPTURE.COMPLETED</code>.",
     "savePayment": "Išsaugoti mokėjimo nustatymus",
     "saved": "Mokėjimo nustatymai išsaugoti"
   },

+ 49 - 0
messages/nb/integrations.json

@@ -55,6 +55,9 @@
     },
     "ai": {
       "chat": "Tekst og sammendrag"
+    },
+    "payments": {
+      "checkout": "Betal fra fakturalenken"
     }
   },
   "statuses": {
@@ -105,6 +108,9 @@
     "inboundUrl": "Inbound webhook URL",
     "inboundHintSecret": "Paste this URL into the vendor's inbound message settings. It carries a secret that identifies your workshop, so keep it private.",
     "inboundHintMeta": "Paste this URL as the callback URL in your Meta app, with the verify token you entered above, and subscribe to messages.",
+    "inboundHintStripe": "Legg inn denne URL-en i Stripe-dashbordet under Developers, Webhooks, abonnert på checkout.session.completed. Lim deretter signeringshemmeligheten Stripe viser inn i feltet for webhook-hemmelighet her, slik at varslene kan verifiseres.",
+    "inboundHintVipps": "Legg inn denne URL-en som callback-URL i Vipps MobilePay-portalen. Vipps krever også publiserte salgsvilkår, som settes under Innstillinger, Betaling.",
+    "inboundHintPaypal": "Legg inn denne URL-en i PayPal-utviklerdashbordet under Webhooks, abonnert på PAYMENT.CAPTURE.COMPLETED.",
     "copy": "Copy",
     "copied": "Copied",
     "updateKeys": "Update keys",
@@ -426,6 +432,49 @@
         "model": "Modell",
         "modelHelp": "Hvilken modell som svarer. Listen leses fra kontoen din, nyeste først. Mindre modeller koster mindre per kall og holder for det meste av verkstedteksten."
       }
+    },
+    "stripe": {
+      "description": "Kortbetaling fra fakturalenken, rett inn på din egen Stripe-konto. Kunden betaler, Stripe sier fra til Torqvoice, og fakturaen merkes betalt av seg selv.",
+      "fields": {
+        "secretKey": "Hemmelig nøkkel",
+        "secretKeyHelp": "Fra Stripe-dashbordet under Developers, API keys. Testnøkler behandler ingenting ekte, så prøv en betaling med dem først og bytt til live-nøkler etterpå.",
+        "publishableKey": "Publiserbar nøkkel",
+        "webhookSecret": "Signeringshemmelighet for webhook",
+        "webhookSecretHelp": "Vises av Stripe når du legger inn varslings-URL-en fra denne siden som webhook. Med den verifiseres hvert varsel før en betaling registreres."
+      },
+      "settings": {
+        "enabled": "Tilby på fakturalenker",
+        "enabledHelp": "Slå av for å ta betalingsknappen bort fra delte fakturaer en stund, uten å koble fra. Betalinger som allerede er startet, registreres fortsatt."
+      }
+    },
+    "vipps": {
+      "description": "Vipps MobilePay fra fakturalenken. Kunden betaler i appen de allerede har, og fakturaen merkes betalt av seg selv.",
+      "fields": {
+        "clientId": "Client ID",
+        "clientSecret": "Client secret",
+        "subscriptionKey": "Subscription key",
+        "subscriptionKeyHelp": "Ocp-Apim-Subscription-Key fra Vipps MobilePay-portalen, under salgsenhetens API-nøkler.",
+        "merchantSerialNumber": "Merchant Serial Number (MSN)"
+      },
+      "settings": {
+        "enabled": "Tilby på fakturalenker",
+        "enabledHelp": "Slå av for å ta betalingsknappen bort fra delte fakturaer en stund, uten å koble fra. Betalinger som allerede er startet, registreres fortsatt.",
+        "testMode": "Testmodus",
+        "testModeHelp": "Bruk Vipps sitt testmiljø med testnøkler. La den stå på til en hel betaling har gått gjennom, slå den så av og legg inn live-nøklene."
+      }
+    },
+    "paypal": {
+      "description": "PayPal-betaling fra fakturalenken, gjennom din egen PayPal-app. Kunden betaler med PayPal-konto eller kort, og fakturaen merkes betalt av seg selv.",
+      "fields": {
+        "clientId": "Client ID",
+        "clientSecret": "Client secret"
+      },
+      "settings": {
+        "enabled": "Tilby på fakturalenker",
+        "enabledHelp": "Slå av for å ta betalingsknappen bort fra delte fakturaer en stund, uten å koble fra. Betalinger som allerede er startet, registreres fortsatt.",
+        "sandbox": "Sandkassemodus",
+        "sandboxHelp": "Bruk PayPal sin sandkasse med sandkasse-appens nøkler. La den stå på til en hel betaling har gått gjennom, slå den så av og legg inn live-appens nøkler."
+      }
     }
   },
   "jobKinds": {

+ 7 - 22
messages/nb/settings.json

@@ -831,32 +831,17 @@
     "paymentTermsPlaceholder": "Netto 14 dager",
     "paymentTermsHint": "Betalingsvilkartekst vist pa fakturaer (f.eks. \"Netto 14 dager\", \"Ved mottak\")",
     "onlinePayments": "Nettbetalinger",
-    "onlinePaymentsDescription": "La kunder betale fakturaer pa nett via delte fakturalenker.",
-    "stripe": "Stripe",
-    "stripeDescription": "Godta kredit-/debetkortbetalinger",
-    "secretKey": "Hemmelig nokkel",
-    "publishableKey": "Offentlig nokkel",
-    "webhookSecret": "Webhook-hemmelighet",
-    "webhookUrl": "Webhook-URL",
-    "stripeWebhookHint": "Legg til denne URL-en i Stripe-dashbordet under Webhooks. Abonner pa <code>checkout.session.completed</code>.",
-    "vipps": "Vipps",
-    "vippsDescription": "Godta Vipps-mobilbetalinger (Norge)",
-    "clientId": "Klient-ID",
-    "clientSecret": "Klienthemmelighet",
-    "subscriptionKey": "Abonnementsnokkel",
-    "merchantSerialNumber": "Forhandlerserienummer",
-    "testMode": "Testmodus",
-    "callbackUrl": "Callback-URL",
-    "termsOfSale": "Salgsvilkar",
-    "termsOfSaleDescription": "Palagt av Vipps. Kunder kan se disse fra fakturasiden.",
+    "onlinePaymentsDescription": "La kundene betale fakturaer fra lenken du sender dem. Stripe, Vipps og PayPal kobles til under Integrasjoner og kan brukes side om side.",
+    "onlinePaymentsHint": "Når en kunde betaler, sier leverandøren fra til Torqvoice og fakturaen merkes betalt av seg selv. Siden til hver leverandør viser varslings-URL-en den trenger.",
+    "offered": "På fakturalenker",
+    "paused": "Tilkoblet, tilbys ikke",
+    "termsOfSale": "Salgsvilkår",
+    "termsOfSaleDescription": "Det kunden godtar ved betaling på nett. Vipps krever publiserte vilkår; fakturaen lenker til dem uansett.",
+    "termsOfSaleText": "Vilkårstekst",
     "externalTermsUrl": "Ekstern vilkars-URL (valgfritt)",
     "externalTermsUrlHint": "Lenke til en ekstern side med vilkarene dine. Hvis satt, vil fakturaen lenke hit.",
     "publicTermsUrl": "Offentlig vilkars-URL",
     "publicTermsUrlHint": "Del denne URL-en med Vipps eller lenk den fra nettstedet ditt.",
-    "paypal": "PayPal",
-    "paypalDescription": "Godta PayPal-betalinger",
-    "sandboxMode": "Sandkassemodus",
-    "paypalWebhookHint": "Legg til denne URL-en i PayPal-utviklerdashbordet under Webhooks. Abonner pa <code>PAYMENT.CAPTURE.COMPLETED</code>.",
     "savePayment": "Lagre betalingsinnstillinger",
     "saved": "Betalingsinnstillinger lagret"
   },

+ 49 - 0
messages/nl/integrations.json

@@ -55,6 +55,9 @@
     },
     "ai": {
       "chat": "Teksten en samenvattingen"
+    },
+    "payments": {
+      "checkout": "Betalen via de factuurlink"
     }
   },
   "statuses": {
@@ -105,6 +108,9 @@
     "inboundUrl": "Inbound webhook URL",
     "inboundHintSecret": "Paste this URL into the vendor's inbound message settings. It carries a secret that identifies your workshop, so keep it private.",
     "inboundHintMeta": "Paste this URL as the callback URL in your Meta app, with the verify token you entered above, and subscribe to messages.",
+    "inboundHintStripe": "Voeg deze URL toe in het Stripe-dashboard onder Developers, Webhooks, geabonneerd op checkout.session.completed. Plak daarna het ondertekeningsgeheim dat Stripe toont in het veld webhookgeheim hier, zodat meldingen geverifieerd kunnen worden.",
+    "inboundHintVipps": "Vul deze URL in als callback-URL in het Vipps MobilePay-portaal. Vipps vereist ook gepubliceerde verkoopvoorwaarden, die staan onder Instellingen, Betaling.",
+    "inboundHintPaypal": "Voeg deze URL toe in het PayPal-ontwikkelaarsdashboard onder Webhooks, geabonneerd op PAYMENT.CAPTURE.COMPLETED.",
     "copy": "Copy",
     "copied": "Copied",
     "updateKeys": "Update keys",
@@ -426,6 +432,49 @@
         "model": "Model",
         "modelHelp": "Welk model antwoordt. De lijst komt uit uw account, nieuwste eerst. Kleinere modellen kosten minder per aanroep en volstaan voor de meeste werkplaatsteksten."
       }
+    },
+    "stripe": {
+      "description": "Kaartbetalingen via de factuurlink, op je eigen Stripe-account. De klant betaalt, Stripe meldt het aan Torqvoice en de factuur markeert zichzelf als betaald.",
+      "fields": {
+        "secretKey": "Geheime sleutel",
+        "secretKeyHelp": "Uit het Stripe-dashboard onder Developers, API keys. Testsleutels verwerken niets echts, dus probeer er eerst een betaling mee en wissel daarna naar live-sleutels.",
+        "publishableKey": "Publiceerbare sleutel",
+        "webhookSecret": "Ondertekeningsgeheim van webhook",
+        "webhookSecretHelp": "Stripe toont dit zodra je de meldings-URL van deze pagina als webhook toevoegt. Hiermee wordt elke melding geverifieerd voordat een betaling wordt vastgelegd."
+      },
+      "settings": {
+        "enabled": "Aanbieden op factuurlinks",
+        "enabledHelp": "Zet uit om de betaalknop een tijdje van gedeelde facturen te halen, zonder los te koppelen. Al gestarte betalingen worden nog steeds vastgelegd."
+      }
+    },
+    "vipps": {
+      "description": "Vipps MobilePay via de factuurlink. De klant betaalt in de app die hij al heeft en de factuur markeert zichzelf als betaald.",
+      "fields": {
+        "clientId": "Client-ID",
+        "clientSecret": "Client secret",
+        "subscriptionKey": "Subscription key",
+        "subscriptionKeyHelp": "De Ocp-Apim-Subscription-Key uit het Vipps MobilePay-portaal, onder de API-sleutels van de verkoopeenheid.",
+        "merchantSerialNumber": "Merchant serial number"
+      },
+      "settings": {
+        "enabled": "Aanbieden op factuurlinks",
+        "enabledHelp": "Zet uit om de betaalknop een tijdje van gedeelde facturen te halen, zonder los te koppelen. Al gestarte betalingen worden nog steeds vastgelegd.",
+        "testMode": "Testmodus",
+        "testModeHelp": "Gebruik de testomgeving van Vipps met testsleutels. Laat dit aan tot een volledige betaling is gelukt, zet het dan uit en vul de live-sleutels in."
+      }
+    },
+    "paypal": {
+      "description": "PayPal-checkout via de factuurlink, met je eigen PayPal-app. De klant betaalt met een PayPal-account of een kaart en de factuur markeert zichzelf als betaald.",
+      "fields": {
+        "clientId": "Client-ID",
+        "clientSecret": "Client secret"
+      },
+      "settings": {
+        "enabled": "Aanbieden op factuurlinks",
+        "enabledHelp": "Zet uit om de betaalknop een tijdje van gedeelde facturen te halen, zonder los te koppelen. Al gestarte betalingen worden nog steeds vastgelegd.",
+        "sandbox": "Sandboxmodus",
+        "sandboxHelp": "Gebruik de sandbox van PayPal met de sleutels van de sandbox-app. Laat dit aan tot een volledige betaling is gelukt, zet het dan uit en vul de sleutels van de live-app in."
+      }
     }
   },
   "jobKinds": {

+ 7 - 22
messages/nl/settings.json

@@ -831,32 +831,17 @@
     "paymentTermsPlaceholder": "30 dagen netto",
     "paymentTermsHint": "Betalingsvoorwaarden op facturen (bijv. \"30 dagen netto\", \"Bij ontvangst\")",
     "onlinePayments": "Online betalingen",
-    "onlinePaymentsDescription": "Laat klanten facturen online betalen via gedeelde factuurlinks.",
-    "stripe": "Stripe",
-    "stripeDescription": "Creditcard-/debitcardbetalingen accepteren",
-    "secretKey": "Geheime sleutel",
-    "publishableKey": "Publieke sleutel",
-    "webhookSecret": "Webhook-geheim",
-    "webhookUrl": "Webhook-URL",
-    "stripeWebhookHint": "Voeg deze URL toe in uw Stripe Dashboard onder Webhooks. Abonneer op <code>checkout.session.completed</code>.",
-    "vipps": "Vipps",
-    "vippsDescription": "Vipps mobiele betalingen accepteren (Noorwegen)",
-    "clientId": "Client-ID",
-    "clientSecret": "Client-geheim",
-    "subscriptionKey": "Abonnementssleutel",
-    "merchantSerialNumber": "Handelaarserienummer",
-    "testMode": "Testmodus",
-    "callbackUrl": "Callback-URL",
-    "termsOfSale": "Verkoopvoorwaarden (Salgsvilkar)",
-    "termsOfSaleDescription": "Vereist door Vipps. Klanten kunnen deze bekijken vanaf de factuurpagina.",
+    "onlinePaymentsDescription": "Laat klanten facturen betalen via de link die je ze stuurt. Stripe, Vipps en PayPal koppel je onder Integraties en kunnen naast elkaar draaien.",
+    "onlinePaymentsHint": "Als een klant betaalt, meldt de aanbieder dat aan Torqvoice en markeert de factuur zichzelf als betaald. De pagina van elke aanbieder toont de meldings-URL die hij nodig heeft.",
+    "offered": "Op factuurlinks",
+    "paused": "Gekoppeld, niet aangeboden",
+    "termsOfSale": "Verkoopvoorwaarden",
+    "termsOfSaleDescription": "Waar een klant mee akkoord gaat bij online betalen. Vipps vereist gepubliceerde voorwaarden; de factuur linkt er hoe dan ook naar.",
+    "termsOfSaleText": "Tekst van de voorwaarden",
     "externalTermsUrl": "Externe voorwaarden-URL (optioneel)",
     "externalTermsUrlHint": "Link naar een externe pagina met uw voorwaarden.",
     "publicTermsUrl": "Publieke voorwaarden-URL",
     "publicTermsUrlHint": "Deel deze URL met Vipps of link ernaar vanaf uw website.",
-    "paypal": "PayPal",
-    "paypalDescription": "PayPal-betalingen accepteren",
-    "sandboxMode": "Sandboxmodus",
-    "paypalWebhookHint": "Voeg deze URL toe in uw PayPal Developer Dashboard onder Webhooks. Abonneer op <code>PAYMENT.CAPTURE.COMPLETED</code>.",
     "savePayment": "Betalingsinstellingen opslaan",
     "saved": "Betalingsinstellingen opgeslagen"
   },

+ 49 - 0
messages/pl/integrations.json

@@ -55,6 +55,9 @@
     },
     "ai": {
       "chat": "Teksty i podsumowania"
+    },
+    "payments": {
+      "checkout": "Płatność z linku do faktury"
     }
   },
   "statuses": {
@@ -105,6 +108,9 @@
     "inboundUrl": "Inbound webhook URL",
     "inboundHintSecret": "Paste this URL into the vendor's inbound message settings. It carries a secret that identifies your workshop, so keep it private.",
     "inboundHintMeta": "Paste this URL as the callback URL in your Meta app, with the verify token you entered above, and subscribe to messages.",
+    "inboundHintStripe": "Dodaj ten adres URL w panelu Stripe w sekcji Developers, Webhooks, z subskrypcją checkout.session.completed. Następnie wklej sekret podpisu pokazany przez Stripe w polu sekretu webhooka tutaj, aby powiadomienia mogły być weryfikowane.",
+    "inboundHintVipps": "Wpisz ten adres URL jako adres callback w portalu Vipps MobilePay. Vipps wymaga też opublikowanych warunków sprzedaży, które ustawia się w Ustawienia, Płatność.",
+    "inboundHintPaypal": "Dodaj ten adres URL w panelu dewelopera PayPal w sekcji Webhooks, z subskrypcją PAYMENT.CAPTURE.COMPLETED.",
     "copy": "Copy",
     "copied": "Copied",
     "updateKeys": "Update keys",
@@ -426,6 +432,49 @@
         "model": "Model",
         "modelHelp": "Który model odpowiada. Lista pochodzi z Twojego konta, najnowsze na górze. Mniejsze modele kosztują mniej za wywołanie i wystarczają do większości tekstów warsztatowych."
       }
+    },
+    "stripe": {
+      "description": "Płatności kartą z linku do faktury, na Twoje własne konto Stripe. Klient płaci, Stripe informuje Torqvoice, a faktura sama oznacza się jako opłacona.",
+      "fields": {
+        "secretKey": "Klucz tajny",
+        "secretKeyHelp": "Z panelu Stripe w sekcji Developers, API keys. Klucze testowe niczego naprawdę nie przetwarzają, więc najpierw wypróbuj na nich płatność, a potem przejdź na klucze produkcyjne.",
+        "publishableKey": "Klucz publikowalny",
+        "webhookSecret": "Sekret podpisu webhooka",
+        "webhookSecretHelp": "Stripe pokazuje go po dodaniu adresu powiadomień z tej strony jako webhooka. Dzięki niemu każde powiadomienie jest weryfikowane przed zapisaniem płatności."
+      },
+      "settings": {
+        "enabled": "Oferuj w linkach do faktur",
+        "enabledHelp": "Wyłącz, aby na jakiś czas usunąć przycisk płatności z udostępnianych faktur bez rozłączania. Rozpoczęte już płatności nadal są rejestrowane."
+      }
+    },
+    "vipps": {
+      "description": "Vipps MobilePay z linku do faktury. Klient płaci w aplikacji, którą już ma, a faktura sama oznacza się jako opłacona.",
+      "fields": {
+        "clientId": "Identyfikator klienta",
+        "clientSecret": "Sekret klienta",
+        "subscriptionKey": "Klucz subskrypcji",
+        "subscriptionKeyHelp": "Ocp-Apim-Subscription-Key z portalu Vipps MobilePay, w kluczach API jednostki sprzedaży.",
+        "merchantSerialNumber": "Numer seryjny sprzedawcy"
+      },
+      "settings": {
+        "enabled": "Oferuj w linkach do faktur",
+        "enabledHelp": "Wyłącz, aby na jakiś czas usunąć przycisk płatności z udostępnianych faktur bez rozłączania. Rozpoczęte już płatności nadal są rejestrowane.",
+        "testMode": "Tryb testowy",
+        "testModeHelp": "Używaj środowiska testowego Vipps z kluczami testowymi. Zostaw włączony, aż pełna płatność przejdzie, potem wyłącz i wpisz klucze produkcyjne."
+      }
+    },
+    "paypal": {
+      "description": "Płatność PayPal z linku do faktury przez Twoją własną aplikację PayPal. Klient płaci kontem PayPal lub kartą, a faktura sama oznacza się jako opłacona.",
+      "fields": {
+        "clientId": "Identyfikator klienta",
+        "clientSecret": "Sekret klienta"
+      },
+      "settings": {
+        "enabled": "Oferuj w linkach do faktur",
+        "enabledHelp": "Wyłącz, aby na jakiś czas usunąć przycisk płatności z udostępnianych faktur bez rozłączania. Rozpoczęte już płatności nadal są rejestrowane.",
+        "sandbox": "Tryb sandbox",
+        "sandboxHelp": "Używaj sandboxa PayPal z kluczami aplikacji sandbox. Zostaw włączony, aż pełna płatność przejdzie, potem wyłącz i wpisz klucze aplikacji produkcyjnej."
+      }
     }
   },
   "jobKinds": {

+ 7 - 22
messages/pl/settings.json

@@ -831,32 +831,17 @@
     "paymentTermsPlaceholder": "14 dni netto",
     "paymentTermsHint": "Tekst warunkow platnosci na fakturach (np. \"14 dni netto\", \"Platne przy odbiorze\")",
     "onlinePayments": "Platnosci online",
-    "onlinePaymentsDescription": "Pozwól klientom płacić faktury online przez udostępnione linki do faktur.",
-    "stripe": "Stripe",
-    "stripeDescription": "Akceptuj platnosci karta kredytowa/debetowa",
-    "secretKey": "Klucz tajny",
-    "publishableKey": "Klucz publiczny",
-    "webhookSecret": "Sekret webhook",
-    "webhookUrl": "URL webhook",
-    "stripeWebhookHint": "Dodaj ten URL w panelu Stripe w sekcji Webhooks. Subskrybuj <code>checkout.session.completed</code>.",
-    "vipps": "Vipps",
-    "vippsDescription": "Akceptuj platnosci mobilne Vipps (Norwegia)",
-    "clientId": "ID klienta",
-    "clientSecret": "Sekret klienta",
-    "subscriptionKey": "Klucz subskrypcji",
-    "merchantSerialNumber": "Numer seryjny sprzedawcy",
-    "testMode": "Tryb testowy",
-    "callbackUrl": "URL callback",
-    "termsOfSale": "Warunki sprzedaży (Salgsvilkår)",
-    "termsOfSaleDescription": "Wymagane przez Vipps. Klienci mogą je wyswietlic ze strony faktury.",
+    "onlinePaymentsDescription": "Pozwól klientom płacić faktury z linku, który im wysyłasz. Stripe, Vipps i PayPal podłącza się w Integracjach i mogą działać równolegle.",
+    "onlinePaymentsHint": "Gdy klient płaci, dostawca informuje Torqvoice, a faktura sama oznacza się jako opłacona. Strona każdego dostawcy pokazuje potrzebny mu adres powiadomień.",
+    "offered": "W linkach do faktur",
+    "paused": "Podłączony, nieoferowany",
+    "termsOfSale": "Warunki sprzedaży",
+    "termsOfSaleDescription": "To, na co klient zgadza się, płacąc online. Vipps wymaga opublikowanych warunków; faktura i tak do nich linkuje.",
+    "termsOfSaleText": "Treść warunków",
     "externalTermsUrl": "Zewnetrzny URL warunkow (opcjonalnie)",
     "externalTermsUrlHint": "Link do zewnetrznej strony z warunkami. Jeśli ustawiony, faktura będzie tu kierowac.",
     "publicTermsUrl": "Publiczny URL warunkow",
     "publicTermsUrlHint": "Udostępnij ten URL Vipps lub podlacz go ze swojej strony.",
-    "paypal": "PayPal",
-    "paypalDescription": "Akceptuj platnosci PayPal",
-    "sandboxMode": "Tryb sandbox",
-    "paypalWebhookHint": "Dodaj ten URL w panelu PayPal Developer w sekcji Webhooks. Subskrybuj <code>PAYMENT.CAPTURE.COMPLETED</code>.",
     "savePayment": "Zapisz ustawienia platnosci",
     "saved": "Ustawienia platnosci zapisane"
   },

+ 49 - 0
messages/pt-BR/integrations.json

@@ -55,6 +55,9 @@
     },
     "ai": {
       "chat": "Textos e resumos"
+    },
+    "payments": {
+      "checkout": "Pagar pelo link da fatura"
     }
   },
   "statuses": {
@@ -105,6 +108,9 @@
     "inboundUrl": "Inbound webhook URL",
     "inboundHintSecret": "Paste this URL into the vendor's inbound message settings. It carries a secret that identifies your workshop, so keep it private.",
     "inboundHintMeta": "Paste this URL as the callback URL in your Meta app, with the verify token you entered above, and subscribe to messages.",
+    "inboundHintStripe": "Adicione esta URL no painel do Stripe em Developers, Webhooks, inscrita em checkout.session.completed. Depois cole o segredo de assinatura que o Stripe mostra no campo de segredo do webhook aqui, para que as notificações possam ser verificadas.",
+    "inboundHintVipps": "Informe esta URL como URL de callback no portal do Vipps MobilePay. O Vipps também exige termos de venda publicados, definidos em Configurações, Pagamento.",
+    "inboundHintPaypal": "Adicione esta URL no painel de desenvolvedor do PayPal em Webhooks, inscrita em PAYMENT.CAPTURE.COMPLETED.",
     "copy": "Copy",
     "copied": "Copied",
     "updateKeys": "Update keys",
@@ -426,6 +432,49 @@
         "model": "Modelo",
         "modelHelp": "Qual modelo responde. A lista vem da sua conta, os mais novos primeiro. Modelos menores custam menos por chamada e bastam para a maior parte dos textos da oficina."
       }
+    },
+    "stripe": {
+      "description": "Pagamentos com cartão pelo link da fatura, na sua própria conta Stripe. O cliente paga, o Stripe avisa a Torqvoice e a fatura se marca como paga sozinha.",
+      "fields": {
+        "secretKey": "Chave secreta",
+        "secretKeyHelp": "Do painel do Stripe em Developers, API keys. Chaves de teste não processam nada real, então teste um pagamento com elas primeiro e troque pelas chaves reais depois.",
+        "publishableKey": "Chave publicável",
+        "webhookSecret": "Segredo de assinatura do webhook",
+        "webhookSecretHelp": "Mostrado pelo Stripe quando você adiciona a URL de notificação desta página como webhook. Com ele, cada notificação é verificada antes de registrar um pagamento."
+      },
+      "settings": {
+        "enabled": "Oferecer nos links de fatura",
+        "enabledHelp": "Desative para tirar o botão de pagamento das faturas compartilhadas por um tempo, sem desconectar. Pagamentos já iniciados continuam sendo registrados."
+      }
+    },
+    "vipps": {
+      "description": "Vipps MobilePay pelo link da fatura. O cliente paga no app que já tem e a fatura se marca como paga sozinha.",
+      "fields": {
+        "clientId": "ID do cliente",
+        "clientSecret": "Segredo do cliente",
+        "subscriptionKey": "Chave de assinatura",
+        "subscriptionKeyHelp": "A Ocp-Apim-Subscription-Key do portal do Vipps MobilePay, nas chaves de API da unidade de venda.",
+        "merchantSerialNumber": "Número de série do comerciante"
+      },
+      "settings": {
+        "enabled": "Oferecer nos links de fatura",
+        "enabledHelp": "Desative para tirar o botão de pagamento das faturas compartilhadas por um tempo, sem desconectar. Pagamentos já iniciados continuam sendo registrados.",
+        "testMode": "Modo de teste",
+        "testModeHelp": "Usar o ambiente de teste do Vipps com chaves de teste. Deixe ligado até um pagamento completo funcionar, depois desligue e informe as chaves reais."
+      }
+    },
+    "paypal": {
+      "description": "Checkout do PayPal pelo link da fatura, pelo seu próprio app do PayPal. O cliente paga com conta PayPal ou cartão e a fatura se marca como paga sozinha.",
+      "fields": {
+        "clientId": "ID do cliente",
+        "clientSecret": "Segredo do cliente"
+      },
+      "settings": {
+        "enabled": "Oferecer nos links de fatura",
+        "enabledHelp": "Desative para tirar o botão de pagamento das faturas compartilhadas por um tempo, sem desconectar. Pagamentos já iniciados continuam sendo registrados.",
+        "sandbox": "Modo sandbox",
+        "sandboxHelp": "Usar o sandbox do PayPal com as chaves do app de sandbox. Deixe ligado até um pagamento completo funcionar, depois desligue e informe as chaves do app real."
+      }
     }
   },
   "jobKinds": {

+ 7 - 22
messages/pt-BR/settings.json

@@ -831,32 +831,17 @@
     "paymentTermsPlaceholder": "Líquido 14 dias",
     "paymentTermsHint": "Texto das condições de pagamento nas faturas (ex. \"Líquido 14 dias\", \"No recebimento\")",
     "onlinePayments": "Pagamentos online",
-    "onlinePaymentsDescription": "Permita que os clientes paguem faturas online atraves de links de fatura compartilhados.",
-    "stripe": "Stripe",
-    "stripeDescription": "Aceitar pagamentos com cartão de credito/debito",
-    "secretKey": "Chave secreta",
-    "publishableKey": "Chave publica",
-    "webhookSecret": "Segredo do webhook",
-    "webhookUrl": "URL do webhook",
-    "stripeWebhookHint": "Adicione esta URL no Painel do Stripe em Webhooks. Inscreva-se em <code>checkout.session.completed</code>.",
-    "vipps": "Vipps",
-    "vippsDescription": "Aceitar pagamentos moveis Vipps (Noruega)",
-    "clientId": "ID do cliente",
-    "clientSecret": "Segredo do cliente",
-    "subscriptionKey": "Chave de assinatura",
-    "merchantSerialNumber": "Número de serie do comerciante",
-    "testMode": "Modo de teste",
-    "callbackUrl": "URL de callback",
-    "termsOfSale": "Condições de venda (Salgsvilkår)",
-    "termsOfSaleDescription": "Exigido pelo Vipps. Os clientes podem visualizar na página da fatura.",
+    "onlinePaymentsDescription": "Deixe os clientes pagarem faturas pelo link que você envia. Stripe, Vipps e PayPal são conectados em Integrações e podem funcionar juntos.",
+    "onlinePaymentsHint": "Quando um cliente paga, o provedor avisa a Torqvoice e a fatura se marca como paga sozinha. A página de cada provedor mostra a URL de notificação de que ele precisa.",
+    "offered": "Nos links de fatura",
+    "paused": "Conectado, não oferecido",
+    "termsOfSale": "Termos de venda",
+    "termsOfSaleDescription": "O que um cliente aceita ao pagar online. O Vipps exige termos publicados; a fatura faz link para eles de qualquer forma.",
+    "termsOfSaleText": "Texto dos termos",
     "externalTermsUrl": "URL de condições externas (opcional)",
     "externalTermsUrlHint": "Link para uma página externa com suas condições. Se definido, a fatura ira direcionar aqui.",
     "publicTermsUrl": "URL publica das condições",
     "publicTermsUrlHint": "Compartilhe esta URL com o Vipps ou vincule-a ao seu site.",
-    "paypal": "PayPal",
-    "paypalDescription": "Aceitar pagamentos PayPal",
-    "sandboxMode": "Modo sandbox",
-    "paypalWebhookHint": "Adicione esta URL no Painel de Desenvolvedor do PayPal em Webhooks. Inscreva-se em <code>PAYMENT.CAPTURE.COMPLETED</code>.",
     "savePayment": "Salvar configurações de pagamento",
     "saved": "Configurações de pagamento salvas"
   },

+ 49 - 0
messages/ru/integrations.json

@@ -55,6 +55,9 @@
     },
     "ai": {
       "chat": "Тексты и сводки"
+    },
+    "payments": {
+      "checkout": "Оплата по ссылке на счёт"
     }
   },
   "statuses": {
@@ -105,6 +108,9 @@
     "inboundUrl": "Inbound webhook URL",
     "inboundHintSecret": "Paste this URL into the vendor's inbound message settings. It carries a secret that identifies your workshop, so keep it private.",
     "inboundHintMeta": "Paste this URL as the callback URL in your Meta app, with the verify token you entered above, and subscribe to messages.",
+    "inboundHintStripe": "Добавьте этот URL в панели Stripe в разделе Developers, Webhooks с подпиской на checkout.session.completed. Затем вставьте секрет подписи, который покажет Stripe, в поле секрета вебхука здесь, чтобы уведомления можно было проверять.",
+    "inboundHintVipps": "Укажите этот URL как callback-URL в портале Vipps MobilePay. Vipps также требует опубликованных условий продажи, они задаются в разделе Настройки, Оплата.",
+    "inboundHintPaypal": "Добавьте этот URL в панели разработчика PayPal в разделе Webhooks с подпиской на PAYMENT.CAPTURE.COMPLETED.",
     "copy": "Copy",
     "copied": "Copied",
     "updateKeys": "Update keys",
@@ -426,6 +432,49 @@
         "model": "Модель",
         "modelHelp": "Какая модель отвечает. Список берётся из вашего аккаунта, новые сверху. Модели поменьше дешевле за запрос и справляются с большинством текстов мастерской."
       }
+    },
+    "stripe": {
+      "description": "Оплата картой по ссылке на счёт на ваш собственный аккаунт Stripe. Клиент платит, Stripe сообщает Torqvoice, и счёт сам отмечается оплаченным.",
+      "fields": {
+        "secretKey": "Секретный ключ",
+        "secretKeyHelp": "Из панели Stripe в разделе Developers, API keys. Тестовые ключи ничего реального не проводят, поэтому сначала попробуйте платёж на них, а затем переключитесь на боевые ключи.",
+        "publishableKey": "Публикуемый ключ",
+        "webhookSecret": "Секрет подписи вебхука",
+        "webhookSecretHelp": "Stripe показывает его, когда вы добавляете URL уведомлений с этой страницы как вебхук. С ним каждое уведомление проверяется перед записью платежа."
+      },
+      "settings": {
+        "enabled": "Предлагать в ссылках на счета",
+        "enabledHelp": "Выключите, чтобы на время убрать кнопку оплаты с общих счетов, не отключая интеграцию. Уже начатые платежи всё равно записываются."
+      }
+    },
+    "vipps": {
+      "description": "Vipps MobilePay по ссылке на счёт. Клиент платит в приложении, которое у него уже есть, и счёт сам отмечается оплаченным.",
+      "fields": {
+        "clientId": "Client ID",
+        "clientSecret": "Client secret",
+        "subscriptionKey": "Ключ подписки",
+        "subscriptionKeyHelp": "Ocp-Apim-Subscription-Key из портала Vipps MobilePay, в API-ключах торговой единицы.",
+        "merchantSerialNumber": "Серийный номер продавца"
+      },
+      "settings": {
+        "enabled": "Предлагать в ссылках на счета",
+        "enabledHelp": "Выключите, чтобы на время убрать кнопку оплаты с общих счетов, не отключая интеграцию. Уже начатые платежи всё равно записываются.",
+        "testMode": "Тестовый режим",
+        "testModeHelp": "Использовать тестовую среду Vipps с тестовыми ключами. Оставьте включённым, пока не пройдёт полный платёж, затем выключите и введите боевые ключи."
+      }
+    },
+    "paypal": {
+      "description": "Оплата через PayPal по ссылке на счёт через ваше собственное приложение PayPal. Клиент платит аккаунтом PayPal или картой, и счёт сам отмечается оплаченным.",
+      "fields": {
+        "clientId": "Client ID",
+        "clientSecret": "Client secret"
+      },
+      "settings": {
+        "enabled": "Предлагать в ссылках на счета",
+        "enabledHelp": "Выключите, чтобы на время убрать кнопку оплаты с общих счетов, не отключая интеграцию. Уже начатые платежи всё равно записываются.",
+        "sandbox": "Режим песочницы",
+        "sandboxHelp": "Использовать песочницу PayPal с ключами приложения песочницы. Оставьте включённым, пока не пройдёт полный платёж, затем выключите и введите ключи боевого приложения."
+      }
     }
   },
   "jobKinds": {

+ 7 - 22
messages/ru/settings.json

@@ -831,32 +831,17 @@
     "paymentTermsPlaceholder": "14 дней",
     "paymentTermsHint": "Текст условий оплаты, отображаемый на счетах (напр. «14 дней», «По получении»)",
     "onlinePayments": "Онлайн-платежи",
-    "onlinePaymentsDescription": "Позвольте клиентам оплачивать счета онлайн через общие ссылки на счета.",
-    "stripe": "Stripe",
-    "stripeDescription": "Приём оплаты кредитными/дебетовыми картами",
-    "secretKey": "Секретный ключ",
-    "publishableKey": "Публичный ключ",
-    "webhookSecret": "Секрет вебхука",
-    "webhookUrl": "URL вебхука",
-    "stripeWebhookHint": "Добавьте этот URL в панели управления Stripe в разделе Webhooks. Подпишитесь на <code>checkout.session.completed</code>.",
-    "vipps": "Vipps",
-    "vippsDescription": "Приём мобильных платежей Vipps (Норвегия)",
-    "clientId": "ID клиента",
-    "clientSecret": "Секрет клиента",
-    "subscriptionKey": "Ключ подписки",
-    "merchantSerialNumber": "Серийный номер продавца",
-    "testMode": "Тестовый режим",
-    "callbackUrl": "URL обратного вызова",
-    "termsOfSale": "Условия продажи (Salgsvilkar)",
-    "termsOfSaleDescription": "Требуется Vipps. Клиенты могут просмотреть их со страницы счёта.",
+    "onlinePaymentsDescription": "Позвольте клиентам оплачивать счета по ссылке, которую вы им отправляете. Stripe, Vipps и PayPal подключаются в разделе Интеграции и могут работать одновременно.",
+    "onlinePaymentsHint": "Когда клиент платит, провайдер сообщает Torqvoice, и счёт сам отмечается оплаченным. На странице каждого провайдера показан нужный ему URL уведомлений.",
+    "offered": "В ссылках на счета",
+    "paused": "Подключено, не предлагается",
+    "termsOfSale": "Условия продажи",
+    "termsOfSaleDescription": "То, с чем клиент соглашается при онлайн-оплате. Vipps требует опубликованных условий; счёт в любом случае ссылается на них.",
+    "termsOfSaleText": "Текст условий",
     "externalTermsUrl": "Внешний URL условий (необязательно)",
     "externalTermsUrlHint": "Ссылка на внешнюю страницу с вашими условиями. Если указана, счёт будет ссылаться на неё вместо встроенной страницы условий.",
     "publicTermsUrl": "Публичный URL условий",
     "publicTermsUrlHint": "Предоставьте этот URL в Vipps или разместите на своём сайте.",
-    "paypal": "PayPal",
-    "paypalDescription": "Приём платежей через PayPal",
-    "sandboxMode": "Режим песочницы",
-    "paypalWebhookHint": "Добавьте этот URL в панели разработчика PayPal в разделе Webhooks. Подпишитесь на <code>PAYMENT.CAPTURE.COMPLETED</code>.",
     "savePayment": "Сохранить настройки оплаты",
     "saved": "Настройки оплаты сохранены"
   },

+ 49 - 0
messages/tr/integrations.json

@@ -55,6 +55,9 @@
     },
     "ai": {
       "chat": "Metinler ve özetler"
+    },
+    "payments": {
+      "checkout": "Fatura bağlantısından ödeme"
     }
   },
   "statuses": {
@@ -105,6 +108,9 @@
     "inboundUrl": "Inbound webhook URL",
     "inboundHintSecret": "Paste this URL into the vendor's inbound message settings. It carries a secret that identifies your workshop, so keep it private.",
     "inboundHintMeta": "Paste this URL as the callback URL in your Meta app, with the verify token you entered above, and subscribe to messages.",
+    "inboundHintStripe": "Bu URL'yi Stripe panelinde Developers, Webhooks altında checkout.session.completed olayına abone olarak ekleyin. Ardından Stripe'ın gösterdiği imza sırrını buradaki webhook sırrı alanına yapıştırın ki bildirimler doğrulanabilsin.",
+    "inboundHintVipps": "Bu URL'yi Vipps MobilePay portalında geri çağırma URL'si olarak girin. Vipps ayrıca yayımlanmış satış koşulları ister; bunlar Ayarlar, Ödeme altında ayarlanır.",
+    "inboundHintPaypal": "Bu URL'yi PayPal geliştirici panelinde Webhooks altında PAYMENT.CAPTURE.COMPLETED olayına abone olarak ekleyin.",
     "copy": "Copy",
     "copied": "Copied",
     "updateKeys": "Update keys",
@@ -426,6 +432,49 @@
         "model": "Model",
         "modelHelp": "Hangi modelin yanıtlayacağı. Liste hesabınızdan okunur, en yenisi üstte. Küçük modeller çağrı başına daha ucuzdur ve servis metinlerinin çoğuna yeter."
       }
+    },
+    "stripe": {
+      "description": "Fatura bağlantısından kendi Stripe hesabınıza kart ödemesi. Müşteri öder, Stripe Torqvoice'a bildirir ve fatura kendini ödendi olarak işaretler.",
+      "fields": {
+        "secretKey": "Gizli anahtar",
+        "secretKeyHelp": "Stripe panelinde Developers, API keys altından. Test anahtarları gerçek hiçbir şeyi işlemez; önce onlarla bir ödeme deneyin, sonra canlı anahtarlara geçin.",
+        "publishableKey": "Yayımlanabilir anahtar",
+        "webhookSecret": "Webhook imza sırrı",
+        "webhookSecretHelp": "Bu sayfadaki bildirim URL'sini webhook olarak eklediğinizde Stripe gösterir. Onunla her bildirim, ödeme kaydedilmeden önce doğrulanır."
+      },
+      "settings": {
+        "enabled": "Fatura bağlantılarında sun",
+        "enabledHelp": "Bağlantıyı kesmeden ödeme düğmesini paylaşılan faturalardan bir süreliğine kaldırmak için kapatın. Zaten başlatılmış ödemeler yine kaydedilir."
+      }
+    },
+    "vipps": {
+      "description": "Fatura bağlantısından Vipps MobilePay. Müşteri zaten sahip olduğu uygulamada öder ve fatura kendini ödendi olarak işaretler.",
+      "fields": {
+        "clientId": "İstemci kimliği",
+        "clientSecret": "İstemci sırrı",
+        "subscriptionKey": "Abonelik anahtarı",
+        "subscriptionKeyHelp": "Vipps MobilePay portalında satış biriminin API anahtarları altındaki Ocp-Apim-Subscription-Key.",
+        "merchantSerialNumber": "Satıcı seri numarası"
+      },
+      "settings": {
+        "enabled": "Fatura bağlantılarında sun",
+        "enabledHelp": "Bağlantıyı kesmeden ödeme düğmesini paylaşılan faturalardan bir süreliğine kaldırmak için kapatın. Zaten başlatılmış ödemeler yine kaydedilir.",
+        "testMode": "Test modu",
+        "testModeHelp": "Vipps test ortamını test anahtarlarıyla kullanın. Tam bir ödeme geçene kadar açık bırakın, sonra kapatıp canlı anahtarları girin."
+      }
+    },
+    "paypal": {
+      "description": "Kendi PayPal uygulamanız üzerinden fatura bağlantısından PayPal ödemesi. Müşteri PayPal hesabı veya kartla öder ve fatura kendini ödendi olarak işaretler.",
+      "fields": {
+        "clientId": "İstemci kimliği",
+        "clientSecret": "İstemci sırrı"
+      },
+      "settings": {
+        "enabled": "Fatura bağlantılarında sun",
+        "enabledHelp": "Bağlantıyı kesmeden ödeme düğmesini paylaşılan faturalardan bir süreliğine kaldırmak için kapatın. Zaten başlatılmış ödemeler yine kaydedilir.",
+        "sandbox": "Sandbox modu",
+        "sandboxHelp": "PayPal sandbox'ını sandbox uygulama anahtarlarıyla kullanın. Tam bir ödeme geçene kadar açık bırakın, sonra kapatıp canlı uygulamanın anahtarlarını girin."
+      }
     }
   },
   "jobKinds": {

+ 7 - 22
messages/tr/settings.json

@@ -831,32 +831,17 @@
     "paymentTermsPlaceholder": "Net 14 gün",
     "paymentTermsHint": "Faturalarda gösterilen ödeme koşulları metni (ör. \"Net 14 gün\", \"Teslimde ödeme\")",
     "onlinePayments": "Online Ödemeler",
-    "onlinePaymentsDescription": "Müşterilerin paylaşılan fatura linkleri üzerinden online ödeme yapmasına izin verin.",
-    "stripe": "Stripe",
-    "stripeDescription": "Kredi/banka kartı ödemelerini kabul edin",
-    "secretKey": "Gizli Anahtar",
-    "publishableKey": "Yayınlanabilir Anahtar",
-    "webhookSecret": "Webhook Gizli Anahtarı",
-    "webhookUrl": "Webhook URL'si",
-    "stripeWebhookHint": "Bu URL'yi Stripe Panonuzda Webhooks altına ekleyin. <code>checkout.session.completed</code> olayına abone olun.",
-    "vipps": "Vipps",
-    "vippsDescription": "Vipps mobil ödemelerini kabul edin (Norveç)",
-    "clientId": "İstemci Kimliği",
-    "clientSecret": "İstemci Gizli Anahtarı",
-    "subscriptionKey": "Abonelik Anahtarı",
-    "merchantSerialNumber": "Satıcı Seri Numarası",
-    "testMode": "Test Modu",
-    "callbackUrl": "Geri Çağırma URL'si",
-    "termsOfSale": "Satış Koşulları (Salgsvilkår)",
-    "termsOfSaleDescription": "Vipps tarafından zorunludur. Müşteriler bunları fatura sayfasından görüntüleyebilir.",
+    "onlinePaymentsDescription": "Müşterilerin, gönderdiğiniz bağlantıdan fatura ödemesine izin verin. Stripe, Vipps ve PayPal, Entegrasyonlar altında bağlanır ve birlikte çalışabilir.",
+    "onlinePaymentsHint": "Bir müşteri ödediğinde sağlayıcı Torqvoice'a bildirir ve fatura kendini ödendi olarak işaretler. Her sağlayıcının sayfası, ihtiyaç duyduğu bildirim URL'sini gösterir.",
+    "offered": "Fatura bağlantılarında",
+    "paused": "Bağlı, sunulmuyor",
+    "termsOfSale": "Satış koşulları",
+    "termsOfSaleDescription": "Müşterinin çevrimiçi öderken kabul ettiği şey. Vipps yayımlanmış koşullar ister; fatura her durumda onlara bağlantı verir.",
+    "termsOfSaleText": "Koşul metni",
     "externalTermsUrl": "Harici Koşullar URL'si (isteğe bağlı)",
     "externalTermsUrlHint": "Koşullarınızın bulunduğu harici bir sayfaya bağlantı. Ayarlanırsa, fatura yerleşik koşullar sayfası yerine buraya bağlanır.",
     "publicTermsUrl": "Genel Koşullar URL'si",
     "publicTermsUrlHint": "Bu URL'yi Vipps ile paylaşın veya web sitenizden bağlantı verin.",
-    "paypal": "PayPal",
-    "paypalDescription": "PayPal ödemelerini kabul edin",
-    "sandboxMode": "Sandbox Modu",
-    "paypalWebhookHint": "Bu URL'yi PayPal Geliştirici Panonuzda Webhooks altına ekleyin. <code>PAYMENT.CAPTURE.COMPLETED</code> olayına abone olun.",
     "savePayment": "Ödeme Ayarlarını Kaydet",
     "saved": "Ödeme ayarları kaydedildi"
   },

+ 1 - 0
public/images/integrations/paypal.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48" role="img" aria-label="PayPal"><title>PayPal</title><rect width="48" height="48" rx="11" fill="#fff"/><rect x="0.5" y="0.5" width="47" height="47" rx="10.5" fill="none" stroke="#E5E7EB"/><path fill-rule="evenodd" d="M15.5 8h9.5c5.2 0 8.5 2.8 8.5 7.2 0 5.8-4.2 9.3-10.3 9.3h-3.2l-1.9 9.5h-6.6zm7.3 5.4-1.3 6.2h2.4c2.8 0 4.6-1.4 4.6-3.6 0-1.7-1.2-2.6-3.3-2.6z" fill="#003087"/><path fill-rule="evenodd" d="M20 15.5h9.5c5.2 0 8.5 2.8 8.5 7.2 0 5.8-4.2 9.3-10.3 9.3h-3.2L22.6 41.5H16zm7.3 5.4L26 27.1h2.4c2.8 0 4.6-1.4 4.6-3.6 0-1.7-1.2-2.6-3.3-2.6z" fill="#009CDE"/></svg>

+ 1 - 0
public/images/integrations/stripe.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48" role="img" aria-label="Stripe"><title>Stripe</title><rect width="48" height="48" rx="11" fill="#5433FF"/><path d="M13 17.5 35 13v17.5L13 35z" fill="#fff"/></svg>

+ 1 - 0
public/images/integrations/vipps.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48" role="img" aria-label="Vipps MobilePay"><title>Vipps MobilePay</title><rect width="48" height="48" rx="11" fill="#FF5B24"/><circle cx="28" cy="15.5" r="4" fill="#fff"/><path d="M10.5 24.5Q23.5 43.5 36.5 25.5" fill="none" stroke="#fff" stroke-width="5.8" stroke-linecap="round"/></svg>

+ 3 - 1
src/__tests__/features/integrations/ai.test.ts

@@ -137,7 +137,9 @@ describe('AI connection', () => {
   })
 
   it('falls back to the old rows when the vault cannot open a connection', async () => {
-    const quiet = vi.spyOn(console, 'error').mockImplementation(() => {})
+    const quiet = vi.spyOn(console, 'error').mockImplementation(() => {
+      // The fallback logs on purpose; the test only checks that it does.
+    })
     integrationConnection.findMany.mockResolvedValue([
       { id: 'conn-1', connectorId: 'openai', credentials: 'v1.bad.bad.bad', settings: {} },
     ])

+ 327 - 0
src/__tests__/features/integrations/payments.test.ts

@@ -0,0 +1,327 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import { PAYMENT_VENDORS, legacyPaymentKeys } from '@/integrations/payments/catalog'
+
+const appSetting = { findMany: vi.fn(), findUnique: vi.fn(), upsert: vi.fn() }
+const integrationConnection = {
+  findMany: vi.fn(),
+  findUnique: vi.fn(),
+  create: vi.fn(),
+  update: vi.fn(),
+  updateMany: vi.fn(),
+}
+
+vi.mock('@/lib/db', () => ({ db: { appSetting, integrationConnection } }))
+
+const {
+  PAYMENTS_ADOPTED_KEY,
+  offeredPaymentProviders,
+  paymentProviderFor,
+  paymentSetups,
+  paymentWebhook,
+} = await import('@/features/integrations/Lib/payments')
+const { openCredentials, sealCredentials } = await import('@/features/integrations/Lib/vault')
+
+const ORG = 'org-1'
+
+/** The rows a workshop with Stripe and Vipps switched on had before the move. */
+function legacyRows(overrides: Record<string, string> = {}) {
+  const values: Record<string, string> = {
+    [SETTING_KEYS.PAYMENT_PROVIDERS_ENABLED]: 'stripe,vipps',
+    [SETTING_KEYS.PAYMENT_STRIPE_SECRET_KEY]: 'sk_live_old',
+    [SETTING_KEYS.PAYMENT_STRIPE_PUBLISHABLE_KEY]: 'pk_live_old',
+    [SETTING_KEYS.PAYMENT_STRIPE_WEBHOOK_SECRET]: 'whsec_old',
+    [SETTING_KEYS.PAYMENT_VIPPS_CLIENT_ID]: 'vipps-id',
+    [SETTING_KEYS.PAYMENT_VIPPS_CLIENT_SECRET]: 'vipps-secret',
+    [SETTING_KEYS.PAYMENT_VIPPS_SUBSCRIPTION_KEY]: 'vipps-sub',
+    [SETTING_KEYS.PAYMENT_VIPPS_MSN]: '123456',
+    [SETTING_KEYS.PAYMENT_VIPPS_USE_TEST]: 'true',
+    // PayPal keys present but the switch off: exactly what the old page
+    // meant by "keep the keys, do not offer it".
+    [SETTING_KEYS.PAYMENT_PAYPAL_CLIENT_ID]: 'pp-id',
+    [SETTING_KEYS.PAYMENT_PAYPAL_CLIENT_SECRET]: 'pp-secret',
+    ...overrides,
+  }
+  return Object.entries(values)
+    .filter(([, value]) => value !== '')
+    .map(([key, value]) => ({ key, value, userId: 'user-1' }))
+}
+
+beforeEach(() => {
+  vi.clearAllMocks()
+  process.env.INTEGRATIONS_ENCRYPTION_KEY = 'a'.repeat(64)
+  integrationConnection.findMany.mockResolvedValue([])
+  integrationConnection.findUnique.mockResolvedValue(null)
+  appSetting.findMany.mockResolvedValue([])
+  integrationConnection.create.mockImplementation(async ({ data }) => ({
+    id: `conn-${data.connectorId}`,
+    status: data.status,
+  }))
+})
+
+describe('payment catalog', () => {
+  it('declares a vendor for every id the old enabled list could hold, in the order the buttons had', () => {
+    expect(PAYMENT_VENDORS.map((v) => v.id)).toEqual(['stripe', 'vipps', 'paypal'])
+  })
+
+  it('names every old row it needs to read, once', () => {
+    const keys = legacyPaymentKeys()
+    expect(new Set(keys).size).toBe(keys.length)
+    expect(keys).toContain(SETTING_KEYS.PAYMENT_PROVIDERS_ENABLED)
+    expect(keys).toContain(SETTING_KEYS.PAYMENT_STRIPE_WEBHOOK_SECRET)
+    expect(keys).toContain(SETTING_KEYS.PAYMENT_VIPPS_USE_TEST)
+    expect(keys).toContain(SETTING_KEYS.PAYMENT_PAYPAL_USE_SANDBOX)
+  })
+
+  it('points each vendor at the webhook route that already exists', () => {
+    expect(paymentWebhook('stripe', 'https://shop.example/')).toEqual({
+      url: 'https://shop.example/api/webhooks/stripe',
+      note: 'inboundHintStripe',
+    })
+    expect(paymentWebhook('vipps', 'https://shop.example')?.url).toBe(
+      'https://shop.example/api/webhooks/vipps'
+    )
+    expect(paymentWebhook('paypal', 'https://shop.example')?.url).toBe(
+      'https://shop.example/api/webhooks/paypal'
+    )
+    expect(paymentWebhook('zoom', 'https://shop.example')).toBeNull()
+    expect(paymentWebhook('stripe', '')).toBeNull()
+  })
+})
+
+/**
+ * The move is only safe if a workshop that switched Vipps on last year keeps
+ * taking Vipps payments without touching anything, so that is what these
+ * check.
+ */
+describe('payment connections', () => {
+  it('runs on active connections, with keys from the vault and defaults under the settings', async () => {
+    integrationConnection.findMany.mockResolvedValue([
+      {
+        id: 'conn-2',
+        connectorId: 'paypal',
+        credentials: sealCredentials({ clientId: 'pp', clientSecret: 'pps' }),
+        settings: { sandbox: true },
+      },
+      {
+        id: 'conn-1',
+        connectorId: 'stripe',
+        credentials: sealCredentials({ secretKey: 'sk_live_new' }),
+        settings: {},
+      },
+    ])
+
+    const setups = await paymentSetups(ORG)
+
+    // Vendor order, not row order, so the buttons keep their places.
+    expect(setups.map((s) => s.connectorId)).toEqual(['stripe', 'paypal'])
+    expect(setups[0]).toMatchObject({
+      connectionId: 'conn-1',
+      credentials: { secretKey: 'sk_live_new' },
+      settings: { enabled: true },
+    })
+    expect(setups[1].settings).toEqual({ enabled: true, sandbox: true })
+    expect(integrationConnection.create).not.toHaveBeenCalled()
+  })
+
+  it('adopts every vendor the old list had switched on, sealing the keys', async () => {
+    appSetting.findMany.mockResolvedValue(legacyRows())
+
+    const setups = await paymentSetups(ORG)
+
+    expect(setups.map((s) => s.connectorId)).toEqual(['stripe', 'vipps'])
+    expect(integrationConnection.create).toHaveBeenCalledTimes(2)
+
+    const [stripe, vipps] = integrationConnection.create.mock.calls.map((c) => c[0].data)
+    expect(stripe).toMatchObject({ organizationId: ORG, connectorId: 'stripe', status: 'active' })
+    expect(stripe.credentials).not.toContain('sk_live_old')
+    expect(openCredentials(stripe.credentials)).toEqual({
+      secretKey: 'sk_live_old',
+      publishableKey: 'pk_live_old',
+      webhookSecret: 'whsec_old',
+    })
+
+    expect(vipps).toMatchObject({
+      connectorId: 'vipps',
+      settings: { testMode: true },
+      externalAccountName: 'MSN 123456',
+    })
+    expect(openCredentials(vipps.credentials)).toEqual({
+      clientId: 'vipps-id',
+      clientSecret: 'vipps-secret',
+      subscriptionKey: 'vipps-sub',
+      merchantSerialNumber: '123456',
+    })
+
+    // Test mode carried over, so an adopted Vipps still talks to the test API.
+    expect(setups[1].settings).toEqual({ enabled: true, testMode: true })
+
+    // And recorded, so the next checkout does not adopt all over again.
+    expect(appSetting.upsert).toHaveBeenCalledWith(
+      expect.objectContaining({
+        where: { organizationId_key: { organizationId: ORG, key: PAYMENTS_ADOPTED_KEY } },
+      })
+    )
+  })
+
+  it('leaves a vendor with keys but the switch off alone', async () => {
+    appSetting.findMany.mockResolvedValue(legacyRows())
+
+    await paymentSetups(ORG)
+
+    const adopted = integrationConnection.create.mock.calls.map((c) => c[0].data.connectorId)
+    expect(adopted).not.toContain('paypal')
+  })
+
+  it('leaves a vendor switched on but never finished alone', async () => {
+    appSetting.findMany.mockResolvedValue(
+      legacyRows({ [SETTING_KEYS.PAYMENT_VIPPS_SUBSCRIPTION_KEY]: '' })
+    )
+
+    const setups = await paymentSetups(ORG)
+
+    expect(setups.map((s) => s.connectorId)).toEqual(['stripe'])
+    expect(integrationConnection.create).toHaveBeenCalledTimes(1)
+  })
+
+  it('adopts nothing when the old list was empty', async () => {
+    appSetting.findMany.mockResolvedValue(
+      legacyRows({ [SETTING_KEYS.PAYMENT_PROVIDERS_ENABLED]: '' })
+    )
+
+    await expect(paymentSetups(ORG)).resolves.toEqual([])
+    expect(integrationConnection.create).not.toHaveBeenCalled()
+    expect(appSetting.upsert).not.toHaveBeenCalled()
+  })
+
+  it('adopts once: a disconnected vendor is not resurrected by the old rows', async () => {
+    appSetting.findMany.mockResolvedValue([
+      ...legacyRows(),
+      { key: PAYMENTS_ADOPTED_KEY, value: '2026-09-04T00:00:00.000Z', userId: 'user-1' },
+    ])
+
+    await expect(paymentSetups(ORG)).resolves.toEqual([])
+    expect(integrationConnection.create).not.toHaveBeenCalled()
+  })
+
+  it('never writes over a connection the workshop made themselves', async () => {
+    appSetting.findMany.mockResolvedValue(legacyRows())
+    integrationConnection.findUnique.mockImplementation(async ({ where }) =>
+      where.organizationId_connectorId.connectorId === 'stripe' ? { id: 'conn-theirs' } : null
+    )
+
+    const setups = await paymentSetups(ORG)
+
+    expect(setups.map((s) => s.connectorId)).toEqual(['vipps'])
+    expect(integrationConnection.create).toHaveBeenCalledTimes(1)
+  })
+
+  it('uses the winner of a race on the first checkout after a deploy', async () => {
+    appSetting.findMany.mockResolvedValue(
+      legacyRows({ [SETTING_KEYS.PAYMENT_PROVIDERS_ENABLED]: 'stripe' })
+    )
+    integrationConnection.create.mockRejectedValue({ code: 'P2002' })
+    integrationConnection.findUnique
+      .mockResolvedValueOnce(null)
+      .mockResolvedValueOnce({ id: 'conn-winner', status: 'active' })
+
+    const setups = await paymentSetups(ORG)
+
+    expect(setups).toHaveLength(1)
+    expect(setups[0].connectionId).toBe('conn-winner')
+  })
+
+  it('falls back to the old rows when the vault cannot open a connection', async () => {
+    const quiet = vi.spyOn(console, 'error').mockImplementation(() => {
+      // The fallback logs on purpose; the test only checks that it does.
+    })
+    integrationConnection.findMany.mockResolvedValue([
+      { id: 'conn-1', connectorId: 'stripe', credentials: 'v1.bad.bad.bad', settings: {} },
+    ])
+    appSetting.findMany.mockResolvedValue(legacyRows())
+
+    const setups = await paymentSetups(ORG)
+
+    expect(setups).toHaveLength(1)
+    expect(setups[0]).toMatchObject({
+      connectionId: 'conn-1',
+      credentials: { secretKey: 'sk_live_old' },
+    })
+    expect(quiet).toHaveBeenCalled()
+    quiet.mockRestore()
+  })
+
+  it('builds the client for one vendor, and none for a vendor that is not connected', async () => {
+    integrationConnection.findMany.mockResolvedValue([
+      {
+        id: 'conn-1',
+        connectorId: 'stripe',
+        credentials: sealCredentials({ secretKey: 'sk_live_new' }),
+        settings: {},
+      },
+    ])
+
+    const stripe = await paymentProviderFor(ORG, 'stripe')
+    expect(stripe?.setup.connectionId).toBe('conn-1')
+    expect(stripe?.provider.createCheckout).toBeTypeOf('function')
+    await expect(paymentProviderFor(ORG, 'vipps')).resolves.toBeNull()
+  })
+})
+
+/**
+ * The shared invoice page asks which buttons to show on every view, and a
+ * page render is no place to create a connection. Before the move is
+ * adopted it reads the old rows as they stand, so the buttons are there on
+ * the first view after the deploy exactly as they were on the last one
+ * before it.
+ */
+describe('offered vendors', () => {
+  it('reads the old rows without connecting anything', async () => {
+    appSetting.findMany.mockResolvedValue(legacyRows())
+
+    await expect(offeredPaymentProviders(ORG)).resolves.toEqual(['stripe', 'vipps'])
+    expect(integrationConnection.create).not.toHaveBeenCalled()
+    expect(appSetting.upsert).not.toHaveBeenCalled()
+  })
+
+  it('offers only what is connected and switched on, in vendor order', async () => {
+    integrationConnection.findMany.mockResolvedValue([
+      { connectorId: 'paypal', settings: {} },
+      { connectorId: 'vipps', settings: { enabled: false } },
+      { connectorId: 'stripe', settings: { enabled: true } },
+    ])
+
+    await expect(offeredPaymentProviders(ORG)).resolves.toEqual(['stripe', 'paypal'])
+    expect(appSetting.findMany).not.toHaveBeenCalled()
+  })
+
+  it('offers nothing once the move is adopted and every vendor is gone', async () => {
+    appSetting.findMany.mockResolvedValue([
+      ...legacyRows(),
+      { key: PAYMENTS_ADOPTED_KEY, value: '2026-09-04T00:00:00.000Z', userId: 'user-1' },
+    ])
+
+    await expect(offeredPaymentProviders(ORG)).resolves.toEqual([])
+  })
+})
+
+/**
+ * Online payments were behind the `payments` plan feature on their settings
+ * page, and the catalog is behind `integrations`. Moving the cards must not
+ * change who can reach them, so the connectors carry their own gate.
+ */
+describe('plan gates', () => {
+  it('gates the payment connectors by the payments feature', async () => {
+    const { connectorAllowed } = await import('@/features/integrations/Lib/plan')
+    const { PLAN_FEATURES } = await import('@/lib/features')
+    const { getManifest } = await import('@/integrations/registry')
+
+    for (const id of ['stripe', 'vipps', 'paypal']) {
+      const manifest = getManifest(id)
+      expect(manifest?.plan).toBe('payments')
+      expect(manifest?.category).toBe('payments')
+      expect(connectorAllowed(manifest!, PLAN_FEATURES.free)).toBe(false)
+      expect(connectorAllowed(manifest!, PLAN_FEATURES.pro)).toBe(true)
+    }
+  })
+})

+ 16 - 1
src/app/(authenticated)/settings/integrations/[connector]/connection-settings.tsx

@@ -634,12 +634,22 @@ function ConnectForm({
           {fields.map((f) => (
             <div key={f.key} className="space-y-1">
               <Label className="text-xs">{tc(`fields.${f.label}`)}</Label>
+              {/* Vendor keys are not a login. Browsers ignore autocomplete="off"
+                  on a password field and fill the site's saved sign-in into it,
+                  with the username into the field before it, so the secrets
+                  ask for a "new password" instead, which nothing has saved,
+                  and the password managers are told to stay out. */}
               <Input
                 type={f.type === 'password' ? 'password' : 'text'}
+                name={`${manifest.id}-${f.key}`}
                 value={values[f.key] ?? ''}
                 placeholder={f.key === 'clientSecret' && tenantReady ? '••••••••' : f.placeholder}
                 onChange={(e) => setValues((v) => ({ ...v, [f.key]: e.target.value }))}
-                autoComplete="off"
+                autoComplete={f.type === 'password' ? 'new-password' : 'off'}
+                data-1p-ignore
+                data-lpignore="true"
+                data-bwignore
+                data-form-type="other"
               />
               {f.help && <p className="text-xs text-muted-foreground">{tc(`fields.${f.help}`)}</p>}
             </div>
@@ -753,7 +763,12 @@ function SettingFieldList({
             {(f.type === 'text' || f.type === 'number') && (
               <Input
                 type={f.type}
+                name={`${connectorId}-${f.key}`}
                 className="h-8"
+                autoComplete="off"
+                data-1p-ignore
+                data-lpignore="true"
+                data-form-type="other"
                 value={String(values[f.key] ?? '')}
                 onChange={(e) =>
                   setValues((s) => ({

+ 9 - 2
src/app/(authenticated)/settings/payment/page.tsx

@@ -1,4 +1,5 @@
 import { getSettings } from '@/features/settings/Actions/settingsActions'
+import { getPaymentConnections } from '@/features/integrations/Actions/integrationActions'
 import { getLayoutData } from '@/lib/get-layout-data'
 import { getFeatures, isCloudMode } from '@/lib/features'
 import { PaymentSettings } from './payment-settings'
@@ -23,8 +24,14 @@ export default async function PaymentSettingsPage() {
     )
   }
 
-  const result = await getSettings()
+  const [result, connections] = await Promise.all([getSettings(), getPaymentConnections()])
   const settings = result.success && result.data ? result.data : {}
 
-  return <PaymentSettings settings={settings} orgId={data.organizationId} />
+  return (
+    <PaymentSettings
+      settings={settings}
+      orgId={data.organizationId}
+      providers={connections.success && connections.data ? connections.data : []}
+    />
+  )
 }

+ 96 - 337
src/app/(authenticated)/settings/payment/payment-settings.tsx

@@ -1,33 +1,52 @@
 'use client'
 
 import { useState, useEffect } from 'react'
+import Image from 'next/image'
+import Link from 'next/link'
 import { useRouter } from 'next/navigation'
 import { useTranslations } from 'next-intl'
 import { AppCard } from '@/components/app-card'
+import { Badge } from '@/components/ui/badge'
 import { Input } from '@/components/ui/input'
 import { Label } from '@/components/ui/label'
 import { Textarea } from '@/components/ui/textarea'
 import { Button } from '@/components/ui/button'
 import { Separator } from '@/components/ui/separator'
-import { Switch } from '@/components/ui/switch'
 import { toast } from 'sonner'
 import { setSettings } from '@/features/settings/Actions/settingsActions'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
-import { Banknote, CreditCard, Loader2, Save, Copy, Check } from 'lucide-react'
+import type { PaymentConnectionSummary } from '@/features/integrations/Actions/integrationActions'
+import {
+  ArrowRight,
+  Banknote,
+  CreditCard,
+  FileText,
+  Loader2,
+  Save,
+  Copy,
+  Check,
+} from 'lucide-react'
 import { ReadOnlyBanner, SaveButton, ReadOnlyWrapper } from '../read-only-guard'
 
+/**
+ * What prints on the invoice and what a customer agrees to. The vendors a
+ * customer can pay through live in the integrations catalog now, so this
+ * page only shows where each one stands and links across.
+ */
 export function PaymentSettings({
   settings,
   orgId,
+  providers,
 }: {
   settings: Record<string, string>
   orgId: string
+  providers: PaymentConnectionSummary[]
 }) {
   const router = useRouter()
   const t = useTranslations('settings')
+  const ti = useTranslations('integrations')
   const [saving, setSaving] = useState(false)
 
-  // Existing fields
   const [bankAccount, setBankAccount] = useState(settings[SETTING_KEYS.INVOICE_BANK_ACCOUNT] || '')
   const [paymentTerms, setPaymentTerms] = useState(
     settings[SETTING_KEYS.INVOICE_PAYMENT_TERMS] || ''
@@ -37,54 +56,6 @@ export function PaymentSettings({
     settings[SETTING_KEYS.PAYMENT_TERMS_OF_SALE_URL] || ''
   )
 
-  // Online payment providers
-  const enabledRaw = settings[SETTING_KEYS.PAYMENT_PROVIDERS_ENABLED] || ''
-  const enabledList = enabledRaw
-    .split(',')
-    .map((s) => s.trim())
-    .filter(Boolean)
-
-  const [stripeEnabled, setStripeEnabled] = useState(enabledList.includes('stripe'))
-  const [vippsEnabled, setVippsEnabled] = useState(enabledList.includes('vipps'))
-  const [paypalEnabled, setPaypalEnabled] = useState(enabledList.includes('paypal'))
-
-  // Stripe fields
-  const [stripeSecretKey, setStripeSecretKey] = useState(
-    settings[SETTING_KEYS.PAYMENT_STRIPE_SECRET_KEY] || ''
-  )
-  const [stripePublishableKey, setStripePublishableKey] = useState(
-    settings[SETTING_KEYS.PAYMENT_STRIPE_PUBLISHABLE_KEY] || ''
-  )
-  const [stripeWebhookSecret, setStripeWebhookSecret] = useState(
-    settings[SETTING_KEYS.PAYMENT_STRIPE_WEBHOOK_SECRET] || ''
-  )
-
-  // Vipps fields
-  const [vippsClientId, setVippsClientId] = useState(
-    settings[SETTING_KEYS.PAYMENT_VIPPS_CLIENT_ID] || ''
-  )
-  const [vippsClientSecret, setVippsClientSecret] = useState(
-    settings[SETTING_KEYS.PAYMENT_VIPPS_CLIENT_SECRET] || ''
-  )
-  const [vippsSubscriptionKey, setVippsSubscriptionKey] = useState(
-    settings[SETTING_KEYS.PAYMENT_VIPPS_SUBSCRIPTION_KEY] || ''
-  )
-  const [vippsMsn, setVippsMsn] = useState(settings[SETTING_KEYS.PAYMENT_VIPPS_MSN] || '')
-  const [vippsTestMode, setVippsTestMode] = useState(
-    settings[SETTING_KEYS.PAYMENT_VIPPS_USE_TEST] === 'true'
-  )
-
-  // PayPal fields
-  const [paypalClientId, setPaypalClientId] = useState(
-    settings[SETTING_KEYS.PAYMENT_PAYPAL_CLIENT_ID] || ''
-  )
-  const [paypalClientSecret, setPaypalClientSecret] = useState(
-    settings[SETTING_KEYS.PAYMENT_PAYPAL_CLIENT_SECRET] || ''
-  )
-  const [paypalSandbox, setPaypalSandbox] = useState(
-    settings[SETTING_KEYS.PAYMENT_PAYPAL_USE_SANDBOX] === 'true'
-  )
-
   const [copiedUrl, setCopiedUrl] = useState<string | null>(null)
   const [appUrl, setAppUrl] = useState('')
 
@@ -94,42 +65,25 @@ export function PaymentSettings({
 
   const handleSave = async () => {
     setSaving(true)
-
-    const providers: string[] = []
-    if (stripeEnabled) providers.push('stripe')
-    if (vippsEnabled) providers.push('vipps')
-    if (paypalEnabled) providers.push('paypal')
-
     await setSettings({
       [SETTING_KEYS.INVOICE_BANK_ACCOUNT]: bankAccount,
       [SETTING_KEYS.INVOICE_PAYMENT_TERMS]: paymentTerms,
       [SETTING_KEYS.PAYMENT_TERMS_OF_SALE]: termsOfSale,
       [SETTING_KEYS.PAYMENT_TERMS_OF_SALE_URL]: termsOfSaleUrl,
-      [SETTING_KEYS.PAYMENT_PROVIDERS_ENABLED]: providers.join(','),
-      [SETTING_KEYS.PAYMENT_STRIPE_SECRET_KEY]: stripeSecretKey,
-      [SETTING_KEYS.PAYMENT_STRIPE_PUBLISHABLE_KEY]: stripePublishableKey,
-      [SETTING_KEYS.PAYMENT_STRIPE_WEBHOOK_SECRET]: stripeWebhookSecret,
-      [SETTING_KEYS.PAYMENT_VIPPS_CLIENT_ID]: vippsClientId,
-      [SETTING_KEYS.PAYMENT_VIPPS_CLIENT_SECRET]: vippsClientSecret,
-      [SETTING_KEYS.PAYMENT_VIPPS_SUBSCRIPTION_KEY]: vippsSubscriptionKey,
-      [SETTING_KEYS.PAYMENT_VIPPS_MSN]: vippsMsn,
-      [SETTING_KEYS.PAYMENT_VIPPS_USE_TEST]: vippsTestMode ? 'true' : 'false',
-      [SETTING_KEYS.PAYMENT_PAYPAL_CLIENT_ID]: paypalClientId,
-      [SETTING_KEYS.PAYMENT_PAYPAL_CLIENT_SECRET]: paypalClientSecret,
-      [SETTING_KEYS.PAYMENT_PAYPAL_USE_SANDBOX]: paypalSandbox ? 'true' : 'false',
     })
-
     setSaving(false)
     router.refresh()
     toast.success(t('payment.saved'))
   }
 
-  const copyWebhookUrl = (url: string) => {
+  const copyUrl = (url: string) => {
     navigator.clipboard.writeText(url)
     setCopiedUrl(url)
     setTimeout(() => setCopiedUrl(null), 2000)
   }
 
+  const publicTermsUrl = `${appUrl}/share/terms/${orgId}`
+
   return (
     <div className="space-y-6">
       <ReadOnlyBanner />
@@ -175,281 +129,86 @@ export function PaymentSettings({
           </div>
         </AppCard>
 
-        <AppCard icon={CreditCard} title={t('payment.onlinePayments')} contentClassName="space-y-6">
+        <AppCard icon={CreditCard} title={t('payment.onlinePayments')} contentClassName="space-y-4">
           <p className="text-sm text-muted-foreground">{t('payment.onlinePaymentsDescription')}</p>
 
-          {/* Stripe */}
-          <div className="space-y-4 rounded-lg border p-4">
-            <div className="flex items-center justify-between">
-              <div>
-                <Label className="text-base font-semibold">{t('payment.stripe')}</Label>
-                <p className="text-xs text-muted-foreground">{t('payment.stripeDescription')}</p>
-              </div>
-              <Switch checked={stripeEnabled} onCheckedChange={setStripeEnabled} />
-            </div>
-
-            {stripeEnabled && (
-              <div className="space-y-4 pt-2">
-                <div className="space-y-2">
-                  <Label htmlFor="stripeSecretKey">{t('payment.secretKey')}</Label>
-                  <Input
-                    id="stripeSecretKey"
-                    type="password"
-                    placeholder="sk_live_..."
-                    value={stripeSecretKey}
-                    onChange={(e) => setStripeSecretKey(e.target.value)}
-                  />
-                </div>
-                <div className="space-y-2">
-                  <Label htmlFor="stripePublishableKey">{t('payment.publishableKey')}</Label>
-                  <Input
-                    id="stripePublishableKey"
-                    type="password"
-                    placeholder="pk_live_..."
-                    value={stripePublishableKey}
-                    onChange={(e) => setStripePublishableKey(e.target.value)}
-                  />
-                </div>
-                <div className="space-y-2">
-                  <Label htmlFor="stripeWebhookSecret">{t('payment.webhookSecret')}</Label>
-                  <Input
-                    id="stripeWebhookSecret"
-                    type="password"
-                    placeholder="whsec_..."
-                    value={stripeWebhookSecret}
-                    onChange={(e) => setStripeWebhookSecret(e.target.value)}
-                  />
-                </div>
-                <div className="space-y-1">
-                  <Label className="text-xs text-muted-foreground">{t('payment.webhookUrl')}</Label>
-                  <div className="flex items-center gap-2">
-                    <code className="flex-1 rounded bg-muted px-3 py-2 text-xs break-all">
-                      {appUrl}/api/webhooks/stripe
-                    </code>
-                    <Button
-                      variant="outline"
-                      size="sm"
-                      onClick={() => copyWebhookUrl(`${appUrl}/api/webhooks/stripe`)}
-                    >
-                      {copiedUrl === `${appUrl}/api/webhooks/stripe` ? (
-                        <Check className="h-3.5 w-3.5" />
-                      ) : (
-                        <Copy className="h-3.5 w-3.5" />
-                      )}
-                    </Button>
-                  </div>
-                  <p className="text-xs text-muted-foreground">
-                    {t.rich('payment.stripeWebhookHint', {
-                      code: (chunks) => <code className="text-xs">{chunks}</code>,
-                    })}
-                  </p>
-                </div>
-              </div>
-            )}
-          </div>
-
-          {/* Vipps */}
-          <div className="space-y-4 rounded-lg border p-4">
-            <div className="flex items-center justify-between">
-              <div>
-                <Label className="text-base font-semibold">{t('payment.vipps')}</Label>
-                <p className="text-xs text-muted-foreground">{t('payment.vippsDescription')}</p>
-              </div>
-              <Switch checked={vippsEnabled} onCheckedChange={setVippsEnabled} />
-            </div>
-
-            {vippsEnabled && (
-              <div className="space-y-4 pt-2">
-                <div className="space-y-2">
-                  <Label htmlFor="vippsClientId">{t('payment.clientId')}</Label>
-                  <Input
-                    id="vippsClientId"
-                    type="password"
-                    placeholder="Client ID"
-                    value={vippsClientId}
-                    onChange={(e) => setVippsClientId(e.target.value)}
-                  />
-                </div>
-                <div className="space-y-2">
-                  <Label htmlFor="vippsClientSecret">{t('payment.clientSecret')}</Label>
-                  <Input
-                    id="vippsClientSecret"
-                    type="password"
-                    placeholder="Client Secret"
-                    value={vippsClientSecret}
-                    onChange={(e) => setVippsClientSecret(e.target.value)}
-                  />
-                </div>
-                <div className="space-y-2">
-                  <Label htmlFor="vippsSubscriptionKey">{t('payment.subscriptionKey')}</Label>
-                  <Input
-                    id="vippsSubscriptionKey"
-                    type="password"
-                    placeholder="Ocp-Apim-Subscription-Key"
-                    value={vippsSubscriptionKey}
-                    onChange={(e) => setVippsSubscriptionKey(e.target.value)}
-                  />
-                </div>
-                <div className="space-y-2">
-                  <Label htmlFor="vippsMsn">{t('payment.merchantSerialNumber')}</Label>
-                  <Input
-                    id="vippsMsn"
-                    placeholder="123456"
-                    value={vippsMsn}
-                    onChange={(e) => setVippsMsn(e.target.value)}
-                  />
-                </div>
-                <div className="flex items-center gap-3">
-                  <Switch checked={vippsTestMode} onCheckedChange={setVippsTestMode} />
-                  <Label>{t('payment.testMode')}</Label>
-                </div>
-                <div className="space-y-1">
-                  <Label className="text-xs text-muted-foreground">
-                    {t('payment.callbackUrl')}
-                  </Label>
-                  <div className="flex items-center gap-2">
-                    <code className="flex-1 rounded bg-muted px-3 py-2 text-xs break-all">
-                      {appUrl}/api/webhooks/vipps
-                    </code>
-                    <Button
-                      variant="outline"
-                      size="sm"
-                      onClick={() => copyWebhookUrl(`${appUrl}/api/webhooks/vipps`)}
-                    >
-                      {copiedUrl === `${appUrl}/api/webhooks/vipps` ? (
-                        <Check className="h-3.5 w-3.5" />
-                      ) : (
-                        <Copy className="h-3.5 w-3.5" />
-                      )}
-                    </Button>
-                  </div>
-                </div>
-
-                <Separator />
+          <ul className="divide-y rounded-lg border">
+            {providers.map((p) => (
+              <li key={p.id} className="flex items-center gap-3 px-3 py-2.5">
+                <Image
+                  src={p.logo}
+                  alt=""
+                  width={28}
+                  height={28}
+                  className="h-7 w-7 shrink-0 rounded-md"
+                />
+                <span className="min-w-0 flex-1 text-sm font-medium">{p.name}</span>
+                {p.status === 'active' ? (
+                  <Badge variant={p.offered ? 'default' : 'secondary'}>
+                    {p.offered ? t('payment.offered') : t('payment.paused')}
+                  </Badge>
+                ) : p.status ? (
+                  <Badge variant="outline">{ti(`statuses.${p.status}`)}</Badge>
+                ) : null}
+                <Button asChild variant="ghost" size="sm">
+                  <Link href={`/settings/integrations/${p.id}`}>
+                    {p.status ? ti('catalog.manage') : ti('catalog.connect')}
+                    <ArrowRight className="ml-1 h-3.5 w-3.5" />
+                  </Link>
+                </Button>
+              </li>
+            ))}
+          </ul>
+
+          <p className="text-xs text-muted-foreground">{t('payment.onlinePaymentsHint')}</p>
+        </AppCard>
 
-                <div className="space-y-2">
-                  <Label htmlFor="termsOfSale">{t('payment.termsOfSale')}</Label>
-                  <p className="text-xs text-muted-foreground">
-                    {t('payment.termsOfSaleDescription')}
-                  </p>
-                  <Textarea
-                    id="termsOfSale"
-                    rows={10}
-                    placeholder={
-                      '1. Parties\n2. Payment\n3. Delivery\n4. Right of withdrawal\n5. Returns\n6. Complaints\n7. Disputes'
-                    }
-                    value={termsOfSale}
-                    onChange={(e) => setTermsOfSale(e.target.value)}
-                  />
-                </div>
+        <AppCard icon={FileText} title={t('payment.termsOfSale')} contentClassName="space-y-4">
+          <p className="text-sm text-muted-foreground">{t('payment.termsOfSaleDescription')}</p>
 
-                <div className="space-y-2">
-                  <Label htmlFor="termsOfSaleUrl">{t('payment.externalTermsUrl')}</Label>
-                  <Input
-                    id="termsOfSaleUrl"
-                    type="url"
-                    placeholder="https://example.com/salgsvilkar"
-                    value={termsOfSaleUrl}
-                    onChange={(e) => setTermsOfSaleUrl(e.target.value)}
-                  />
-                  <p className="text-xs text-muted-foreground">
-                    {t('payment.externalTermsUrlHint')}
-                  </p>
-                </div>
+          <div className="space-y-2">
+            <Label htmlFor="termsOfSale">{t('payment.termsOfSaleText')}</Label>
+            <Textarea
+              id="termsOfSale"
+              rows={10}
+              placeholder={
+                '1. Parties\n2. Payment\n3. Delivery\n4. Right of withdrawal\n5. Returns\n6. Complaints\n7. Disputes'
+              }
+              value={termsOfSale}
+              onChange={(e) => setTermsOfSale(e.target.value)}
+            />
+          </div>
 
-                {!termsOfSaleUrl && termsOfSale && (
-                  <div className="space-y-1">
-                    <Label className="text-xs text-muted-foreground">
-                      {t('payment.publicTermsUrl')}
-                    </Label>
-                    <div className="flex items-center gap-2">
-                      <code className="flex-1 rounded bg-muted px-3 py-2 text-xs break-all">
-                        {appUrl}/share/terms/{orgId}
-                      </code>
-                      <Button
-                        variant="outline"
-                        size="sm"
-                        onClick={() => copyWebhookUrl(`${appUrl}/share/terms/${orgId}`)}
-                      >
-                        {copiedUrl === `${appUrl}/share/terms/${orgId}` ? (
-                          <Check className="h-3.5 w-3.5" />
-                        ) : (
-                          <Copy className="h-3.5 w-3.5" />
-                        )}
-                      </Button>
-                    </div>
-                    <p className="text-xs text-muted-foreground">
-                      {t('payment.publicTermsUrlHint')}
-                    </p>
-                  </div>
-                )}
-              </div>
-            )}
+          <div className="space-y-2">
+            <Label htmlFor="termsOfSaleUrl">{t('payment.externalTermsUrl')}</Label>
+            <Input
+              id="termsOfSaleUrl"
+              type="url"
+              placeholder="https://example.com/terms"
+              value={termsOfSaleUrl}
+              onChange={(e) => setTermsOfSaleUrl(e.target.value)}
+            />
+            <p className="text-xs text-muted-foreground">{t('payment.externalTermsUrlHint')}</p>
           </div>
 
-          {/* PayPal */}
-          <div className="space-y-4 rounded-lg border p-4">
-            <div className="flex items-center justify-between">
-              <div>
-                <Label className="text-base font-semibold">{t('payment.paypal')}</Label>
-                <p className="text-xs text-muted-foreground">{t('payment.paypalDescription')}</p>
+          {!termsOfSaleUrl && termsOfSale && (
+            <div className="space-y-1">
+              <Label className="text-xs text-muted-foreground">{t('payment.publicTermsUrl')}</Label>
+              <div className="flex items-center gap-2">
+                <code className="flex-1 rounded bg-muted px-3 py-2 text-xs break-all">
+                  {publicTermsUrl}
+                </code>
+                <Button variant="outline" size="sm" onClick={() => copyUrl(publicTermsUrl)}>
+                  {copiedUrl === publicTermsUrl ? (
+                    <Check className="h-3.5 w-3.5" />
+                  ) : (
+                    <Copy className="h-3.5 w-3.5" />
+                  )}
+                </Button>
               </div>
-              <Switch checked={paypalEnabled} onCheckedChange={setPaypalEnabled} />
+              <p className="text-xs text-muted-foreground">{t('payment.publicTermsUrlHint')}</p>
             </div>
-
-            {paypalEnabled && (
-              <div className="space-y-4 pt-2">
-                <div className="space-y-2">
-                  <Label htmlFor="paypalClientId">{t('payment.clientId')}</Label>
-                  <Input
-                    id="paypalClientId"
-                    type="password"
-                    placeholder="PayPal Client ID"
-                    value={paypalClientId}
-                    onChange={(e) => setPaypalClientId(e.target.value)}
-                  />
-                </div>
-                <div className="space-y-2">
-                  <Label htmlFor="paypalClientSecret">{t('payment.clientSecret')}</Label>
-                  <Input
-                    id="paypalClientSecret"
-                    type="password"
-                    placeholder="PayPal Client Secret"
-                    value={paypalClientSecret}
-                    onChange={(e) => setPaypalClientSecret(e.target.value)}
-                  />
-                </div>
-                <div className="flex items-center gap-3">
-                  <Switch checked={paypalSandbox} onCheckedChange={setPaypalSandbox} />
-                  <Label>{t('payment.sandboxMode')}</Label>
-                </div>
-                <div className="space-y-1">
-                  <Label className="text-xs text-muted-foreground">{t('payment.webhookUrl')}</Label>
-                  <div className="flex items-center gap-2">
-                    <code className="flex-1 rounded bg-muted px-3 py-2 text-xs break-all">
-                      {appUrl}/api/webhooks/paypal
-                    </code>
-                    <Button
-                      variant="outline"
-                      size="sm"
-                      onClick={() => copyWebhookUrl(`${appUrl}/api/webhooks/paypal`)}
-                    >
-                      {copiedUrl === `${appUrl}/api/webhooks/paypal` ? (
-                        <Check className="h-3.5 w-3.5" />
-                      ) : (
-                        <Copy className="h-3.5 w-3.5" />
-                      )}
-                    </Button>
-                  </div>
-                  <p className="text-xs text-muted-foreground">
-                    {t.rich('payment.paypalWebhookHint', {
-                      code: (chunks) => <code className="text-xs">{chunks}</code>,
-                    })}
-                  </p>
-                </div>
-              </div>
-            )}
-          </div>
+          )}
 
           <SaveButton>
             <Separator />

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

@@ -443,7 +443,8 @@ export function InvoiceView({
     const sessionId = params.get('session_id')
     const reference = params.get('reference')
 
-    const paypalOrderId = params.get('paypal_order_id')
+    // PayPal sends the order id back as "token" beside the marker we set.
+    const paypalOrderId = params.get('paypal') === 'return' ? params.get('token') : null
 
     if (sessionId) {
       verifyPayment('stripe', sessionId)
@@ -500,7 +501,11 @@ export function InvoiceView({
       })
       const data = await res.json()
       if (!res.ok) {
-        setPaymentError(data.error || t('errorCheckoutFailed'))
+        // A vendor failure is the workshop's to read, on the integration's
+        // activity log; the customer gets one line they can act on.
+        setPaymentError(
+          res.status >= 500 ? t('errorCheckoutFailed') : data.error || t('errorCheckoutFailed')
+        )
         return
       }
       window.location.href = data.redirectUrl

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

@@ -11,6 +11,7 @@ import { getTorqvoiceLogoDataUri } from '@/lib/torqvoice-branding'
 import { headers } from 'next/headers'
 import type { Metadata } from 'next'
 import { getOrgTelegramBotUsername } from '@/lib/telegram'
+import { offeredPaymentProviders } from '@/features/integrations/Lib/payments'
 
 /** Rewrites /api/protected/files/[orgId]/[category]/[filename] to /api/public/files/[token]/[category]/[filename] */
 function toPublicFileUrl(fileUrl: string, token: string): string {
@@ -76,12 +77,9 @@ export default async function PublicInvoicePage({
   const showLogo = assembly.template.showLogo !== false
   const showCompanyName = assembly.template.showCompanyName !== false
 
-  // Determine which online payment providers are enabled for this org
-  const enabledProvidersRaw = settingsMap['payment.providersEnabled'] || ''
-  const enabledProviders = enabledProvidersRaw
-    .split(',')
-    .map((s) => s.trim())
-    .filter(Boolean)
+  // The vendors the customer can pay through, whichever side of the move
+  // into the integrations catalog they were connected on.
+  const enabledProviders = await offeredPaymentProviders(orgId)
 
   // What reaches the browser: internal unitCost/markupPercent must never be
   // in the customer-facing payload, attachment URLs go through the public

+ 43 - 27
src/app/api/public/share/invoice/[orgId]/[token]/checkout/route.ts

@@ -1,13 +1,18 @@
 import { NextResponse } from 'next/server'
 import { z } from 'zod'
 import { db } from '@/lib/db'
-import { getPaymentProvider, getEnabledProviders } from '@/lib/payment-providers'
+import {
+  PAYMENT_CONNECTOR_IDS,
+  isOffered,
+  paymentProviderFor,
+} from '@/features/integrations/Lib/payments'
+import { writeLog } from '@/features/integrations/Lib/connections'
 import { rateLimit } from '@/lib/rate-limit'
 import { resolvePortalOrg } from '@/lib/portal-slug'
 import { calculateTotals } from '@/lib/tax'
 
 const checkoutSchema = z.object({
-  provider: z.enum(['stripe', 'vipps', 'paypal']),
+  provider: z.enum(PAYMENT_CONNECTOR_IDS as [string, ...string[]]),
   amount: z.number().positive(),
 })
 
@@ -86,43 +91,54 @@ export async function POST(
       )
     }
 
-    // Load org payment settings
-    const settings = await db.appSetting.findMany({
-      where: { organizationId: orgId },
-    })
-    const settingsMap: Record<string, string> = {}
-    for (const s of settings) settingsMap[s.key] = s.value
-
-    const enabledProviders = getEnabledProviders(settingsMap)
-    if (!enabledProviders.includes(provider)) {
+    // The vendor's connection, adopted from the old settings rows on the
+    // first checkout after the move. A vendor the workshop has paused with
+    // the offered switch takes no new payments, whatever the page showed.
+    const connected = await paymentProviderFor(orgId, provider)
+    if (!connected || !isOffered(connected.setup)) {
       return NextResponse.json(
         { error: `Payment provider "${provider}" is not enabled` },
         { status: 400 }
       )
     }
 
-    const currencyCode = settingsMap['workshop.currencyCode'] || 'USD'
+    const currencySetting = await db.appSetting.findUnique({
+      where: { organizationId_key: { organizationId: orgId, key: 'workshop.currencyCode' } },
+      select: { value: true },
+    })
+    const currencyCode = currencySetting?.value || 'USD'
     const invoiceNumber = record.invoiceNumber || `INV-${record.id.slice(-8).toUpperCase()}`
 
     const appUrl = process.env.NEXT_PUBLIC_APP_URL || ''
     const invoiceUrl = `${appUrl}/share/invoice/${orgId}/${token}`
 
-    const paymentProvider = getPaymentProvider(provider, settingsMap)
-    const result = await paymentProvider.createCheckout({
-      amount,
-      currency: currencyCode,
-      invoiceNumber,
-      description: `Payment for ${invoiceNumber} - ${record.title}`,
-      successUrl: invoiceUrl,
-      cancelUrl: invoiceUrl,
-      serviceRecordId: record.id,
-      orgId,
-    })
-
-    return NextResponse.json(result)
+    try {
+      const result = await connected.provider.createCheckout({
+        amount,
+        currency: currencyCode,
+        invoiceNumber,
+        description: `Payment for ${invoiceNumber} - ${record.title}`,
+        successUrl: invoiceUrl,
+        cancelUrl: invoiceUrl,
+        serviceRecordId: record.id,
+        orgId,
+      })
+      return NextResponse.json(result)
+    } catch (error) {
+      // The vendor's answer is for the workshop, on the connection's
+      // activity log, where whoever set the keys up will look. The customer
+      // is told only that it did not work.
+      const message = error instanceof Error ? error.message : String(error)
+      console.error('[Checkout] Error:', error)
+      await writeLog(connected.setup.connectionId, 'error', `Checkout failed: ${message}`, {
+        invoiceNumber,
+        amount,
+        currency: currencyCode,
+      })
+      return NextResponse.json({ error: 'Checkout failed' }, { status: 500 })
+    }
   } catch (error) {
     console.error('[Checkout] Error:', error)
-    const message = error instanceof Error ? error.message : 'Checkout failed'
-    return NextResponse.json({ error: message }, { status: 500 })
+    return NextResponse.json({ error: 'Checkout failed' }, { status: 500 })
   }
 }

+ 9 - 15
src/app/api/public/share/invoice/[orgId]/[token]/verify/route.ts

@@ -1,13 +1,13 @@
 import { NextResponse } from 'next/server'
 import { z } from 'zod'
 import { db } from '@/lib/db'
-import { getPaymentProvider, getEnabledProviders } from '@/lib/payment-providers'
+import { PAYMENT_CONNECTOR_IDS, paymentProviderFor } from '@/features/integrations/Lib/payments'
 import { rateLimit } from '@/lib/rate-limit'
 import { notify } from '@/lib/notify'
 import { resolvePortalOrg } from '@/lib/portal-slug'
 
 const verifySchema = z.object({
-  provider: z.enum(['stripe', 'vipps', 'paypal']),
+  provider: z.enum(PAYMENT_CONNECTOR_IDS as [string, ...string[]]),
   externalId: z.string().min(1),
 })
 
@@ -48,20 +48,15 @@ export async function POST(
 
     const { provider, externalId } = parsed.data
 
-    // Load org payment settings
-    const settings = await db.appSetting.findMany({
-      where: { organizationId: orgId },
-    })
-    const settingsMap: Record<string, string> = {}
-    for (const s of settings) settingsMap[s.key] = s.value
-
-    const enabledProviders = getEnabledProviders(settingsMap)
-    if (!enabledProviders.includes(provider)) {
+    // The customer is back from the vendor, so the money may already have
+    // moved: a vendor the workshop paused in the meantime still gets its
+    // payment recorded, as long as it is connected.
+    const connected = await paymentProviderFor(orgId, provider)
+    if (!connected) {
       return NextResponse.json({ error: `Provider "${provider}" is not enabled` }, { status: 400 })
     }
 
-    const paymentProvider = getPaymentProvider(provider, settingsMap)
-    const result = await paymentProvider.verifyPayment(externalId)
+    const result = await connected.provider.verifyPayment(externalId)
 
     if (!result || !result.paid) {
       return NextResponse.json({ verified: false })
@@ -102,7 +97,6 @@ export async function POST(
     })
   } catch (error) {
     console.error('[Verify] Error:', error)
-    const message = error instanceof Error ? error.message : 'Verification failed'
-    return NextResponse.json({ error: message }, { status: 500 })
+    return NextResponse.json({ error: 'Verification failed' }, { status: 500 })
   }
 }

+ 4 - 12
src/app/api/webhooks/paypal/route.ts

@@ -1,7 +1,6 @@
 import { NextResponse } from 'next/server'
 import { db } from '@/lib/db'
-import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
-import { getPaymentProvider } from '@/lib/payment-providers'
+import { paymentProviderFor } from '@/features/integrations/Lib/payments'
 
 export async function POST(request: Request) {
   try {
@@ -48,20 +47,13 @@ async function processPayPalPayment(orderId: string, orgId: string, serviceRecor
     return NextResponse.json({ received: true })
   }
 
-  // Load org settings
-  const settings = await db.appSetting.findMany({
-    where: { organizationId: orgId },
-  })
-  const settingsMap: Record<string, string> = {}
-  for (const s of settings) settingsMap[s.key] = s.value
-
-  if (!settingsMap[SETTING_KEYS.PAYMENT_PAYPAL_CLIENT_ID]) {
+  const connected = await paymentProviderFor(orgId, 'paypal')
+  if (!connected) {
     return NextResponse.json({ error: 'PayPal not configured for this org' }, { status: 400 })
   }
 
   // Verify the order with PayPal API
-  const provider = getPaymentProvider('paypal', settingsMap)
-  const result = await provider.verifyPayment(orderId)
+  const result = await connected.provider.verifyPayment(orderId)
 
   if (!result || !result.paid) {
     return NextResponse.json({ received: true, status: 'not_paid' })

+ 11 - 24
src/app/api/webhooks/stripe/route.ts

@@ -1,7 +1,7 @@
 import { NextResponse } from 'next/server'
 import Stripe from 'stripe'
 import { db } from '@/lib/db'
-import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import { paymentProviderFor } from '@/features/integrations/Lib/payments'
 
 export async function POST(request: Request) {
   try {
@@ -31,30 +31,17 @@ export async function POST(request: Request) {
       return NextResponse.json({ error: 'Missing metadata' }, { status: 400 })
     }
 
-    const [webhookSecretSetting, secretKeySetting] = await Promise.all([
-      db.appSetting.findUnique({
-        where: {
-          organizationId_key: {
-            organizationId: routingOrgId,
-            key: SETTING_KEYS.PAYMENT_STRIPE_WEBHOOK_SECRET,
-          },
-        },
-      }),
-      db.appSetting.findUnique({
-        where: {
-          organizationId_key: {
-            organizationId: routingOrgId,
-            key: SETTING_KEYS.PAYMENT_STRIPE_SECRET_KEY,
-          },
-        },
-      }),
-    ])
-
-    if (!secretKeySetting?.value) {
+    // The org's Stripe connection, adopted from the old settings rows if this
+    // is the first payment after the move.
+    const connected = await paymentProviderFor(routingOrgId, 'stripe')
+    const secretKey = connected?.setup.credentials.secretKey
+    if (typeof secretKey !== 'string' || !secretKey) {
       return NextResponse.json({ error: 'Stripe not configured for this org' }, { status: 400 })
     }
+    const webhookSecret = connected?.setup.credentials.webhookSecret
+    const signingSecret = typeof webhookSecret === 'string' ? webhookSecret : ''
 
-    const stripe = new Stripe(secretKeySetting.value)
+    const stripe = new Stripe(secretKey)
 
     // Establish an AUTHENTIC session object. Two trust paths, never the body:
     //  - webhook secret configured → verify the signature over the raw body;
@@ -64,13 +51,13 @@ export async function POST(request: Request) {
     //    way while real payments keep recording.
     let session: Stripe.Checkout.Session
 
-    if (webhookSecretSetting?.value) {
+    if (signingSecret) {
       if (!signature) {
         return NextResponse.json({ error: 'Missing stripe-signature header' }, { status: 400 })
       }
       let event: Stripe.Event
       try {
-        event = stripe.webhooks.constructEvent(body, signature, webhookSecretSetting.value)
+        event = stripe.webhooks.constructEvent(body, signature, signingSecret)
       } catch {
         return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
       }

+ 4 - 12
src/app/api/webhooks/vipps/route.ts

@@ -1,7 +1,6 @@
 import { NextResponse } from 'next/server'
 import { db } from '@/lib/db'
-import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
-import { getPaymentProvider } from '@/lib/payment-providers'
+import { paymentProviderFor } from '@/features/integrations/Lib/payments'
 
 export async function POST(request: Request) {
   try {
@@ -47,19 +46,12 @@ async function processVippsPayment(reference: string, orgId: string, serviceReco
     return NextResponse.json({ received: true })
   }
 
-  // Load org settings
-  const settings = await db.appSetting.findMany({
-    where: { organizationId: orgId },
-  })
-  const settingsMap: Record<string, string> = {}
-  for (const s of settings) settingsMap[s.key] = s.value
-
-  if (!settingsMap[SETTING_KEYS.PAYMENT_VIPPS_CLIENT_ID]) {
+  const connected = await paymentProviderFor(orgId, 'vipps')
+  if (!connected) {
     return NextResponse.json({ error: 'Vipps not configured for this org' }, { status: 400 })
   }
 
-  const provider = getPaymentProvider('vipps', settingsMap)
-  const result = await provider.verifyPayment(reference)
+  const result = await connected.provider.verifyPayment(reference)
 
   if (!result || !result.paid) {
     return NextResponse.json({ received: true, status: 'not_paid' })

+ 77 - 12
src/features/integrations/Actions/integrationActions.ts

@@ -36,16 +36,26 @@ import {
 } from '../Lib/messaging'
 import { openCredentials } from '../Lib/vault'
 import { AI_CONNECTOR_IDS, adoptLegacyAi, markAiAdopted, retireOtherAiProviders } from '../Lib/ai'
+import {
+  PAYMENT_CONNECTOR_IDS,
+  adoptLegacyPayments,
+  isOffered,
+  isPaymentConnector,
+  markPaymentsAdopted,
+  paymentWebhook,
+} from '../Lib/payments'
 import { anyConnectorAllowed, connectorAllowed } from '../Lib/plan'
 
 /**
- * The inbound URL for a messaging connection, from its sealed secret. A row
- * whose credentials cannot be opened shows no URL rather than no page.
+ * The inbound URL for a messaging connection, from its sealed secret, or the
+ * notification URL a payment vendor must be given. A row whose credentials
+ * cannot be opened shows no URL rather than no page.
  */
 function inboundFor(
   row: { connectorId: string; organizationId: string; credentials: string | null },
   appUrl: string
 ): InboundWebhook | null {
+  if (isPaymentConnector(row.connectorId)) return paymentWebhook(row.connectorId, appUrl)
   if (!messagingProvider(row.connectorId)) return null
   try {
     return inboundWebhook(
@@ -82,7 +92,8 @@ function cleanSettings(manifest: ConnectorManifest, raw: unknown): Record<string
  * The one way a connection becomes live. A workshop sends through one SMS
  * vendor, one mail vendor and so on, the way the old provider dropdown
  * worked, so going live stands the channel's other vendors down, and records
- * that the channel is decided by its connections from now on.
+ * that the channel is decided by its connections from now on. Payment
+ * vendors run side by side, so going live there only records the move.
  */
 async function activateConnection(
   connectionId: string,
@@ -96,6 +107,10 @@ async function activateConnection(
     await markAiAdopted(organizationId, userId)
     return
   }
+  if (isPaymentConnector(connectorId)) {
+    await markPaymentsAdopted(organizationId, userId)
+    return
+  }
   const provider = messagingProvider(connectorId)
   if (!provider) return
   await retireOtherProviders(organizationId, connectorId)
@@ -138,10 +153,11 @@ function manifestForClient(m: ConnectorManifest): ConnectorManifest {
 export async function getIntegrationCatalog() {
   return withAuth(
     async ({ organizationId }) => {
-      // AI moved into the catalog from a settings page of its own. Adopting
-      // here as well as on first use means a workshop that had it switched on
-      // opens this page and finds it already connected.
-      await adoptLegacyAi(organizationId)
+      // AI and online payments moved into the catalog from settings pages of
+      // their own. Adopting here as well as on first use means a workshop
+      // that had them switched on opens this page and finds them already
+      // connected.
+      await Promise.all([adoptLegacyAi(organizationId), adoptLegacyPayments(organizationId)])
       const [features, connections, countrySetting] = await Promise.all([
         getFeatures(organizationId),
         db.integrationConnection.findMany({
@@ -265,11 +281,12 @@ export async function getIntegrationConnection(connectorId: string) {
     async ({ organizationId }): Promise<ConnectionView> => {
       const manifest = getManifest(connectorId)
       if (!manifest) throw new Error('Unknown integration')
-      // Opening an AI vendor's page directly, without passing the catalog,
-      // still finds an old settings-page setup already connected.
+      // Opening a vendor's page directly, without passing the catalog, still
+      // finds an old settings-page setup already connected.
       if (AI_CONNECTOR_IDS.includes(connectorId as (typeof AI_CONNECTOR_IDS)[number])) {
         await adoptLegacyAi(organizationId)
       }
+      if (isPaymentConnector(connectorId)) await adoptLegacyPayments(organizationId)
       const [features, row, supersedes] = await Promise.all([
         getFeatures(organizationId),
         db.integrationConnection.findUnique({
@@ -651,11 +668,12 @@ export async function disconnectIntegration(connectorId: string) {
           console.warn('[integrations] onDisconnect failed:', err)
         }
       }
-      // A messaging vendor the workshop disconnects stays disconnected: the
-      // rows it was set up from before the move must not be adopted back on
-      // the next send.
+      // A messaging or payment vendor the workshop disconnects stays
+      // disconnected: the rows it was set up from before the move must not
+      // be adopted back on the next send or checkout.
       const provider = messagingProvider(connectorId)
       if (provider) await markChannelAdopted(organizationId, provider.channel, userId)
+      if (isPaymentConnector(connectorId)) await markPaymentsAdopted(organizationId, userId)
       // Tokens go; links and logs go with the row so nothing dangles.
       await db.integrationConnection.delete({ where: { id: row.id } })
       await clearPulledEvents(row.id)
@@ -902,3 +920,50 @@ export async function hasIntegrationErrors() {
     return count > 0
   })
 }
+
+export interface PaymentConnectionSummary {
+  id: string
+  name: string
+  logo: string
+  status: ConnectionStatus | null
+  /** Connected and switched on for the invoice link. */
+  offered: boolean
+}
+
+/**
+ * The payment vendors as the payment settings page lists them: every one
+ * the catalog offers, with whether this workshop has it connected. Online
+ * payments used to be configured on that page, so it keeps showing where
+ * they stand and points at the catalog for the rest. Adopts an old setup
+ * first, so a workshop that had Vipps switched on sees it connected.
+ */
+export async function getPaymentConnections() {
+  return withAuth(
+    async ({ organizationId }): Promise<PaymentConnectionSummary[]> => {
+      await adoptLegacyPayments(organizationId)
+      const rows = await db.integrationConnection.findMany({
+        where: { organizationId, connectorId: { in: [...PAYMENT_CONNECTOR_IDS] } },
+        select: { connectorId: true, status: true, settings: true },
+      })
+      const byId = new Map(rows.map((r) => [r.connectorId, r]))
+      return PAYMENT_CONNECTOR_IDS.flatMap((id) => {
+        const manifest = getManifest(id)
+        if (!manifest) return []
+        const row = byId.get(id)
+        const status = (row?.status as ConnectionStatus | undefined) ?? null
+        return [
+          {
+            id,
+            name: manifest.name,
+            logo: manifest.logo,
+            status,
+            offered:
+              status === 'active' &&
+              isOffered({ settings: (row?.settings as Record<string, unknown>) ?? {} }),
+          },
+        ]
+      })
+    },
+    { requiredPermissions: READ_PERMISSION }
+  )
+}

+ 2 - 1
src/features/integrations/Lib/messaging.ts

@@ -23,6 +23,7 @@ import {
   providersForChannel,
 } from '@/integrations/messaging/catalog'
 import { db } from '@/lib/db'
+import type { PaymentWebhook } from './payments'
 import { openCredentials, sealCredentials } from './vault'
 
 export type { MessagingChannel }
@@ -564,7 +565,7 @@ export async function organizationForWebhookSecret(
 export interface InboundWebhook {
   url: string
   /** What the vendor also needs beside the URL, as an i18n key under connection. */
-  note: 'inboundHintSecret' | 'inboundHintMeta'
+  note: 'inboundHintSecret' | 'inboundHintMeta' | PaymentWebhook['note']
 }
 
 /**

+ 343 - 0
src/features/integrations/Lib/payments.ts

@@ -0,0 +1,343 @@
+/**
+ * Which connections a customer can pay an invoice through.
+ *
+ * Stripe, Vipps and PayPal used to be three cards on the payment settings
+ * page, their keys in `AppSetting` rows and the ones switched on listed in
+ * `payment.providersEnabled`. They are integrations like any other, so they
+ * moved into the catalog: keys sealed on an `IntegrationConnection`, test
+ * mode a setting on it. What must not change is that a workshop which
+ * switched Vipps on last year keeps taking Vipps payments without touching
+ * anything. The first time a payment path runs, or the catalog is opened,
+ * every vendor the old list had switched on is adopted into an active
+ * connection, once, and used from then on. Nothing is asked of the workshop
+ * and nothing is deleted, so a rollback still finds the old rows where it
+ * left them.
+ *
+ * Unlike messaging, vendors here run side by side. Adoption takes all of
+ * them at once and nothing stands another vendor down.
+ */
+
+import {
+  LEGACY_ENABLED_KEY,
+  PAYMENT_CONNECTOR_IDS,
+  PAYMENT_VENDORS,
+  type PaymentVendor,
+  isPaymentConnector,
+  legacyPaymentKeys,
+  paymentVendor,
+} from '@/integrations/payments/catalog'
+import { db } from '@/lib/db'
+import { type PaymentProvider, buildPaymentProvider } from '@/lib/payment-providers'
+import { openCredentials, sealCredentials } from './vault'
+
+export { PAYMENT_CONNECTOR_IDS, isPaymentConnector }
+
+/**
+ * Row that records the move having happened, so it happens once.
+ *
+ * Without it, disconnecting an adopted vendor would be undone by the next
+ * checkout, which would read the old rows and adopt them all over again.
+ * One row covers all three vendors, since the old page switched them on
+ * through one list. The row is one more setting the old code never read, so
+ * a rollback ignores it.
+ */
+export const PAYMENTS_ADOPTED_KEY = 'integrations.payments.adoptedAt'
+
+export interface PaymentSetup {
+  connectionId: string
+  connectorId: string
+  vendor: PaymentVendor
+  credentials: Record<string, unknown>
+  settings: Record<string, unknown>
+}
+
+/** Manifest defaults under what the workshop saved, the way the settings form shows them. */
+function withDefaults(
+  vendor: PaymentVendor,
+  saved: Record<string, unknown>
+): Record<string, unknown> {
+  const out: Record<string, unknown> = {}
+  for (const field of vendor.settings) {
+    if (field.default !== undefined) out[field.key] = field.default
+  }
+  return { ...out, ...saved }
+}
+
+/** Whether the workshop offers this vendor on the invoice link right now. */
+export function isOffered(setup: Pick<PaymentSetup, 'settings'>): boolean {
+  return setup.settings.enabled !== false
+}
+
+/** Vendors in the order the invoice link shows them, whatever order the rows came in. */
+function inVendorOrder<T extends { connectorId: string }>(setups: T[]): T[] {
+  const rank = new Map(PAYMENT_VENDORS.map((v, i) => [v.id, i]))
+  return [...setups].sort(
+    (a, b) => (rank.get(a.connectorId) ?? 99) - (rank.get(b.connectorId) ?? 99)
+  )
+}
+
+/** Record that the connections table decides online payments from now on. */
+export async function markPaymentsAdopted(organizationId: string, userId: string): Promise<void> {
+  await db.appSetting.upsert({
+    where: { organizationId_key: { organizationId, key: PAYMENTS_ADOPTED_KEY } },
+    create: { organizationId, userId, key: PAYMENTS_ADOPTED_KEY, value: new Date().toISOString() },
+    update: {},
+  })
+}
+
+export interface LegacyPaymentSetup {
+  vendor: PaymentVendor
+  credentials: Record<string, string>
+  settings: Record<string, unknown>
+  userId: string | null
+}
+
+/**
+ * The vendors an organization had switched on before the move, read without
+ * writing anything.
+ *
+ * A vendor counts only if the old list named it and every credential it
+ * requires is filled in: a card the workshop switched on and never finished
+ * is left alone rather than adopted into a connection that cannot take a
+ * payment. A vendor with keys but switched off is left alone too; that is
+ * what the switch meant.
+ */
+export async function legacyPaymentSetups(
+  organizationId: string
+): Promise<{ setups: LegacyPaymentSetup[]; adopted: boolean }> {
+  const rows = await db.appSetting.findMany({
+    where: { organizationId, key: { in: [...legacyPaymentKeys(), PAYMENTS_ADOPTED_KEY] } },
+    select: { key: true, value: true, userId: true },
+  })
+  const values = new Map(rows.map((r) => [r.key, r.value]))
+  const adopted = values.has(PAYMENTS_ADOPTED_KEY)
+  const userId = rows.find((r) => r.userId)?.userId ?? null
+
+  const enabled = new Set(
+    (values.get(LEGACY_ENABLED_KEY) ?? '')
+      .split(',')
+      .map((s) => s.trim())
+      .filter(Boolean)
+  )
+
+  const setups: LegacyPaymentSetup[] = []
+  for (const vendor of PAYMENT_VENDORS) {
+    if (!enabled.has(vendor.id)) continue
+
+    const credentials: Record<string, string> = {}
+    for (const field of vendor.credentials) {
+      const value = values.get(field.legacy)?.trim()
+      if (value) credentials[field.key] = value
+    }
+    if (vendor.credentials.some((f) => f.required && !credentials[f.key])) continue
+
+    const settings: Record<string, unknown> = {}
+    for (const field of vendor.settings) {
+      const raw = field.legacy ? values.get(field.legacy) : undefined
+      // An empty boolean row is no answer; the catalog default applies, the
+      // way the old checkout treated a missing row.
+      if (raw === undefined || (field.type === 'boolean' && raw === '')) continue
+      settings[field.key] = field.type === 'boolean' ? raw === 'true' : raw
+    }
+
+    setups.push({ vendor, credentials, settings, userId })
+  }
+  return { setups, adopted }
+}
+
+/** Prisma's code for a unique constraint the row already satisfies. */
+function isUniqueViolation(err: unknown): boolean {
+  return typeof err === 'object' && err !== null && (err as { code?: string }).code === 'P2002'
+}
+
+/** What the connection page shows as the account, for a setup that never went through identify. */
+function accountNameOf(setup: LegacyPaymentSetup): string | null {
+  const msn = setup.credentials.merchantSerialNumber
+  return msn ? `MSN ${msn}` : null
+}
+
+/**
+ * Turn the old setup into live connections. Runs once per organization: the
+ * marker is written whether or not every row survives, and a vendor row the
+ * workshop put there themselves is never written over.
+ */
+export async function adoptLegacyPayments(organizationId: string): Promise<PaymentSetup[]> {
+  const { setups, adopted } = await legacyPaymentSetups(organizationId)
+  // Adopted once already: whatever happened to those connections since, such
+  // as the workshop disconnecting one, is the current truth.
+  if (adopted || setups.length === 0) return []
+
+  const live: PaymentSetup[] = []
+  for (const setup of setups) {
+    const { vendor } = setup
+    // A row for this vendor that the workshop created themselves stays
+    // theirs, even in error: the old rows are not copied over it.
+    const existing = await db.integrationConnection.findUnique({
+      where: { organizationId_connectorId: { organizationId, connectorId: vendor.id } },
+      select: { id: true },
+    })
+    if (existing) continue
+
+    let connection: { id: string; status: string }
+    try {
+      connection = await db.integrationConnection.create({
+        data: {
+          organizationId,
+          connectorId: vendor.id,
+          status: 'active',
+          credentials: sealCredentials(setup.credentials),
+          settings: setup.settings as object,
+          createdById: setup.userId ?? 'migration',
+          label: 'Adopted from settings',
+          externalAccountName: accountNameOf(setup),
+        },
+        select: { id: true, status: true },
+      })
+    } catch (err) {
+      // Two checkouts can land here at once on the first payment after a
+      // deploy. The loser of that race uses the winner's row.
+      if (!isUniqueViolation(err)) throw err
+      const winner = await db.integrationConnection.findUnique({
+        where: { organizationId_connectorId: { organizationId, connectorId: vendor.id } },
+        select: { id: true, status: true },
+      })
+      if (!winner) throw err
+      connection = winner
+    }
+
+    if (connection.status === 'active') {
+      live.push({
+        connectionId: connection.id,
+        connectorId: vendor.id,
+        vendor,
+        credentials: setup.credentials,
+        settings: withDefaults(vendor, setup.settings),
+      })
+    }
+  }
+
+  // Every old row carries the user who wrote it, so there is always one to
+  // own the marker.
+  const owner = setups.find((s) => s.userId)?.userId
+  if (owner) await markPaymentsAdopted(organizationId, owner)
+
+  return inVendorOrder(live)
+}
+
+/**
+ * Last resort when a connection's credentials cannot be opened: the old rows
+ * are still there, so the vendor keeps taking payments from them while
+ * someone sorts the key out. Logged every time, because it should never be
+ * quiet.
+ */
+async function unsealedFallback(
+  organizationId: string,
+  connectorId: string,
+  connectionId: string,
+  err: unknown
+): Promise<PaymentSetup | null> {
+  console.error(
+    `[integrations] cannot open credentials for ${connectorId} connection ${connectionId} of organization ${organizationId}; ` +
+      'check INTEGRATIONS_ENCRYPTION_KEY / BETTER_AUTH_SECRET (see scripts/rekey-integrations.ts). ' +
+      'Falling back to the settings rows from before the move.',
+    err
+  )
+  const { setups } = await legacyPaymentSetups(organizationId)
+  const setup = setups.find((s) => s.vendor.id === connectorId)
+  if (!setup) return null
+  return {
+    connectionId,
+    connectorId,
+    vendor: setup.vendor,
+    credentials: setup.credentials,
+    settings: withDefaults(setup.vendor, setup.settings),
+  }
+}
+
+/**
+ * The connections a customer can pay through, adopting an old setup on first
+ * use. Includes vendors the workshop has paused with the offered switch;
+ * callers that put buttons on a page filter with `isOffered`, while the
+ * paths that record money already paid do not.
+ */
+export async function paymentSetups(organizationId: string): Promise<PaymentSetup[]> {
+  const rows = await db.integrationConnection.findMany({
+    where: { organizationId, connectorId: { in: [...PAYMENT_CONNECTOR_IDS] }, status: 'active' },
+    select: { id: true, connectorId: true, credentials: true, settings: true },
+  })
+  if (rows.length === 0) return adoptLegacyPayments(organizationId)
+
+  const setups: PaymentSetup[] = []
+  for (const row of rows) {
+    const vendor = paymentVendor(row.connectorId)
+    if (!vendor) continue
+    let credentials: Record<string, unknown>
+    try {
+      credentials = openCredentials(row.credentials)
+    } catch (err) {
+      const fallback = await unsealedFallback(organizationId, row.connectorId, row.id, err)
+      if (fallback) setups.push(fallback)
+      continue
+    }
+    setups.push({
+      connectionId: row.id,
+      connectorId: row.connectorId,
+      vendor,
+      credentials,
+      settings: withDefaults(vendor, (row.settings as Record<string, unknown>) ?? {}),
+    })
+  }
+  return inVendorOrder(setups)
+}
+
+/** One vendor's connection, with the client that talks to it. Null when the workshop has not connected it. */
+export async function paymentProviderFor(
+  organizationId: string,
+  connectorId: string
+): Promise<{ setup: PaymentSetup; provider: PaymentProvider } | null> {
+  const setup = (await paymentSetups(organizationId)).find((s) => s.connectorId === connectorId)
+  if (!setup) return null
+  const provider = buildPaymentProvider(connectorId, setup.credentials, setup.settings)
+  return provider ? { setup, provider } : null
+}
+
+/**
+ * Which vendors the invoice link should offer, without adopting anything.
+ *
+ * The shared invoice page calls this on every view, and a page render is no
+ * place to create a connection; the adoption happens when a customer
+ * actually pays, or when the catalog is opened. Until then an old setup is
+ * read as it stands, so the buttons are there on the first view after the
+ * deploy as they were on the last one before it.
+ */
+export async function offeredPaymentProviders(organizationId: string): Promise<string[]> {
+  const rows = await db.integrationConnection.findMany({
+    where: { organizationId, connectorId: { in: [...PAYMENT_CONNECTOR_IDS] }, status: 'active' },
+    select: { connectorId: true, settings: true },
+  })
+  if (rows.length > 0) {
+    return inVendorOrder(rows)
+      .filter((row) => isOffered({ settings: (row.settings as Record<string, unknown>) ?? {} }))
+      .map((row) => row.connectorId)
+  }
+  const { setups, adopted } = await legacyPaymentSetups(organizationId)
+  if (adopted) return []
+  return setups.filter((s) => isOffered(s)).map((s) => s.vendor.id)
+}
+
+export interface PaymentWebhook {
+  url: string
+  /** What the vendor also needs beside the URL, as an i18n key under connection. */
+  note: PaymentVendor['webhookNote']
+}
+
+/**
+ * The URL a payment vendor must notify when a customer has paid. The same
+ * route serves every workshop, because the vendor sends back the metadata
+ * the checkout was created with, so there is no secret in it to keep.
+ */
+export function paymentWebhook(connectorId: string, appUrl: string): PaymentWebhook | null {
+  const vendor = paymentVendor(connectorId)
+  if (!vendor || !appUrl) return null
+  return { url: `${appUrl.replace(/\/$/, '')}${vendor.webhookPath}`, note: vendor.webhookNote }
+}

+ 43 - 0
src/integrations/payments/build.ts

@@ -0,0 +1,43 @@
+import type {
+  ConnectorManifest,
+  CredentialField,
+  SettingField,
+} from '@/features/integrations/Lib/types'
+import { type PaymentVendor, paymentVendor } from './catalog'
+
+/** The one thing a payment connector does: let a customer pay from the invoice link. */
+export const PAYMENT_CAPABILITY = 'payments.checkout'
+
+function withoutLegacy<T extends { legacy?: string }>(field: T): Omit<T, 'legacy'> {
+  const { legacy: _legacy, ...rest } = field
+  return rest
+}
+
+/**
+ * A payment vendor's manifest, built from the catalog entry so the fields
+ * the connect page shows and the rows adoption reads are the same list.
+ */
+export function paymentManifest(
+  id: string,
+  extras: Pick<ConnectorManifest, 'countries' | 'logo'>
+): ConnectorManifest {
+  const vendor = paymentVendor(id)
+  if (!vendor) throw new Error(`Unknown payment vendor ${id}`)
+  return {
+    id: vendor.id,
+    name: vendor.name,
+    category: 'payments',
+    countries: extras.countries,
+    logo: extras.logo,
+    docs: '/docs/configuration/payment-providers',
+    auth: {
+      type: 'api-key',
+      fields: vendor.credentials.map((c) => withoutLegacy(c) as CredentialField),
+    },
+    capabilities: [PAYMENT_CAPABILITY],
+    settings: vendor.settings.map((s) => withoutLegacy(s) as SettingField),
+    plan: 'payments',
+  }
+}
+
+export type { PaymentVendor }

+ 199 - 0
src/integrations/payments/catalog.ts

@@ -0,0 +1,199 @@
+/**
+ * Every payment vendor a customer can pay an invoice through, declared once.
+ *
+ * Stripe, Vipps and PayPal used to be three cards on the payment settings
+ * page, each writing its own `AppSetting` rows and all switched on or off
+ * through one `payment.providersEnabled` list. They are integrations like
+ * any other, so they live in the catalog now. What must not change is that a
+ * workshop which switched Vipps on last year keeps taking Vipps payments: the
+ * `legacy` field on every credential and setting names the row the value
+ * used to live in, so the platform can adopt an existing setup into a
+ * connection without anyone touching a form.
+ *
+ * Unlike messaging, where one vendor per channel is in charge, payment
+ * vendors run side by side: a Norwegian shop offers Vipps and card together.
+ * Nothing here stands another vendor down.
+ */
+
+import type { CredentialField, SettingField } from '@/features/integrations/Lib/types'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+
+/** A credential field plus the settings row it used to be stored in. */
+export interface PaymentCredential extends CredentialField {
+  legacy: string
+}
+
+export interface PaymentSetting extends SettingField {
+  legacy?: string
+}
+
+export interface PaymentVendor {
+  /** Connector id, which is also the folder under src/integrations and the value in the old enabled list. */
+  id: string
+  /** Vendor name as the vendor writes it. */
+  name: string
+  credentials: PaymentCredential[]
+  settings: PaymentSetting[]
+  /**
+   * Where the vendor must send its payment notifications, under the app URL.
+   * The route is the same for every workshop; the vendor identifies the
+   * workshop from the metadata the checkout was created with.
+   */
+  webhookPath: string
+  /** i18n key under integrations.connection describing what to do with the URL. */
+  webhookNote: 'inboundHintStripe' | 'inboundHintVipps' | 'inboundHintPaypal'
+}
+
+/** The value of `payment.providersEnabled` before the move, as a list. */
+export const LEGACY_ENABLED_KEY = SETTING_KEYS.PAYMENT_PROVIDERS_ENABLED
+
+/**
+ * Whether a vendor is offered on the invoice link. The old page had a
+ * switch per vendor, so a workshop could keep its keys and take a vendor off
+ * the invoice for a while; this keeps that possible without disconnecting.
+ */
+export const OFFERED_SETTING: PaymentSetting = {
+  key: 'enabled',
+  type: 'boolean',
+  label: 'enabled',
+  help: 'enabledHelp',
+  default: true,
+}
+
+export const PAYMENT_VENDORS: readonly PaymentVendor[] = [
+  {
+    id: 'stripe',
+    name: 'Stripe',
+    credentials: [
+      {
+        key: 'secretKey',
+        label: 'secretKey',
+        type: 'password',
+        required: true,
+        placeholder: 'sk_live_...',
+        help: 'secretKeyHelp',
+        legacy: SETTING_KEYS.PAYMENT_STRIPE_SECRET_KEY,
+      },
+      {
+        key: 'publishableKey',
+        label: 'publishableKey',
+        type: 'password',
+        placeholder: 'pk_live_...',
+        legacy: SETTING_KEYS.PAYMENT_STRIPE_PUBLISHABLE_KEY,
+      },
+      {
+        key: 'webhookSecret',
+        label: 'webhookSecret',
+        type: 'password',
+        placeholder: 'whsec_...',
+        help: 'webhookSecretHelp',
+        legacy: SETTING_KEYS.PAYMENT_STRIPE_WEBHOOK_SECRET,
+      },
+    ],
+    settings: [OFFERED_SETTING],
+    webhookPath: '/api/webhooks/stripe',
+    webhookNote: 'inboundHintStripe',
+  },
+  {
+    id: 'vipps',
+    name: 'Vipps',
+    credentials: [
+      {
+        key: 'clientId',
+        label: 'clientId',
+        type: 'password',
+        required: true,
+        legacy: SETTING_KEYS.PAYMENT_VIPPS_CLIENT_ID,
+      },
+      {
+        key: 'clientSecret',
+        label: 'clientSecret',
+        type: 'password',
+        required: true,
+        legacy: SETTING_KEYS.PAYMENT_VIPPS_CLIENT_SECRET,
+      },
+      {
+        key: 'subscriptionKey',
+        label: 'subscriptionKey',
+        type: 'password',
+        required: true,
+        help: 'subscriptionKeyHelp',
+        legacy: SETTING_KEYS.PAYMENT_VIPPS_SUBSCRIPTION_KEY,
+      },
+      {
+        key: 'merchantSerialNumber',
+        label: 'merchantSerialNumber',
+        type: 'text',
+        required: true,
+        placeholder: '123456',
+        legacy: SETTING_KEYS.PAYMENT_VIPPS_MSN,
+      },
+    ],
+    settings: [
+      OFFERED_SETTING,
+      {
+        key: 'testMode',
+        type: 'boolean',
+        label: 'testMode',
+        help: 'testModeHelp',
+        default: false,
+        legacy: SETTING_KEYS.PAYMENT_VIPPS_USE_TEST,
+      },
+    ],
+    webhookPath: '/api/webhooks/vipps',
+    webhookNote: 'inboundHintVipps',
+  },
+  {
+    id: 'paypal',
+    name: 'PayPal',
+    credentials: [
+      {
+        key: 'clientId',
+        label: 'clientId',
+        type: 'password',
+        required: true,
+        legacy: SETTING_KEYS.PAYMENT_PAYPAL_CLIENT_ID,
+      },
+      {
+        key: 'clientSecret',
+        label: 'clientSecret',
+        type: 'password',
+        required: true,
+        legacy: SETTING_KEYS.PAYMENT_PAYPAL_CLIENT_SECRET,
+      },
+    ],
+    settings: [
+      OFFERED_SETTING,
+      {
+        key: 'sandbox',
+        type: 'boolean',
+        label: 'sandbox',
+        help: 'sandboxHelp',
+        default: false,
+        legacy: SETTING_KEYS.PAYMENT_PAYPAL_USE_SANDBOX,
+      },
+    ],
+    webhookPath: '/api/webhooks/paypal',
+    webhookNote: 'inboundHintPaypal',
+  },
+]
+
+export const PAYMENT_CONNECTOR_IDS = PAYMENT_VENDORS.map((v) => v.id)
+
+export function paymentVendor(connectorId: string): PaymentVendor | null {
+  return PAYMENT_VENDORS.find((v) => v.id === connectorId) ?? null
+}
+
+export function isPaymentConnector(connectorId: string): boolean {
+  return PAYMENT_VENDORS.some((v) => v.id === connectorId)
+}
+
+/** Every old row the payment vendors were stored under, plus the enabled list. */
+export function legacyPaymentKeys(): string[] {
+  const keys = new Set<string>([LEGACY_ENABLED_KEY])
+  for (const vendor of PAYMENT_VENDORS) {
+    for (const c of vendor.credentials) keys.add(c.legacy)
+    for (const s of vendor.settings) if (s.legacy) keys.add(s.legacy)
+  }
+  return [...keys]
+}

+ 7 - 0
src/integrations/paypal/manifest.ts

@@ -0,0 +1,7 @@
+import { paymentManifest } from '../payments/build'
+
+/** PayPal checkout from the invoice link, through the workshop's own PayPal app. */
+export const manifest = paymentManifest('paypal', {
+  countries: 'global',
+  logo: '/images/integrations/paypal.svg',
+})

+ 31 - 0
src/integrations/paypal/server.ts

@@ -0,0 +1,31 @@
+import type { ConnectorContext, ConnectorServer } from '@/features/integrations/Lib/types'
+import { PayPalProvider, paypalConfigFrom } from '@/lib/payment-providers/paypal'
+import { manifest } from './manifest'
+
+function providerOf(ctx: ConnectorContext): PayPalProvider | null {
+  const config = paypalConfigFrom(ctx.credentials, ctx.connection.settings)
+  return config ? new PayPalProvider(config) : null
+}
+
+/**
+ * A client-credentials token is the cheapest thing PayPal will issue, and it
+ * proves the id and secret against the environment the sandbox switch
+ * points at: live keys fail on the sandbox and the other way round, which is
+ * exactly the mistake worth catching before a customer does.
+ */
+export const connector: ConnectorServer = {
+  manifest,
+
+  async test(ctx) {
+    const provider = providerOf(ctx)
+    if (!provider) return { ok: false, message: 'PayPal: a client id and secret are required' }
+    try {
+      await provider.getAccessToken()
+      return { ok: true }
+    } catch (err) {
+      return { ok: false, message: err instanceof Error ? err.message : 'PayPal did not answer' }
+    }
+  },
+
+  jobs: {},
+}

+ 6 - 0
src/integrations/registry.ts

@@ -15,16 +15,19 @@ import { manifest as mailgun } from './mailgun/manifest'
 import { manifest as microsoft365 } from './microsoft-365/manifest'
 import { manifest as openai } from './openai/manifest'
 import { manifest as openapiAutomotive } from './openapi-automotive/manifest'
+import { manifest as paypal } from './paypal/manifest'
 import { manifest as postmark } from './postmark/manifest'
 import { manifest as rdw } from './rdw/manifest'
 import { manifest as regcheck } from './regcheck/manifest'
 import { manifest as resend } from './resend/manifest'
 import { manifest as sendgrid } from './sendgrid/manifest'
 import { manifest as smtp } from './smtp/manifest'
+import { manifest as stripe } from './stripe/manifest'
 import { manifest as telegram } from './telegram/manifest'
 import { manifest as telnyxSms } from './telnyx-sms/manifest'
 import { manifest as twilioSms } from './twilio-sms/manifest'
 import { manifest as vegvesen } from './vegvesen/manifest'
+import { manifest as vipps } from './vipps/manifest'
 import { manifest as vonageSms } from './vonage-sms/manifest'
 import { manifest as whatsappMeta } from './whatsapp-meta/manifest'
 import { manifest as whatsappTwilio } from './whatsapp-twilio/manifest'
@@ -57,6 +60,9 @@ const ENTRIES: readonly RegistryEntry[] = [
   { manifest: mailgun, load: () => import('./mailgun/server') },
   { manifest: sendgrid, load: () => import('./sendgrid/server') },
   { manifest: amazonSes, load: () => import('./amazon-ses/server') },
+  { manifest: stripe, load: () => import('./stripe/server') },
+  { manifest: vipps, load: () => import('./vipps/server') },
+  { manifest: paypal, load: () => import('./paypal/server') },
 ]
 
 const BY_ID = new Map(ENTRIES.map((e) => [e.manifest.id, e]))

+ 11 - 0
src/integrations/stripe/manifest.ts

@@ -0,0 +1,11 @@
+import { paymentManifest } from '../payments/build'
+
+/**
+ * Card payments from the invoice link, through the workshop's own Stripe
+ * account. Not the platform's Stripe, which bills subscriptions and lives in
+ * system settings; this is the account the customer's money lands in.
+ */
+export const manifest = paymentManifest('stripe', {
+  countries: 'global',
+  logo: '/images/integrations/stripe.svg',
+})

+ 48 - 0
src/integrations/stripe/server.ts

@@ -0,0 +1,48 @@
+import Stripe from 'stripe'
+import type { ConnectorContext, ConnectorServer } from '@/features/integrations/Lib/types'
+import { manifest } from './manifest'
+
+function secretKeyOf(ctx: ConnectorContext): string {
+  const key = ctx.credentials.secretKey
+  return typeof key === 'string' ? key.trim() : ''
+}
+
+/** What Stripe calls the account, in the order a workshop would recognise it. */
+function accountName(account: Stripe.Account): string {
+  return (
+    account.settings?.dashboard?.display_name ||
+    account.business_profile?.name ||
+    account.email ||
+    account.id
+  )
+}
+
+/**
+ * Reading the account back is the cheapest call that proves a secret key,
+ * and it also tells us whose account it is. Stripe answers a bad key with
+ * an authentication error rather than a status code we would have to guess at.
+ */
+export const connector: ConnectorServer = {
+  manifest,
+
+  async test(ctx) {
+    const secretKey = secretKeyOf(ctx)
+    if (!secretKey) return { ok: false, message: 'Stripe: a secret key is required' }
+    try {
+      await new Stripe(secretKey).accounts.retrieve()
+      return { ok: true }
+    } catch (err) {
+      if (err instanceof Stripe.errors.StripeAuthenticationError) {
+        return { ok: false, message: 'Stripe rejected the secret key' }
+      }
+      return { ok: false, message: err instanceof Error ? err.message : 'Stripe did not answer' }
+    }
+  },
+
+  async identify(ctx) {
+    const account = await new Stripe(secretKeyOf(ctx)).accounts.retrieve()
+    return { id: account.id, name: accountName(account) }
+  },
+
+  jobs: {},
+}

+ 10 - 0
src/integrations/vipps/manifest.ts

@@ -0,0 +1,10 @@
+import { paymentManifest } from '../payments/build'
+
+/**
+ * Vipps MobilePay from the invoice link. The wallet the Nordic countries pay
+ * with, so it is featured there and available everywhere.
+ */
+export const manifest = paymentManifest('vipps', {
+  countries: ['NO', 'DK', 'FI', 'SE'],
+  logo: '/images/integrations/vipps.svg',
+})

+ 35 - 0
src/integrations/vipps/server.ts

@@ -0,0 +1,35 @@
+import type { ConnectorContext, ConnectorServer } from '@/features/integrations/Lib/types'
+import { VippsProvider, vippsConfigFrom } from '@/lib/payment-providers/vipps'
+import { manifest } from './manifest'
+
+function providerOf(ctx: ConnectorContext): VippsProvider | null {
+  const config = vippsConfigFrom(ctx.credentials, ctx.connection.settings)
+  return config ? new VippsProvider(config) : null
+}
+
+/**
+ * Fetching an access token is the whole of Vipps's authentication, and it
+ * checks every one of the four values at once: a wrong client secret and a
+ * wrong subscription key both come back as a refusal.
+ */
+export const connector: ConnectorServer = {
+  manifest,
+
+  async test(ctx) {
+    const provider = providerOf(ctx)
+    if (!provider) return { ok: false, message: 'Vipps: all four credentials are required' }
+    try {
+      await provider.getAccessToken()
+      return { ok: true }
+    } catch (err) {
+      return { ok: false, message: err instanceof Error ? err.message : 'Vipps did not answer' }
+    }
+  },
+
+  async identify(ctx) {
+    const msn = String(ctx.credentials.merchantSerialNumber ?? '').trim()
+    return { id: msn, name: `MSN ${msn}` }
+  },
+
+  jobs: {},
+}

+ 22 - 47
src/lib/payment-providers/index.ts

@@ -1,61 +1,36 @@
-import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import type { PaymentProvider } from './types'
 import { StripeProvider } from './stripe'
-import { VippsProvider } from './vipps'
-import { PayPalProvider } from './paypal'
+import { VippsProvider, vippsConfigFrom } from './vipps'
+import { PayPalProvider, paypalConfigFrom } from './paypal'
 
 export type { PaymentProvider, CheckoutRequest, CheckoutResult, VerifyResult } from './types'
 
-export function getPaymentProvider(
-  provider: string,
-  settings: Record<string, string>
-): PaymentProvider {
-  switch (provider) {
+/**
+ * The client for one payment connection, from the credentials and settings
+ * the connection holds. Null when the connection is missing something the
+ * vendor cannot work without, which the caller reports as "not configured"
+ * rather than letting a half-built client fail somewhere deeper.
+ */
+export function buildPaymentProvider(
+  connectorId: string,
+  credentials: Record<string, unknown>,
+  settings: Record<string, unknown>
+): PaymentProvider | null {
+  switch (connectorId) {
     case 'stripe': {
-      const secretKey = settings[SETTING_KEYS.PAYMENT_STRIPE_SECRET_KEY]
-      if (!secretKey) {
-        throw new Error('Stripe secret key not configured')
-      }
-      return new StripeProvider(secretKey)
+      const secretKey = credentials.secretKey
+      if (typeof secretKey !== 'string' || !secretKey.trim()) return null
+      return new StripeProvider(secretKey.trim())
     }
     case 'vipps': {
-      const clientId = settings[SETTING_KEYS.PAYMENT_VIPPS_CLIENT_ID]
-      const clientSecret = settings[SETTING_KEYS.PAYMENT_VIPPS_CLIENT_SECRET]
-      const subscriptionKey = settings[SETTING_KEYS.PAYMENT_VIPPS_SUBSCRIPTION_KEY]
-      const msn = settings[SETTING_KEYS.PAYMENT_VIPPS_MSN]
-      if (!clientId || !clientSecret || !subscriptionKey || !msn) {
-        throw new Error('Vipps credentials not fully configured')
-      }
-      return new VippsProvider({
-        clientId,
-        clientSecret,
-        subscriptionKey,
-        merchantSerialNumber: msn,
-        useTestMode: settings[SETTING_KEYS.PAYMENT_VIPPS_USE_TEST] === 'true',
-      })
+      const config = vippsConfigFrom(credentials, settings)
+      return config ? new VippsProvider(config) : null
     }
     case 'paypal': {
-      const clientId = settings[SETTING_KEYS.PAYMENT_PAYPAL_CLIENT_ID]
-      const clientSecret = settings[SETTING_KEYS.PAYMENT_PAYPAL_CLIENT_SECRET]
-      if (!clientId || !clientSecret) {
-        throw new Error('PayPal credentials not fully configured')
-      }
-      return new PayPalProvider({
-        clientId,
-        clientSecret,
-        useSandbox: settings[SETTING_KEYS.PAYMENT_PAYPAL_USE_SANDBOX] === 'true',
-      })
+      const config = paypalConfigFrom(credentials, settings)
+      return config ? new PayPalProvider(config) : null
     }
     default:
-      throw new Error(`Unknown payment provider: ${provider}`)
+      return null
   }
 }
-
-export function getEnabledProviders(settings: Record<string, string>): string[] {
-  const raw = settings[SETTING_KEYS.PAYMENT_PROVIDERS_ENABLED]
-  if (!raw) return []
-  return raw
-    .split(',')
-    .map((s) => s.trim())
-    .filter(Boolean)
-}

+ 21 - 3
src/lib/payment-providers/paypal.ts

@@ -1,11 +1,26 @@
 import type { PaymentProvider, CheckoutRequest, CheckoutResult, VerifyResult } from './types'
 
-interface PayPalConfig {
+export interface PayPalConfig {
   clientId: string
   clientSecret: string
   useSandbox: boolean
 }
 
+function text(value: unknown): string {
+  return typeof value === 'string' ? value.trim() : ''
+}
+
+/** The config a connection's credentials and settings amount to, or null without an id and secret. */
+export function paypalConfigFrom(
+  credentials: Record<string, unknown>,
+  settings: Record<string, unknown>
+): PayPalConfig | null {
+  const clientId = text(credentials.clientId)
+  const clientSecret = text(credentials.clientSecret)
+  if (!clientId || !clientSecret) return null
+  return { clientId, clientSecret, useSandbox: settings.sandbox === true }
+}
+
 export class PayPalProvider implements PaymentProvider {
   private config: PayPalConfig
   private baseUrl: string
@@ -17,7 +32,7 @@ export class PayPalProvider implements PaymentProvider {
       : 'https://api-m.paypal.com'
   }
 
-  private async getAccessToken(): Promise<string> {
+  async getAccessToken(): Promise<string> {
     const credentials = Buffer.from(`${this.config.clientId}:${this.config.clientSecret}`).toString(
       'base64'
     )
@@ -64,7 +79,10 @@ export class PayPalProvider implements PaymentProvider {
         payment_source: {
           paypal: {
             experience_context: {
-              return_url: `${req.successUrl}?paypal_order_id={order.id}`,
+              // PayPal has no placeholder syntax; it appends its own
+              // token (the order id) and PayerID to whatever is given. The
+              // marker tells the invoice page which vendor it is back from.
+              return_url: `${req.successUrl}?paypal=return`,
               cancel_url: req.cancelUrl,
               user_action: 'PAY_NOW',
               brand_name: req.description.split(' - ')[0] || 'Invoice Payment',

+ 28 - 2
src/lib/payment-providers/vipps.ts

@@ -3,7 +3,7 @@ import type { PaymentProvider, CheckoutRequest, CheckoutResult, VerifyResult } f
 const VIPPS_API_URL = 'https://api.vipps.no'
 const VIPPS_TEST_API_URL = 'https://apitest.vipps.no'
 
-interface VippsConfig {
+export interface VippsConfig {
   clientId: string
   clientSecret: string
   subscriptionKey: string
@@ -11,6 +11,32 @@ interface VippsConfig {
   useTestMode: boolean
 }
 
+function text(value: unknown): string {
+  return typeof value === 'string' ? value.trim() : ''
+}
+
+/**
+ * The config a connection's credentials and settings amount to, or null when
+ * any of the four values Vipps insists on is missing.
+ */
+export function vippsConfigFrom(
+  credentials: Record<string, unknown>,
+  settings: Record<string, unknown>
+): VippsConfig | null {
+  const clientId = text(credentials.clientId)
+  const clientSecret = text(credentials.clientSecret)
+  const subscriptionKey = text(credentials.subscriptionKey)
+  const merchantSerialNumber = text(credentials.merchantSerialNumber)
+  if (!clientId || !clientSecret || !subscriptionKey || !merchantSerialNumber) return null
+  return {
+    clientId,
+    clientSecret,
+    subscriptionKey,
+    merchantSerialNumber,
+    useTestMode: settings.testMode === true,
+  }
+}
+
 export class VippsProvider implements PaymentProvider {
   private config: VippsConfig
   private baseUrl: string
@@ -20,7 +46,7 @@ export class VippsProvider implements PaymentProvider {
     this.baseUrl = config.useTestMode ? VIPPS_TEST_API_URL : VIPPS_API_URL
   }
 
-  private async getAccessToken(): Promise<string> {
+  async getAccessToken(): Promise<string> {
     const res = await fetch(`${this.baseUrl}/accesstoken/get`, {
       method: 'POST',
       headers: {