4
0
Эх сурвалжийг харах

Take invoice payments through Paystack (#594)

* Take invoice payments through Paystack

* Ask Paystack to resend a notification it could not confirm

* Draw each pay button like the vendor's own checkout button

* Name each pay button by what it shows
Bernt Christian Egeland 7 цаг өмнө
parent
commit
63df256516
77 өөрчлөгдсөн 1392 нэмэгдсэн , 106 устгасан
  1. 13 9
      e2e/specs/payments/checkout.spec.ts
  2. 1 1
      e2e/specs/security/payment-attribution.spec.ts
  3. 1 1
      messages/de/auth.json
  4. 12 0
      messages/de/integrations.json
  5. 1 1
      messages/de/settings.json
  6. 2 0
      messages/de/share.json
  7. 1 1
      messages/en/auth.json
  8. 12 0
      messages/en/integrations.json
  9. 1 1
      messages/en/settings.json
  10. 2 0
      messages/en/share.json
  11. 1 1
      messages/es/auth.json
  12. 12 0
      messages/es/integrations.json
  13. 1 1
      messages/es/settings.json
  14. 2 0
      messages/es/share.json
  15. 1 1
      messages/fr/auth.json
  16. 12 0
      messages/fr/integrations.json
  17. 1 1
      messages/fr/settings.json
  18. 2 0
      messages/fr/share.json
  19. 1 1
      messages/it/auth.json
  20. 12 0
      messages/it/integrations.json
  21. 1 1
      messages/it/settings.json
  22. 2 0
      messages/it/share.json
  23. 1 1
      messages/lt/auth.json
  24. 12 0
      messages/lt/integrations.json
  25. 1 1
      messages/lt/settings.json
  26. 2 0
      messages/lt/share.json
  27. 1 1
      messages/nb/auth.json
  28. 12 0
      messages/nb/integrations.json
  29. 1 1
      messages/nb/settings.json
  30. 2 0
      messages/nb/share.json
  31. 1 1
      messages/nl/auth.json
  32. 12 0
      messages/nl/integrations.json
  33. 1 1
      messages/nl/settings.json
  34. 2 0
      messages/nl/share.json
  35. 1 1
      messages/pl/auth.json
  36. 12 0
      messages/pl/integrations.json
  37. 1 1
      messages/pl/settings.json
  38. 2 0
      messages/pl/share.json
  39. 1 1
      messages/pt-BR/auth.json
  40. 12 0
      messages/pt-BR/integrations.json
  41. 1 1
      messages/pt-BR/settings.json
  42. 2 0
      messages/pt-BR/share.json
  43. 1 1
      messages/pt-PT/auth.json
  44. 12 0
      messages/pt-PT/integrations.json
  45. 1 1
      messages/pt-PT/settings.json
  46. 2 0
      messages/pt-PT/share.json
  47. 1 1
      messages/ru/auth.json
  48. 12 0
      messages/ru/integrations.json
  49. 1 1
      messages/ru/settings.json
  50. 2 0
      messages/ru/share.json
  51. 1 1
      messages/tr/auth.json
  52. 12 0
      messages/tr/integrations.json
  53. 1 1
      messages/tr/settings.json
  54. 2 0
      messages/tr/share.json
  55. 1 0
      public/images/integrations/paystack.svg
  56. 23 0
      src/__tests__/app/api/webhooks-freeze.test.ts
  57. 160 0
      src/__tests__/app/api/webhooks-paystack.test.ts
  58. 6 2
      src/__tests__/features/integrations/payments.test.ts
  59. 47 8
      src/__tests__/features/public/invoice-view.test.tsx
  60. 267 0
      src/__tests__/lib/payment-providers/paystack.test.ts
  61. 14 1
      src/__tests__/lib/payment-vendor-hosts.test.ts
  62. 16 1
      src/__tests__/setup.ts
  63. 37 49
      src/app/(public)/share/invoice/[orgId]/[token]/invoice-view.tsx
  64. 18 1
      src/app/api/public/share/invoice/[orgId]/[token]/checkout/route.ts
  65. 110 0
      src/app/api/webhooks/paystack/route.ts
  66. 1 1
      src/features/integrations/Lib/payments.ts
  67. 130 0
      src/features/payments/Components/VendorPayButton.tsx
  68. 9 1
      src/features/settings/Lib/searchIndex/payment.ts
  69. 30 4
      src/integrations/payments/catalog.ts
  70. 10 0
      src/integrations/paystack/manifest.ts
  71. 31 0
      src/integrations/paystack/server.ts
  72. 2 0
      src/integrations/registry.ts
  73. 5 0
      src/lib/payment-providers/index.ts
  74. 224 0
      src/lib/payment-providers/paystack.ts
  75. 5 0
      src/lib/payment-providers/types.ts
  76. 15 0
      src/lib/payment-providers/vendor-amounts.ts
  77. 9 2
      src/lib/payment-providers/vendor-hosts.ts

+ 13 - 9
e2e/specs/payments/checkout.spec.ts

@@ -66,14 +66,18 @@ async function expectWorkOrderPaymentState(
 }
 }
 
 
 /** Starts a payment of `amount` with a vendor, and lands on its checkout page. */
 /** Starts a payment of `amount` with a vendor, and lands on its checkout page. */
-async function startPayment(page: Page, vendor: 'Card' | 'PayPal', amount: string): Promise<void> {
+async function startPayment(
+  page: Page,
+  vendor: 'Stripe' | 'PayPal',
+  amount: string
+): Promise<void> {
   await openInvoice(page)
   await openInvoice(page)
   await expect(async () => {
   await expect(async () => {
     await page.getByRole('button', { name: 'Partial payment', exact: true }).click()
     await page.getByRole('button', { name: 'Partial payment', exact: true }).click()
     await expect(page.locator('#payAmount')).toBeVisible({ timeout: 2_000 })
     await expect(page.locator('#payAmount')).toBeVisible({ timeout: 2_000 })
   }).toPass({ timeout: 30_000 })
   }).toPass({ timeout: 30_000 })
   await page.locator('#payAmount').fill(amount)
   await page.locator('#payAmount').fill(amount)
-  await page.getByRole('button', { name: new RegExp(`with ${vendor}$`) }).click()
+  await page.getByRole('button', { name: new RegExp(`^Pay with ${vendor} `) }).click()
   await expect(page.getByRole('heading', { name: /checkout/ })).toBeVisible({ timeout: 30_000 })
   await expect(page.getByRole('heading', { name: /checkout/ })).toBeVisible({ timeout: 30_000 })
 }
 }
 
 
@@ -159,8 +163,8 @@ test.describe('the invoice a customer is sent', () => {
     await openInvoice(page)
     await openInvoice(page)
     await expect(page.getByText('Balance Due').first()).toBeVisible()
     await expect(page.getByText('Balance Due').first()).toBeVisible()
     await expect(page.getByText(/\$1,?000\.00/).first(), 'the total the job came to').toBeVisible()
     await expect(page.getByText(/\$1,?000\.00/).first(), 'the total the job came to').toBeVisible()
-    await expect(page.getByRole('button', { name: /Pay \$1,?000\.00 with Card/ })).toBeVisible()
-    await expect(page.getByRole('button', { name: /Pay \$1,?000\.00 with PayPal/ })).toBeVisible()
+    await expect(page.getByRole('button', { name: /^Pay with Stripe \$1,?000\.00$/ })).toBeVisible()
+    await expect(page.getByRole('button', { name: /^Pay with PayPal \$1,?000\.00$/ })).toBeVisible()
   })
   })
 
 
   test('refuses to charge more than is owed', async ({ request }) => {
   test('refuses to charge more than is owed', async ({ request }) => {
@@ -178,7 +182,7 @@ test.describe('the invoice a customer is sent', () => {
 
 
 test.describe('paying part of it by card', () => {
 test.describe('paying part of it by card', () => {
   test('charges what the customer chose, for this invoice', async ({ page }) => {
   test('charges what the customer chose, for this invoice', async ({ page }) => {
-    await startPayment(page, 'Card', '400')
+    await startPayment(page, 'Stripe', '400')
 
 
     // The vendor was asked for exactly that, in cents, and told whose invoice
     // The vendor was asked for exactly that, in cents, and told whose invoice
     // it is: the metadata is what the notification is matched on later.
     // it is: the metadata is what the notification is matched on later.
@@ -193,7 +197,7 @@ test.describe('paying part of it by card', () => {
   test('is recorded when the customer comes back, and the invoice says what is left', async ({
   test('is recorded when the customer comes back, and the invoice says what is left', async ({
     page,
     page,
   }) => {
   }) => {
-    await startPayment(page, 'Card', '400')
+    await startPayment(page, 'Stripe', '400')
     await page.getByRole('button', { name: 'Pay', exact: true }).click()
     await page.getByRole('button', { name: 'Pay', exact: true }).click()
 
 
     // Back on the invoice, which checks with Stripe before believing it.
     // Back on the invoice, which checks with Stripe before believing it.
@@ -207,7 +211,7 @@ test.describe('paying part of it by card', () => {
 
 
     // The customer's copy owes the rest, and offers it.
     // The customer's copy owes the rest, and offers it.
     await openInvoice(page)
     await openInvoice(page)
-    await expect(page.getByRole('button', { name: /Pay \$600\.00 with Card/ })).toBeVisible()
+    await expect(page.getByRole('button', { name: /^Pay with Stripe \$600\.00$/ })).toBeVisible()
   })
   })
 
 
   test('is not counted twice when the same payment is reported again', async ({
   test('is not counted twice when the same payment is reported again', async ({
@@ -267,7 +271,7 @@ test.describe('a customer who changes their mind at the vendor', () => {
     // Back on the invoice with the same balance, and no payment on the books.
     // Back on the invoice with the same balance, and no payment on the books.
     await expect(page).toHaveURL(new RegExp(new URL(invoiceUrl).pathname))
     await expect(page).toHaveURL(new RegExp(new URL(invoiceUrl).pathname))
     await settle(page)
     await settle(page)
-    await expect(page.getByRole('button', { name: /Pay \$600\.00 with PayPal/ })).toBeVisible()
+    await expect(page.getByRole('button', { name: /^Pay with PayPal \$600\.00$/ })).toBeVisible()
     expect((await paymentsFor(jobId)).length).toBe(1)
     expect((await paymentsFor(jobId)).length).toBe(1)
   })
   })
 })
 })
@@ -293,7 +297,7 @@ test.describe('paying the rest through PayPal', () => {
 
 
     // Nothing is owed, so the customer is offered nothing to pay.
     // Nothing is owed, so the customer is offered nothing to pay.
     await openInvoice(page)
     await openInvoice(page)
-    await expect(page.getByRole('button', { name: /with Card|with PayPal/ })).toHaveCount(0)
+    await expect(page.getByRole('button', { name: /^Pay with (Stripe|PayPal) / })).toHaveCount(0)
   })
   })
 
 
   test('is not counted twice when PayPal reports it too', async ({ request }) => {
   test('is not counted twice when PayPal reports it too', async ({ request }) => {

+ 1 - 1
e2e/specs/security/payment-attribution.spec.ts

@@ -94,7 +94,7 @@ test.describe('a PayPal order', () => {
       await expect(page.locator('#payAmount')).toBeVisible({ timeout: 2_000 })
       await expect(page.locator('#payAmount')).toBeVisible({ timeout: 2_000 })
     }).toPass({ timeout: 30_000 })
     }).toPass({ timeout: 30_000 })
     await page.locator('#payAmount').fill('100')
     await page.locator('#payAmount').fill('100')
-    await page.getByRole('button', { name: /with PayPal$/ }).click()
+    await page.getByRole('button', { name: /^Pay with PayPal / }).click()
     await expect(page.getByRole('heading', { name: /checkout/ })).toBeVisible({ timeout: 30_000 })
     await expect(page.getByRole('heading', { name: /checkout/ })).toBeVisible({ timeout: 30_000 })
     await page.getByRole('button', { name: 'Pay', exact: true }).click()
     await page.getByRole('button', { name: 'Pay', exact: true }).click()
     await expect(page.getByText('Payment received!')).toBeVisible({ timeout: 30_000 })
     await expect(page.getByText('Payment received!')).toBeVisible({ timeout: 30_000 })

+ 1 - 1
messages/de/auth.json

@@ -131,7 +131,7 @@
       },
       },
       "pay": {
       "pay": {
         "title": "Kunden zahlen per Link",
         "title": "Kunden zahlen per Link",
-        "description": "Rechnung senden und online bezahlt werden, mit Stripe, Vipps oder PayPal. Kein Kundenkonto nötig."
+        "description": "Rechnung senden und online bezahlt werden, mit Stripe, Vipps, PayPal oder Paystack. Kein Kundenkonto nötig."
       }
       }
     },
     },
     "proof": {
     "proof": {

+ 12 - 0
messages/de/integrations.json

@@ -154,6 +154,7 @@
     "inboundHintStripe": "Fügen Sie diese URL im Stripe-Dashboard hinzu: Öffnen Sie Workbench, dann Webhooks, wählen Sie Create an event destination und abonnieren Sie checkout.session.completed. Stripe zeigt danach ein Signaturgeheimnis für den Endpunkt; tragen Sie es hier in das Feld für das Signaturgeheimnis ein, damit jede Benachrichtigung anhand ihrer Signatur geprüft wird. Bis dahin bestätigt Torqvoice jede Benachrichtigung, indem es den Checkout bei Stripe abfragt.",
     "inboundHintStripe": "Fügen Sie diese URL im Stripe-Dashboard hinzu: Öffnen Sie Workbench, dann Webhooks, wählen Sie Create an event destination und abonnieren Sie checkout.session.completed. Stripe zeigt danach ein Signaturgeheimnis für den Endpunkt; tragen Sie es hier in das Feld für das Signaturgeheimnis ein, damit jede Benachrichtigung anhand ihrer Signatur geprüft wird. Bis dahin bestätigt Torqvoice jede Benachrichtigung, indem es den Checkout bei Stripe abfragt.",
     "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.",
     "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.",
     "inboundHintPaypal": "Fügen Sie diese URL im PayPal-Entwicklerdashboard unter Webhooks hinzu, abonniert auf PAYMENT.CAPTURE.COMPLETED.",
+    "inboundHintPaystack": "Fügen Sie diese URL im Paystack-Dashboard unter Settings, API Keys & Webhooks als Webhook URL ein. Paystack führt eine für den Testmodus und eine für den Live-Modus, tragen Sie sie also in dem Modus ein, zu dem Ihr Schlüssel gehört.",
     "copy": "Kopieren",
     "copy": "Kopieren",
     "copied": "Kopiert",
     "copied": "Kopiert",
     "updateKeys": "Schlüssel aktualisieren",
     "updateKeys": "Schlüssel aktualisieren",
@@ -704,6 +705,17 @@
         "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."
         "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."
       }
       }
     },
     },
+    "paystack": {
+      "description": "Paystack-Checkout über den Rechnungslink, auf Ihr eigenes Paystack-Konto. Der Kunde zahlt je nach Land mit Karte, Banküberweisung oder Mobile Money, und die Rechnung wird von selbst als bezahlt markiert.",
+      "fields": {
+        "secretKey": "Geheimer Schlüssel",
+        "secretKeyHelp": "Im Paystack-Dashboard unter Settings, API Keys & Webhooks. Ein Testschlüssel (sk_test_) verarbeitet nichts Echtes. Probieren Sie damit zuerst eine Zahlung aus und wechseln Sie danach zum Live-Schlüssel."
+      },
+      "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."
+      }
+    },
     "quickbooks": {
     "quickbooks": {
       "description": "Sende ausgestellte Rechnungen, ihre Kunden und ihre Zahlungen an QuickBooks Online und hole dort erfasste Zahlungen zurück.",
       "description": "Sende ausgestellte Rechnungen, ihre Kunden und ihre Zahlungen an QuickBooks Online und hole dort erfasste Zahlungen zurück.",
       "tenantHelp": "Erstelle eine App im Intuit Developer Portal. Kopiere Client-ID und Secret unter Keys and credentials und trage die Redirect-URI unten unter Settings, Redirect URIs ein. Entwicklungsschlüssel erreichen Sandbox-Firmen, Produktionsschlüssel echte.",
       "tenantHelp": "Erstelle eine App im Intuit Developer Portal. Kopiere Client-ID und Secret unter Keys and credentials und trage die Redirect-URI unten unter Settings, Redirect URIs ein. Entwicklungsschlüssel erreichen Sandbox-Firmen, Produktionsschlüssel echte.",

+ 1 - 1
messages/de/settings.json

@@ -1021,7 +1021,7 @@
     "paymentTermsPlaceholder": "Netto 14 Tage",
     "paymentTermsPlaceholder": "Netto 14 Tage",
     "paymentTermsHint": "Zahlungsbedingungen auf Rechnungen (z.B. \"Netto 14 Tage\", \"Zahlbar bei Erhalt\")",
     "paymentTermsHint": "Zahlungsbedingungen auf Rechnungen (z.B. \"Netto 14 Tage\", \"Zahlbar bei Erhalt\")",
     "onlinePayments": "Online-Zahlungen",
     "onlinePayments": "Online-Zahlungen",
-    "onlinePaymentsDescription": "Kunden bezahlen Rechnungen über den Link, den Sie ihnen schicken. Stripe, Vipps und PayPal werden unter Integrationen verbunden und können nebeneinander laufen.",
+    "onlinePaymentsDescription": "Kunden bezahlen Rechnungen über den Link, den Sie ihnen schicken. Stripe, Vipps, PayPal und Paystack 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.",
     "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",
     "offered": "Auf Rechnungslinks",
     "paused": "Verbunden, nicht angeboten",
     "paused": "Verbunden, nicht angeboten",

+ 2 - 0
messages/de/share.json

@@ -12,6 +12,8 @@
     "payWithCard": "{amount} mit Karte bezahlen",
     "payWithCard": "{amount} mit Karte bezahlen",
     "payWithVipps": "{amount} mit Vipps bezahlen",
     "payWithVipps": "{amount} mit Vipps bezahlen",
     "payWithPaypal": "{amount} mit PayPal bezahlen",
     "payWithPaypal": "{amount} mit PayPal bezahlen",
+    "payWithPaystack": "{amount} mit Paystack bezahlen",
+    "payWithVendor": "Bezahlen mit <vendor></vendor>",
     "errorInvalidAmount": "Bitte geben Sie einen gültigen Betrag ein.",
     "errorInvalidAmount": "Bitte geben Sie einen gültigen Betrag ein.",
     "errorExceedsBalance": "Betrag darf {amount} nicht überschreiten.",
     "errorExceedsBalance": "Betrag darf {amount} nicht überschreiten.",
     "errorVerifyFailed": "Zahlung konnte nicht verifiziert werden. Sie wird möglicherweise noch verarbeitet.",
     "errorVerifyFailed": "Zahlung konnte nicht verifiziert werden. Sie wird möglicherweise noch verarbeitet.",

+ 1 - 1
messages/en/auth.json

@@ -131,7 +131,7 @@
       },
       },
       "pay": {
       "pay": {
         "title": "Customers pay from a link",
         "title": "Customers pay from a link",
-        "description": "Send the invoice and get paid online with Stripe, Vipps or PayPal. No customer account needed."
+        "description": "Send the invoice and get paid online with Stripe, Vipps, PayPal or Paystack. No customer account needed."
       }
       }
     },
     },
     "proof": {
     "proof": {

+ 12 - 0
messages/en/integrations.json

@@ -154,6 +154,7 @@
     "inboundHintStripe": "Add this URL in the Stripe dashboard: open Workbench, then Webhooks, choose Create an event destination, and subscribe it to checkout.session.completed. Stripe then shows a signing secret for the endpoint; paste it into the signing secret field here so every notification is checked by its signature. Until it is in, Torqvoice confirms each notification by asking Stripe for the checkout itself.",
     "inboundHintStripe": "Add this URL in the Stripe dashboard: open Workbench, then Webhooks, choose Create an event destination, and subscribe it to checkout.session.completed. Stripe then shows a signing secret for the endpoint; paste it into the signing secret field here so every notification is checked by its signature. Until it is in, Torqvoice confirms each notification by asking Stripe for the checkout itself.",
     "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.",
     "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.",
     "inboundHintPaypal": "Add this URL in the PayPal developer dashboard under Webhooks, subscribed to PAYMENT.CAPTURE.COMPLETED.",
+    "inboundHintPaystack": "Paste this URL as the Webhook URL in the Paystack dashboard under Settings, API Keys & Webhooks. Paystack keeps one for test mode and one for live mode, so set it in the mode your key belongs to.",
     "copy": "Copy",
     "copy": "Copy",
     "copied": "Copied",
     "copied": "Copied",
     "updateKeys": "Update keys",
     "updateKeys": "Update keys",
@@ -704,6 +705,17 @@
         "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."
         "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."
       }
       }
     },
     },
+    "paystack": {
+      "description": "Paystack checkout from the invoice link, into your own Paystack account. The customer pays by card, bank transfer or mobile money, depending on the country, and the invoice marks itself paid.",
+      "fields": {
+        "secretKey": "Secret key",
+        "secretKeyHelp": "From the Paystack dashboard under Settings, API Keys & Webhooks. A test key (sk_test_) processes nothing real, so try a payment on it first and swap to the live key after."
+      },
+      "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."
+      }
+    },
     "quickbooks": {
     "quickbooks": {
       "description": "Send issued invoices, their customers and their payments to QuickBooks Online, and bring back payments recorded there.",
       "description": "Send issued invoices, their customers and their payments to QuickBooks Online, and bring back payments recorded there.",
       "tenantHelp": "Create an app in the Intuit Developer portal. Copy the client id and secret from Keys and credentials, and enter the redirect URI below under Settings, Redirect URIs. Development keys reach sandbox companies, production keys live ones.",
       "tenantHelp": "Create an app in the Intuit Developer portal. Copy the client id and secret from Keys and credentials, and enter the redirect URI below under Settings, Redirect URIs. Development keys reach sandbox companies, production keys live ones.",

+ 1 - 1
messages/en/settings.json

@@ -1021,7 +1021,7 @@
     "paymentTermsPlaceholder": "Net 14 days",
     "paymentTermsPlaceholder": "Net 14 days",
     "paymentTermsHint": "Payment terms text displayed on invoices (e.g. \"Net 14 days\", \"Due on receipt\")",
     "paymentTermsHint": "Payment terms text displayed on invoices (e.g. \"Net 14 days\", \"Due on receipt\")",
     "onlinePayments": "Online Payments",
     "onlinePayments": "Online Payments",
-    "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.",
+    "onlinePaymentsDescription": "Let customers pay invoices from the link you send them. Stripe, Vipps, PayPal and Paystack 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.",
     "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",
     "offered": "On invoice links",
     "paused": "Connected, not offered",
     "paused": "Connected, not offered",

+ 2 - 0
messages/en/share.json

@@ -12,6 +12,8 @@
     "payWithCard": "Pay {amount} with Card",
     "payWithCard": "Pay {amount} with Card",
     "payWithVipps": "Pay {amount} with Vipps",
     "payWithVipps": "Pay {amount} with Vipps",
     "payWithPaypal": "Pay {amount} with PayPal",
     "payWithPaypal": "Pay {amount} with PayPal",
+    "payWithPaystack": "Pay {amount} with Paystack",
+    "payWithVendor": "Pay with <vendor></vendor>",
     "errorInvalidAmount": "Please enter a valid amount.",
     "errorInvalidAmount": "Please enter a valid amount.",
     "errorExceedsBalance": "Amount cannot exceed {amount}.",
     "errorExceedsBalance": "Amount cannot exceed {amount}.",
     "errorVerifyFailed": "Payment could not be verified. It may still be processing.",
     "errorVerifyFailed": "Payment could not be verified. It may still be processing.",

+ 1 - 1
messages/es/auth.json

@@ -131,7 +131,7 @@
       },
       },
       "pay": {
       "pay": {
         "title": "Los clientes pagan desde un enlace",
         "title": "Los clientes pagan desde un enlace",
-        "description": "Envíe la factura y cobre en línea con Stripe, Vipps o PayPal. Sin cuenta de cliente."
+        "description": "Envíe la factura y cobre en línea con Stripe, Vipps, PayPal o Paystack. Sin cuenta de cliente."
       }
       }
     },
     },
     "proof": {
     "proof": {

+ 12 - 0
messages/es/integrations.json

@@ -154,6 +154,7 @@
     "inboundHintStripe": "Añade esta URL en el panel de Stripe: abre Workbench, luego Webhooks, elige Create an event destination y suscríbela a checkout.session.completed. Stripe mostrará entonces un secreto de firma para el endpoint; pégalo aquí en el campo del secreto de firma para que cada notificación se compruebe por su firma. Hasta entonces, Torqvoice confirma cada notificación consultando el checkout a Stripe.",
     "inboundHintStripe": "Añade esta URL en el panel de Stripe: abre Workbench, luego Webhooks, elige Create an event destination y suscríbela a checkout.session.completed. Stripe mostrará entonces un secreto de firma para el endpoint; pégalo aquí en el campo del secreto de firma para que cada notificación se compruebe por su firma. Hasta entonces, Torqvoice confirma cada notificación consultando el checkout a Stripe.",
     "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.",
     "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.",
     "inboundHintPaypal": "Añade esta URL en el panel de desarrollador de PayPal en Webhooks, suscrita a PAYMENT.CAPTURE.COMPLETED.",
+    "inboundHintPaystack": "Pega esta URL como Webhook URL en el panel de Paystack, en Settings, API Keys & Webhooks. Paystack guarda una para el modo de prueba y otra para el modo real, así que configúrala en el modo al que pertenece tu clave.",
     "copy": "Copiar",
     "copy": "Copiar",
     "copied": "Copiado",
     "copied": "Copiado",
     "updateKeys": "Actualizar claves",
     "updateKeys": "Actualizar claves",
@@ -704,6 +705,17 @@
         "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."
         "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."
       }
       }
     },
     },
+    "paystack": {
+      "description": "Pago con Paystack desde el enlace de la factura, a tu propia cuenta de Paystack. El cliente paga con tarjeta, transferencia bancaria o dinero móvil, según el país, y la factura se marca como pagada por sí sola.",
+      "fields": {
+        "secretKey": "Clave secreta",
+        "secretKeyHelp": "En el panel de Paystack, en Settings, API Keys & Webhooks. Una clave de prueba (sk_test_) no procesa nada real, así que prueba primero un pago con ella y cambia después a la clave real."
+      },
+      "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."
+      }
+    },
     "quickbooks": {
     "quickbooks": {
       "description": "Envía las facturas emitidas, sus clientes y sus pagos a QuickBooks Online y trae de vuelta los pagos registrados allí.",
       "description": "Envía las facturas emitidas, sus clientes y sus pagos a QuickBooks Online y trae de vuelta los pagos registrados allí.",
       "tenantHelp": "Crea una app en el portal de desarrolladores de Intuit. Copia el client id y el secret de Keys and credentials e introduce la URI de redirección de abajo en Settings, Redirect URIs. Las claves de desarrollo llegan a empresas sandbox, las de producción a empresas reales.",
       "tenantHelp": "Crea una app en el portal de desarrolladores de Intuit. Copia el client id y el secret de Keys and credentials e introduce la URI de redirección de abajo en Settings, Redirect URIs. Las claves de desarrollo llegan a empresas sandbox, las de producción a empresas reales.",

+ 1 - 1
messages/es/settings.json

@@ -1021,7 +1021,7 @@
     "paymentTermsPlaceholder": "Neto 14 días",
     "paymentTermsPlaceholder": "Neto 14 días",
     "paymentTermsHint": "Texto de condiciones de pago en las facturas (ej. \"Neto 14 días\", \"Pago al recibir\")",
     "paymentTermsHint": "Texto de condiciones de pago en las facturas (ej. \"Neto 14 días\", \"Pago al recibir\")",
     "onlinePayments": "Pagos en línea",
     "onlinePayments": "Pagos en línea",
-    "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.",
+    "onlinePaymentsDescription": "Deja que los clientes paguen facturas desde el enlace que les envías. Stripe, Vipps, PayPal y Paystack 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.",
     "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",
     "offered": "En enlaces de factura",
     "paused": "Conectado, no ofrecido",
     "paused": "Conectado, no ofrecido",

+ 2 - 0
messages/es/share.json

@@ -12,6 +12,8 @@
     "payWithCard": "Pagar {amount} con tarjeta",
     "payWithCard": "Pagar {amount} con tarjeta",
     "payWithVipps": "Pagar {amount} con Vipps",
     "payWithVipps": "Pagar {amount} con Vipps",
     "payWithPaypal": "Pagar {amount} con PayPal",
     "payWithPaypal": "Pagar {amount} con PayPal",
+    "payWithPaystack": "Pagar {amount} con Paystack",
+    "payWithVendor": "Pagar con <vendor></vendor>",
     "errorInvalidAmount": "Por favor ingrese un monto válido.",
     "errorInvalidAmount": "Por favor ingrese un monto válido.",
     "errorExceedsBalance": "El monto no puede superar {amount}.",
     "errorExceedsBalance": "El monto no puede superar {amount}.",
     "errorVerifyFailed": "No se pudo verificar el pago. Es posible que aún esté procesándose.",
     "errorVerifyFailed": "No se pudo verificar el pago. Es posible que aún esté procesándose.",

+ 1 - 1
messages/fr/auth.json

@@ -131,7 +131,7 @@
       },
       },
       "pay": {
       "pay": {
         "title": "Les clients paient depuis un lien",
         "title": "Les clients paient depuis un lien",
-        "description": "Envoyez la facture et encaissez en ligne avec Stripe, Vipps ou PayPal. Aucun compte client requis."
+        "description": "Envoyez la facture et encaissez en ligne avec Stripe, Vipps, PayPal ou Paystack. Aucun compte client requis."
       }
       }
     },
     },
     "proof": {
     "proof": {

+ 12 - 0
messages/fr/integrations.json

@@ -154,6 +154,7 @@
     "inboundHintStripe": "Ajoutez cette URL dans le tableau de bord Stripe : ouvrez Workbench, puis Webhooks, choisissez Create an event destination et abonnez-la à checkout.session.completed. Stripe affiche ensuite un secret de signature pour ce point de terminaison ; collez-le ici dans le champ du secret de signature pour que chaque notification soit vérifiée par sa signature. En attendant, Torqvoice confirme chaque notification en demandant le checkout à Stripe.",
     "inboundHintStripe": "Ajoutez cette URL dans le tableau de bord Stripe : ouvrez Workbench, puis Webhooks, choisissez Create an event destination et abonnez-la à checkout.session.completed. Stripe affiche ensuite un secret de signature pour ce point de terminaison ; collez-le ici dans le champ du secret de signature pour que chaque notification soit vérifiée par sa signature. En attendant, Torqvoice confirme chaque notification en demandant le checkout à Stripe.",
     "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.",
     "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.",
     "inboundHintPaypal": "Ajoutez cette URL dans le tableau de bord développeur PayPal sous Webhooks, abonnée à PAYMENT.CAPTURE.COMPLETED.",
+    "inboundHintPaystack": "Collez cette URL comme Webhook URL dans le tableau de bord Paystack, sous Settings, API Keys & Webhooks. Paystack en garde une pour le mode test et une pour le mode production : renseignez-la dans le mode auquel appartient votre clé.",
     "copy": "Copier",
     "copy": "Copier",
     "copied": "Copié",
     "copied": "Copié",
     "updateKeys": "Mettre à jour les clés",
     "updateKeys": "Mettre à jour les clés",
@@ -704,6 +705,17 @@
         "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."
         "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."
       }
       }
     },
     },
+    "paystack": {
+      "description": "Paiement Paystack depuis le lien de la facture, sur votre propre compte Paystack. Le client paie par carte, virement bancaire ou mobile money, selon le pays, et la facture se marque payée d'elle-même.",
+      "fields": {
+        "secretKey": "Clé secrète",
+        "secretKeyHelp": "Dans le tableau de bord Paystack, sous Settings, API Keys & Webhooks. Une clé de test (sk_test_) ne traite rien de réel : essayez d'abord un paiement avec elle, puis passez à la clé de production."
+      },
+      "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."
+      }
+    },
     "quickbooks": {
     "quickbooks": {
       "description": "Envoyez les factures émises, leurs clients et leurs paiements vers QuickBooks Online, et récupérez les paiements enregistrés là-bas.",
       "description": "Envoyez les factures émises, leurs clients et leurs paiements vers QuickBooks Online, et récupérez les paiements enregistrés là-bas.",
       "tenantHelp": "Créez une application sur le portail développeur Intuit. Copiez le client id et le secret depuis Keys and credentials, et saisissez l'URI de redirection ci-dessous sous Settings, Redirect URIs. Les clés de développement atteignent les entreprises sandbox, les clés de production les vraies.",
       "tenantHelp": "Créez une application sur le portail développeur Intuit. Copiez le client id et le secret depuis Keys and credentials, et saisissez l'URI de redirection ci-dessous sous Settings, Redirect URIs. Les clés de développement atteignent les entreprises sandbox, les clés de production les vraies.",

+ 1 - 1
messages/fr/settings.json

@@ -1021,7 +1021,7 @@
     "paymentTermsPlaceholder": "Net 14 jours",
     "paymentTermsPlaceholder": "Net 14 jours",
     "paymentTermsHint": "Texte des conditions de paiement sur les factures (ex. \"Net 14 jours\", \"A réception\")",
     "paymentTermsHint": "Texte des conditions de paiement sur les factures (ex. \"Net 14 jours\", \"A réception\")",
     "onlinePayments": "Paiements en ligne",
     "onlinePayments": "Paiements en ligne",
-    "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.",
+    "onlinePaymentsDescription": "Laissez les clients payer les factures depuis le lien que vous leur envoyez. Stripe, Vipps, PayPal et Paystack 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.",
     "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",
     "offered": "Sur les liens de facture",
     "paused": "Connecté, non proposé",
     "paused": "Connecté, non proposé",

+ 2 - 0
messages/fr/share.json

@@ -12,6 +12,8 @@
     "payWithCard": "Payer {amount} par carte",
     "payWithCard": "Payer {amount} par carte",
     "payWithVipps": "Payer {amount} avec Vipps",
     "payWithVipps": "Payer {amount} avec Vipps",
     "payWithPaypal": "Payer {amount} avec PayPal",
     "payWithPaypal": "Payer {amount} avec PayPal",
+    "payWithPaystack": "Payer {amount} avec Paystack",
+    "payWithVendor": "Payer avec <vendor></vendor>",
     "errorInvalidAmount": "Veuillez saisir un montant valide.",
     "errorInvalidAmount": "Veuillez saisir un montant valide.",
     "errorExceedsBalance": "Le montant ne peut pas dépasser {amount}.",
     "errorExceedsBalance": "Le montant ne peut pas dépasser {amount}.",
     "errorVerifyFailed": "Le paiement n'a pas pu être vérifié. Il est peut-être encore en cours de traitement.",
     "errorVerifyFailed": "Le paiement n'a pas pu être vérifié. Il est peut-être encore en cours de traitement.",

+ 1 - 1
messages/it/auth.json

@@ -131,7 +131,7 @@
       },
       },
       "pay": {
       "pay": {
         "title": "I clienti pagano da un link",
         "title": "I clienti pagano da un link",
-        "description": "Invii la fattura e incassi online con Stripe, Vipps o PayPal. Nessun account cliente necessario."
+        "description": "Invii la fattura e incassi online con Stripe, Vipps, PayPal o Paystack. Nessun account cliente necessario."
       }
       }
     },
     },
     "proof": {
     "proof": {

+ 12 - 0
messages/it/integrations.json

@@ -154,6 +154,7 @@
     "inboundHintStripe": "Aggiungi questo URL nella dashboard di Stripe: apri Workbench, poi Webhooks, scegli Create an event destination e iscrivilo a checkout.session.completed. Stripe mostra quindi un segreto di firma per l'endpoint; incollalo qui nel campo del segreto di firma, così ogni notifica viene controllata tramite la sua firma. Fino ad allora, Torqvoice conferma ogni notifica chiedendo il checkout a Stripe.",
     "inboundHintStripe": "Aggiungi questo URL nella dashboard di Stripe: apri Workbench, poi Webhooks, scegli Create an event destination e iscrivilo a checkout.session.completed. Stripe mostra quindi un segreto di firma per l'endpoint; incollalo qui nel campo del segreto di firma, così ogni notifica viene controllata tramite la sua firma. Fino ad allora, Torqvoice conferma ogni notifica chiedendo il checkout a Stripe.",
     "inboundHintVipps": "Inserisci questo URL come URL di callback nel portale Vipps MobilePay. Vipps richiede anche condizioni di vendita pubblicate, impostate in Impostazioni, Pagamento.",
     "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.",
     "inboundHintPaypal": "Aggiungi questo URL nella dashboard sviluppatori di PayPal sotto Webhooks, iscritto a PAYMENT.CAPTURE.COMPLETED.",
+    "inboundHintPaystack": "Incolla questo URL come Webhook URL nella dashboard di Paystack, in Settings, API Keys & Webhooks. Paystack ne tiene uno per la modalità test e uno per la modalità live, quindi impostalo nella modalità a cui appartiene la tua chiave.",
     "copy": "Copia",
     "copy": "Copia",
     "copied": "Copiato",
     "copied": "Copiato",
     "updateKeys": "Aggiorna le chiavi",
     "updateKeys": "Aggiorna le chiavi",
@@ -704,6 +705,17 @@
         "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."
         "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."
       }
       }
     },
     },
+    "paystack": {
+      "description": "Pagamento Paystack dal link della fattura, sul tuo account Paystack. Il cliente paga con carta, bonifico bancario o mobile money, a seconda del paese, e la fattura si segna come pagata da sola.",
+      "fields": {
+        "secretKey": "Chiave segreta",
+        "secretKeyHelp": "Nella dashboard di Paystack, in Settings, API Keys & Webhooks. Una chiave di test (sk_test_) non elabora nulla di reale, quindi prova prima un pagamento con quella e passa poi alla chiave live."
+      },
+      "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."
+      }
+    },
     "quickbooks": {
     "quickbooks": {
       "description": "Invia le fatture emesse, i loro clienti e i loro pagamenti a QuickBooks Online e riporta i pagamenti registrati lì.",
       "description": "Invia le fatture emesse, i loro clienti e i loro pagamenti a QuickBooks Online e riporta i pagamenti registrati lì.",
       "tenantHelp": "Crea un'app nel portale sviluppatori Intuit. Copia client id e secret da Keys and credentials e inserisci l'URI di reindirizzamento qui sotto in Settings, Redirect URIs. Le chiavi di sviluppo raggiungono le aziende sandbox, quelle di produzione le aziende reali.",
       "tenantHelp": "Crea un'app nel portale sviluppatori Intuit. Copia client id e secret da Keys and credentials e inserisci l'URI di reindirizzamento qui sotto in Settings, Redirect URIs. Le chiavi di sviluppo raggiungono le aziende sandbox, quelle di produzione le aziende reali.",

+ 1 - 1
messages/it/settings.json

@@ -1021,7 +1021,7 @@
     "paymentTermsPlaceholder": "A 14 giorni",
     "paymentTermsPlaceholder": "A 14 giorni",
     "paymentTermsHint": "Testo dei termini di pagamento mostrato sulle fatture (es. \"A 14 giorni\", \"Alla consegna\")",
     "paymentTermsHint": "Testo dei termini di pagamento mostrato sulle fatture (es. \"A 14 giorni\", \"Alla consegna\")",
     "onlinePayments": "Pagamenti online",
     "onlinePayments": "Pagamenti online",
-    "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.",
+    "onlinePaymentsDescription": "Lascia che i clienti paghino le fatture dal link che invii loro. Stripe, Vipps, PayPal e Paystack 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.",
     "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",
     "offered": "Nei link delle fatture",
     "paused": "Collegato, non offerto",
     "paused": "Collegato, non offerto",

+ 2 - 0
messages/it/share.json

@@ -12,6 +12,8 @@
     "payWithCard": "Paga {amount} con Carta",
     "payWithCard": "Paga {amount} con Carta",
     "payWithVipps": "Paga {amount} con Vipps",
     "payWithVipps": "Paga {amount} con Vipps",
     "payWithPaypal": "Paga {amount} con PayPal",
     "payWithPaypal": "Paga {amount} con PayPal",
+    "payWithPaystack": "Paga {amount} con Paystack",
+    "payWithVendor": "Paga con <vendor></vendor>",
     "errorInvalidAmount": "Inserisci un importo valido.",
     "errorInvalidAmount": "Inserisci un importo valido.",
     "errorExceedsBalance": "L'importo non può superare {amount}.",
     "errorExceedsBalance": "L'importo non può superare {amount}.",
     "errorVerifyFailed": "Impossibile verificare il pagamento. Potrebbe essere ancora in elaborazione.",
     "errorVerifyFailed": "Impossibile verificare il pagamento. Potrebbe essere ancora in elaborazione.",

+ 1 - 1
messages/lt/auth.json

@@ -131,7 +131,7 @@
       },
       },
       "pay": {
       "pay": {
         "title": "Klientai moka per nuorodą",
         "title": "Klientai moka per nuorodą",
-        "description": "Išsiųskite sąskaitą ir gaukite apmokėjimą internetu per „Stripe“, „Vipps“ ar „PayPal“. Kliento paskyros nereikia."
+        "description": "Išsiųskite sąskaitą ir gaukite apmokėjimą internetu per „Stripe“, „Vipps“, „PayPal“ ar „Paystack“. Kliento paskyros nereikia."
       }
       }
     },
     },
     "proof": {
     "proof": {

+ 12 - 0
messages/lt/integrations.json

@@ -154,6 +154,7 @@
     "inboundHintStripe": "Pridėkite šį URL Stripe valdymo skydelyje: atidarykite Workbench, tada Webhooks, pasirinkite Create an event destination ir prenumeruokite checkout.session.completed. Tada Stripe parodys šio galinio taško parašo paslaptį; įklijuokite ją čia į parašo paslapties lauką, kad kiekvienas pranešimas būtų tikrinamas pagal parašą. Iki tol Torqvoice patvirtina kiekvieną pranešimą užklausdamas Stripe apie patį atsiskaitymą.",
     "inboundHintStripe": "Pridėkite šį URL Stripe valdymo skydelyje: atidarykite Workbench, tada Webhooks, pasirinkite Create an event destination ir prenumeruokite checkout.session.completed. Tada Stripe parodys šio galinio taško parašo paslaptį; įklijuokite ją čia į parašo paslapties lauką, kad kiekvienas pranešimas būtų tikrinamas pagal parašą. Iki tol Torqvoice patvirtina kiekvieną pranešimą užklausdamas Stripe apie patį atsiskaitymą.",
     "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.",
     "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.",
     "inboundHintPaypal": "Pridėkite šį URL PayPal kūrėjų skydelyje, skiltyje Webhooks, prenumeruodami PAYMENT.CAPTURE.COMPLETED.",
+    "inboundHintPaystack": "Įklijuokite šį URL kaip Webhook URL Paystack skydelyje, skiltyje Settings, API Keys & Webhooks. Paystack saugo vieną bandomajam režimui ir vieną tikrajam, todėl nustatykite jį tame režime, kuriam priklauso jūsų raktas.",
     "copy": "Kopijuoti",
     "copy": "Kopijuoti",
     "copied": "Nukopijuota",
     "copied": "Nukopijuota",
     "updateKeys": "Atnaujinti raktus",
     "updateKeys": "Atnaujinti raktus",
@@ -704,6 +705,17 @@
         "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."
         "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."
       }
       }
     },
     },
+    "paystack": {
+      "description": "Paystack apmokėjimas iš sąskaitos nuorodos į jūsų pačių Paystack paskyrą. Klientas moka kortele, banko pavedimu arba mobiliaisiais pinigais, priklausomai nuo šalies, o sąskaita pati pažymima kaip apmokėta.",
+      "fields": {
+        "secretKey": "Slaptasis raktas",
+        "secretKeyHelp": "Paystack skydelyje, skiltyje Settings, API Keys & Webhooks. Bandomasis raktas (sk_test_) neapdoroja nieko tikro, todėl pirmiausia išbandykite mokėjimą su juo, o tada pakeiskite į tikrąjį raktą."
+      },
+      "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."
+      }
+    },
     "quickbooks": {
     "quickbooks": {
       "description": "Siųskite išrašytas sąskaitas, jų klientus ir mokėjimus į QuickBooks Online ir parsisiųskite ten užregistruotus mokėjimus.",
       "description": "Siųskite išrašytas sąskaitas, jų klientus ir mokėjimus į QuickBooks Online ir parsisiųskite ten užregistruotus mokėjimus.",
       "tenantHelp": "Sukurkite programą Intuit kūrėjų portale. Nukopijuokite client id ir secret iš Keys and credentials, o žemiau esantį peradresavimo URI įveskite skiltyje Settings, Redirect URIs. Kūrimo raktai pasiekia sandbox įmones, gamybos raktai tikras.",
       "tenantHelp": "Sukurkite programą Intuit kūrėjų portale. Nukopijuokite client id ir secret iš Keys and credentials, o žemiau esantį peradresavimo URI įveskite skiltyje Settings, Redirect URIs. Kūrimo raktai pasiekia sandbox įmones, gamybos raktai tikras.",

+ 1 - 1
messages/lt/settings.json

@@ -1021,7 +1021,7 @@
     "paymentTermsPlaceholder": "Per 14 dienų",
     "paymentTermsPlaceholder": "Per 14 dienų",
     "paymentTermsHint": "Mokėjimo sąlygų tekstas, rodomas sąskaitose (pvz., \"Per 14 dienų\", \"Gavus sąskaitą\")",
     "paymentTermsHint": "Mokėjimo sąlygų tekstas, rodomas sąskaitose (pvz., \"Per 14 dienų\", \"Gavus sąskaitą\")",
     "onlinePayments": "Mokėjimai internetu",
     "onlinePayments": "Mokėjimai internetu",
-    "onlinePaymentsDescription": "Leiskite klientams apmokėti sąskaitas iš nuorodos, kurią jiems siunčiate. Stripe, Vipps ir PayPal prijungiami skiltyje Integracijos ir gali veikti kartu.",
+    "onlinePaymentsDescription": "Leiskite klientams apmokėti sąskaitas iš nuorodos, kurią jiems siunčiate. Stripe, Vipps, PayPal ir Paystack 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.",
     "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",
     "offered": "Sąskaitų nuorodose",
     "paused": "Prijungta, nesiūloma",
     "paused": "Prijungta, nesiūloma",

+ 2 - 0
messages/lt/share.json

@@ -12,6 +12,8 @@
     "payWithCard": "Mokėti {amount} kortele",
     "payWithCard": "Mokėti {amount} kortele",
     "payWithVipps": "Mokėti {amount} su Vipps",
     "payWithVipps": "Mokėti {amount} su Vipps",
     "payWithPaypal": "Mokėti {amount} su PayPal",
     "payWithPaypal": "Mokėti {amount} su PayPal",
+    "payWithPaystack": "Mokėti {amount} su Paystack",
+    "payWithVendor": "Mokėti su <vendor></vendor>",
     "errorInvalidAmount": "Įveskite teisingą sumą.",
     "errorInvalidAmount": "Įveskite teisingą sumą.",
     "errorExceedsBalance": "Suma negali viršyti {amount}.",
     "errorExceedsBalance": "Suma negali viršyti {amount}.",
     "errorVerifyFailed": "Mokėjimo nepavyko patikrinti. Jis vis dar gali būti apdorojamas.",
     "errorVerifyFailed": "Mokėjimo nepavyko patikrinti. Jis vis dar gali būti apdorojamas.",

+ 1 - 1
messages/nb/auth.json

@@ -131,7 +131,7 @@
       },
       },
       "pay": {
       "pay": {
         "title": "Kundene betaler fra en lenke",
         "title": "Kundene betaler fra en lenke",
-        "description": "Send fakturaen og få betalt på nett med Stripe, Vipps eller PayPal. Ingen kundekonto nødvendig."
+        "description": "Send fakturaen og få betalt på nett med Stripe, Vipps, PayPal eller Paystack. Ingen kundekonto nødvendig."
       }
       }
     },
     },
     "proof": {
     "proof": {

+ 12 - 0
messages/nb/integrations.json

@@ -154,6 +154,7 @@
     "inboundHintStripe": "Legg inn denne URL-en i Stripe-dashbordet: åpne Workbench, deretter Webhooks, velg Create an event destination og abonner på checkout.session.completed. Stripe viser så en signeringshemmelighet for endepunktet; lim den inn i feltet for signeringshemmelighet her, slik at hvert varsel kontrolleres mot signaturen sin. Inntil da bekrefter Torqvoice hvert varsel ved å spørre Stripe om selve betalingen.",
     "inboundHintStripe": "Legg inn denne URL-en i Stripe-dashbordet: åpne Workbench, deretter Webhooks, velg Create an event destination og abonner på checkout.session.completed. Stripe viser så en signeringshemmelighet for endepunktet; lim den inn i feltet for signeringshemmelighet her, slik at hvert varsel kontrolleres mot signaturen sin. Inntil da bekrefter Torqvoice hvert varsel ved å spørre Stripe om selve betalingen.",
     "inboundHintVipps": "Legg inn denne URL-en som callback-URL i Vipps MobilePay-portalen. Vipps krever også publiserte salgsvilkår, som settes under Innstillinger, Betaling.",
     "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.",
     "inboundHintPaypal": "Legg inn denne URL-en i PayPal-utviklerdashbordet under Webhooks, abonnert på PAYMENT.CAPTURE.COMPLETED.",
+    "inboundHintPaystack": "Lim inn denne URL-en som Webhook URL i Paystack-dashbordet under Settings, API Keys & Webhooks. Paystack har én for testmodus og én for live-modus, så legg den inn i den modusen nøkkelen din hører til.",
     "copy": "Kopier",
     "copy": "Kopier",
     "copied": "Kopiert",
     "copied": "Kopiert",
     "updateKeys": "Oppdater nøklene",
     "updateKeys": "Oppdater nøklene",
@@ -704,6 +705,17 @@
         "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."
         "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."
       }
       }
     },
     },
+    "paystack": {
+      "description": "Paystack-betaling fra fakturalenken, til din egen Paystack-konto. Kunden betaler med kort, bankoverføring eller mobilpenger, avhengig av land, og fakturaen merkes som betalt av seg selv.",
+      "fields": {
+        "secretKey": "Hemmelig nøkkel",
+        "secretKeyHelp": "Fra Paystack-dashbordet under Settings, API Keys & Webhooks. En testnøkkel (sk_test_) behandler ingenting ekte, så prøv en betaling med den først og bytt til live-nøkkelen etterpå."
+      },
+      "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."
+      }
+    },
     "quickbooks": {
     "quickbooks": {
       "description": "Send utstedte fakturaer, kundene deres og betalingene deres til QuickBooks Online, og hent tilbake betalinger registrert der.",
       "description": "Send utstedte fakturaer, kundene deres og betalingene deres til QuickBooks Online, og hent tilbake betalinger registrert der.",
       "tenantHelp": "Opprett en app i Intuit Developer-portalen. Kopier klient-ID og hemmelighet fra Keys and credentials, og skriv inn omdirigerings-URI-en nedenfor under Settings, Redirect URIs. Utviklingsnøkler når sandkasse-selskaper, produksjonsnøkler ekte.",
       "tenantHelp": "Opprett en app i Intuit Developer-portalen. Kopier klient-ID og hemmelighet fra Keys and credentials, og skriv inn omdirigerings-URI-en nedenfor under Settings, Redirect URIs. Utviklingsnøkler når sandkasse-selskaper, produksjonsnøkler ekte.",

+ 1 - 1
messages/nb/settings.json

@@ -1021,7 +1021,7 @@
     "paymentTermsPlaceholder": "Netto 14 dager",
     "paymentTermsPlaceholder": "Netto 14 dager",
     "paymentTermsHint": "Betalingsvilkår vist på fakturaer (f.eks. \"Netto 14 dager\", \"Ved mottak\")",
     "paymentTermsHint": "Betalingsvilkår vist på fakturaer (f.eks. \"Netto 14 dager\", \"Ved mottak\")",
     "onlinePayments": "Nettbetalinger",
     "onlinePayments": "Nettbetalinger",
-    "onlinePaymentsDescription": "La kundene betale fakturaer fra lenken du sender dem. Stripe, Vipps og PayPal kobles til under Integrasjoner og kan brukes side om side.",
+    "onlinePaymentsDescription": "La kundene betale fakturaer fra lenken du sender dem. Stripe, Vipps, PayPal og Paystack 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.",
     "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",
     "offered": "På fakturalenker",
     "paused": "Tilkoblet, tilbys ikke",
     "paused": "Tilkoblet, tilbys ikke",

+ 2 - 0
messages/nb/share.json

@@ -12,6 +12,8 @@
     "payWithCard": "Betal {amount} med kort",
     "payWithCard": "Betal {amount} med kort",
     "payWithVipps": "Betal {amount} med Vipps",
     "payWithVipps": "Betal {amount} med Vipps",
     "payWithPaypal": "Betal {amount} med PayPal",
     "payWithPaypal": "Betal {amount} med PayPal",
+    "payWithPaystack": "Betal {amount} med Paystack",
+    "payWithVendor": "Betal med <vendor></vendor>",
     "errorInvalidAmount": "Vennligst angi et gyldig beløp.",
     "errorInvalidAmount": "Vennligst angi et gyldig beløp.",
     "errorExceedsBalance": "Beløpet kan ikke overstige {amount}.",
     "errorExceedsBalance": "Beløpet kan ikke overstige {amount}.",
     "errorVerifyFailed": "Betalingen kunne ikke verifiseres. Den kan fortsatt behandles.",
     "errorVerifyFailed": "Betalingen kunne ikke verifiseres. Den kan fortsatt behandles.",

+ 1 - 1
messages/nl/auth.json

@@ -131,7 +131,7 @@
       },
       },
       "pay": {
       "pay": {
         "title": "Klanten betalen via een link",
         "title": "Klanten betalen via een link",
-        "description": "Verstuur de factuur en ontvang online betaling via Stripe, Vipps of PayPal. Geen klantaccount nodig."
+        "description": "Verstuur de factuur en ontvang online betaling via Stripe, Vipps, PayPal of Paystack. Geen klantaccount nodig."
       }
       }
     },
     },
     "proof": {
     "proof": {

+ 12 - 0
messages/nl/integrations.json

@@ -154,6 +154,7 @@
     "inboundHintStripe": "Voeg deze URL toe in het Stripe-dashboard: open Workbench, dan Webhooks, kies Create an event destination en abonneer op checkout.session.completed. Stripe toont daarna een ondertekeningsgeheim voor het endpoint; plak het hier in het veld voor het ondertekeningsgeheim, zodat elke melding op zijn handtekening wordt gecontroleerd. Tot die tijd bevestigt Torqvoice elke melding door de checkout bij Stripe op te vragen.",
     "inboundHintStripe": "Voeg deze URL toe in het Stripe-dashboard: open Workbench, dan Webhooks, kies Create an event destination en abonneer op checkout.session.completed. Stripe toont daarna een ondertekeningsgeheim voor het endpoint; plak het hier in het veld voor het ondertekeningsgeheim, zodat elke melding op zijn handtekening wordt gecontroleerd. Tot die tijd bevestigt Torqvoice elke melding door de checkout bij Stripe op te vragen.",
     "inboundHintVipps": "Vul deze URL in als callback-URL in het Vipps MobilePay-portaal. Vipps vereist ook gepubliceerde verkoopvoorwaarden, die staan onder Instellingen, Betaling.",
     "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.",
     "inboundHintPaypal": "Voeg deze URL toe in het PayPal-ontwikkelaarsdashboard onder Webhooks, geabonneerd op PAYMENT.CAPTURE.COMPLETED.",
+    "inboundHintPaystack": "Plak deze URL als Webhook URL in het Paystack-dashboard onder Settings, API Keys & Webhooks. Paystack bewaart er één voor de testmodus en één voor de live-modus, dus stel hem in bij de modus waar je sleutel bij hoort.",
     "copy": "Kopiëren",
     "copy": "Kopiëren",
     "copied": "Gekopieerd",
     "copied": "Gekopieerd",
     "updateKeys": "Sleutels bijwerken",
     "updateKeys": "Sleutels bijwerken",
@@ -704,6 +705,17 @@
         "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."
         "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."
       }
       }
     },
     },
+    "paystack": {
+      "description": "Paystack-betaling vanaf de factuurlink, naar je eigen Paystack-account. De klant betaalt met kaart, bankoverschrijving of mobiel geld, afhankelijk van het land, en de factuur wordt vanzelf als betaald gemarkeerd.",
+      "fields": {
+        "secretKey": "Geheime sleutel",
+        "secretKeyHelp": "In het Paystack-dashboard onder Settings, API Keys & Webhooks. Een testsleutel (sk_test_) verwerkt niets echts, dus probeer daar eerst een betaling mee en stap daarna over op de live-sleutel."
+      },
+      "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."
+      }
+    },
     "quickbooks": {
     "quickbooks": {
       "description": "Stuur uitgegeven facturen, hun klanten en hun betalingen naar QuickBooks Online en haal daar geregistreerde betalingen terug.",
       "description": "Stuur uitgegeven facturen, hun klanten en hun betalingen naar QuickBooks Online en haal daar geregistreerde betalingen terug.",
       "tenantHelp": "Maak een app aan in het Intuit Developer-portaal. Kopieer de client-id en het secret uit Keys and credentials en voer de redirect-URI hieronder in onder Settings, Redirect URIs. Ontwikkelsleutels bereiken sandbox-bedrijven, productiesleutels echte.",
       "tenantHelp": "Maak een app aan in het Intuit Developer-portaal. Kopieer de client-id en het secret uit Keys and credentials en voer de redirect-URI hieronder in onder Settings, Redirect URIs. Ontwikkelsleutels bereiken sandbox-bedrijven, productiesleutels echte.",

+ 1 - 1
messages/nl/settings.json

@@ -1021,7 +1021,7 @@
     "paymentTermsPlaceholder": "30 dagen netto",
     "paymentTermsPlaceholder": "30 dagen netto",
     "paymentTermsHint": "Betalingsvoorwaarden op facturen (bijv. \"30 dagen netto\", \"Bij ontvangst\")",
     "paymentTermsHint": "Betalingsvoorwaarden op facturen (bijv. \"30 dagen netto\", \"Bij ontvangst\")",
     "onlinePayments": "Online betalingen",
     "onlinePayments": "Online betalingen",
-    "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.",
+    "onlinePaymentsDescription": "Laat klanten facturen betalen via de link die je ze stuurt. Stripe, Vipps, PayPal en Paystack 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.",
     "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",
     "offered": "Op factuurlinks",
     "paused": "Gekoppeld, niet aangeboden",
     "paused": "Gekoppeld, niet aangeboden",

+ 2 - 0
messages/nl/share.json

@@ -12,6 +12,8 @@
     "payWithCard": "Betaal {amount} met kaart",
     "payWithCard": "Betaal {amount} met kaart",
     "payWithVipps": "Betaal {amount} met Vipps",
     "payWithVipps": "Betaal {amount} met Vipps",
     "payWithPaypal": "Betaal {amount} met PayPal",
     "payWithPaypal": "Betaal {amount} met PayPal",
+    "payWithPaystack": "Betaal {amount} met Paystack",
+    "payWithVendor": "Betalen met <vendor></vendor>",
     "errorInvalidAmount": "Voer een geldig bedrag in.",
     "errorInvalidAmount": "Voer een geldig bedrag in.",
     "errorExceedsBalance": "Bedrag mag niet hoger zijn dan {amount}.",
     "errorExceedsBalance": "Bedrag mag niet hoger zijn dan {amount}.",
     "errorVerifyFailed": "Betaling kon niet worden geverifieerd. De verwerking kan nog gaande zijn.",
     "errorVerifyFailed": "Betaling kon niet worden geverifieerd. De verwerking kan nog gaande zijn.",

+ 1 - 1
messages/pl/auth.json

@@ -131,7 +131,7 @@
       },
       },
       "pay": {
       "pay": {
         "title": "Klienci płacą z linku",
         "title": "Klienci płacą z linku",
-        "description": "Wyślij fakturę i odbierz płatność online przez Stripe, Vipps lub PayPal. Bez konta klienta."
+        "description": "Wyślij fakturę i odbierz płatność online przez Stripe, Vipps, PayPal lub Paystack. Bez konta klienta."
       }
       }
     },
     },
     "proof": {
     "proof": {

+ 12 - 0
messages/pl/integrations.json

@@ -154,6 +154,7 @@
     "inboundHintStripe": "Dodaj ten adres URL w panelu Stripe: otwórz Workbench, potem Webhooks, wybierz Create an event destination i zasubskrybuj checkout.session.completed. Stripe pokaże wtedy sekret podpisu dla tego punktu końcowego; wklej go tutaj w polu sekretu podpisu, aby każde powiadomienie było sprawdzane po podpisie. Do tego czasu Torqvoice potwierdza każde powiadomienie, pytając Stripe o samą płatność.",
     "inboundHintStripe": "Dodaj ten adres URL w panelu Stripe: otwórz Workbench, potem Webhooks, wybierz Create an event destination i zasubskrybuj checkout.session.completed. Stripe pokaże wtedy sekret podpisu dla tego punktu końcowego; wklej go tutaj w polu sekretu podpisu, aby każde powiadomienie było sprawdzane po podpisie. Do tego czasu Torqvoice potwierdza każde powiadomienie, pytając Stripe o samą płatność.",
     "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ść.",
     "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.",
     "inboundHintPaypal": "Dodaj ten adres URL w panelu dewelopera PayPal w sekcji Webhooks, z subskrypcją PAYMENT.CAPTURE.COMPLETED.",
+    "inboundHintPaystack": "Wklej ten adres URL jako Webhook URL w panelu Paystack, w sekcji Settings, API Keys & Webhooks. Paystack przechowuje jeden dla trybu testowego i jeden dla produkcyjnego, więc ustaw go w trybie, do którego należy Twój klucz.",
     "copy": "Kopiuj",
     "copy": "Kopiuj",
     "copied": "Skopiowano",
     "copied": "Skopiowano",
     "updateKeys": "Zaktualizuj klucze",
     "updateKeys": "Zaktualizuj klucze",
@@ -704,6 +705,17 @@
         "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."
         "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."
       }
       }
     },
     },
+    "paystack": {
+      "description": "Płatność Paystack z linku do faktury, na Twoje własne konto Paystack. Klient płaci kartą, przelewem bankowym lub pieniądzem mobilnym, zależnie od kraju, a faktura sama oznacza się jako opłacona.",
+      "fields": {
+        "secretKey": "Klucz tajny",
+        "secretKeyHelp": "W panelu Paystack, w sekcji Settings, API Keys & Webhooks. Klucz testowy (sk_test_) nie przetwarza niczego prawdziwego, więc najpierw wypróbuj na nim płatność, a potem zmień na klucz produkcyjny."
+      },
+      "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."
+      }
+    },
     "quickbooks": {
     "quickbooks": {
       "description": "Wysyłaj wystawione faktury, ich klientów i płatności do QuickBooks Online i pobieraj płatności zarejestrowane tam.",
       "description": "Wysyłaj wystawione faktury, ich klientów i płatności do QuickBooks Online i pobieraj płatności zarejestrowane tam.",
       "tenantHelp": "Utwórz aplikację w portalu Intuit Developer. Skopiuj client id i secret z Keys and credentials, a poniższy adres URI przekierowania wpisz w Settings, Redirect URIs. Klucze deweloperskie działają z firmami sandbox, produkcyjne z prawdziwymi.",
       "tenantHelp": "Utwórz aplikację w portalu Intuit Developer. Skopiuj client id i secret z Keys and credentials, a poniższy adres URI przekierowania wpisz w Settings, Redirect URIs. Klucze deweloperskie działają z firmami sandbox, produkcyjne z prawdziwymi.",

+ 1 - 1
messages/pl/settings.json

@@ -1021,7 +1021,7 @@
     "paymentTermsPlaceholder": "14 dni netto",
     "paymentTermsPlaceholder": "14 dni netto",
     "paymentTermsHint": "Tekst warunkow platnosci na fakturach (np. \"14 dni netto\", \"Platne przy odbiorze\")",
     "paymentTermsHint": "Tekst warunkow platnosci na fakturach (np. \"14 dni netto\", \"Platne przy odbiorze\")",
     "onlinePayments": "Platnosci online",
     "onlinePayments": "Platnosci online",
-    "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.",
+    "onlinePaymentsDescription": "Pozwól klientom płacić faktury z linku, który im wysyłasz. Stripe, Vipps, PayPal i Paystack 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ń.",
     "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",
     "offered": "W linkach do faktur",
     "paused": "Podłączony, nieoferowany",
     "paused": "Podłączony, nieoferowany",

+ 2 - 0
messages/pl/share.json

@@ -12,6 +12,8 @@
     "payWithCard": "Zapłać {amount} kartą",
     "payWithCard": "Zapłać {amount} kartą",
     "payWithVipps": "Zapłać {amount} przez Vipps",
     "payWithVipps": "Zapłać {amount} przez Vipps",
     "payWithPaypal": "Zapłać {amount} przez PayPal",
     "payWithPaypal": "Zapłać {amount} przez PayPal",
+    "payWithPaystack": "Zapłać {amount} przez Paystack",
+    "payWithVendor": "Zapłać przez <vendor></vendor>",
     "errorInvalidAmount": "Proszę wprowadzić prawidłową kwotę.",
     "errorInvalidAmount": "Proszę wprowadzić prawidłową kwotę.",
     "errorExceedsBalance": "Kwota nie może przekraczać {amount}.",
     "errorExceedsBalance": "Kwota nie może przekraczać {amount}.",
     "errorVerifyFailed": "Nie można zweryfikować płatności. Może być jeszcze przetwarzana.",
     "errorVerifyFailed": "Nie można zweryfikować płatności. Może być jeszcze przetwarzana.",

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

@@ -131,7 +131,7 @@
       },
       },
       "pay": {
       "pay": {
         "title": "Clientes pagam por um link",
         "title": "Clientes pagam por um link",
-        "description": "Envie a fatura e receba online com Stripe, Vipps ou PayPal. Sem conta de cliente."
+        "description": "Envie a fatura e receba online com Stripe, Vipps, PayPal ou Paystack. Sem conta de cliente."
       }
       }
     },
     },
     "proof": {
     "proof": {

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

@@ -154,6 +154,7 @@
     "inboundHintStripe": "Adicione esta URL no painel do Stripe: abra Workbench, depois Webhooks, escolha Create an event destination e inscreva-a em checkout.session.completed. O Stripe então mostra um segredo de assinatura para o endpoint; cole-o aqui no campo do segredo de assinatura para que cada notificação seja conferida pela assinatura. Até lá, o Torqvoice confirma cada notificação consultando o checkout no Stripe.",
     "inboundHintStripe": "Adicione esta URL no painel do Stripe: abra Workbench, depois Webhooks, escolha Create an event destination e inscreva-a em checkout.session.completed. O Stripe então mostra um segredo de assinatura para o endpoint; cole-o aqui no campo do segredo de assinatura para que cada notificação seja conferida pela assinatura. Até lá, o Torqvoice confirma cada notificação consultando o checkout no Stripe.",
     "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.",
     "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.",
     "inboundHintPaypal": "Adicione esta URL no painel de desenvolvedor do PayPal em Webhooks, inscrita em PAYMENT.CAPTURE.COMPLETED.",
+    "inboundHintPaystack": "Cole esta URL como Webhook URL no painel do Paystack, em Settings, API Keys & Webhooks. O Paystack mantém uma para o modo de teste e outra para o modo de produção, então configure-a no modo ao qual a sua chave pertence.",
     "copy": "Copiar",
     "copy": "Copiar",
     "copied": "Copiado",
     "copied": "Copiado",
     "updateKeys": "Atualizar chaves",
     "updateKeys": "Atualizar chaves",
@@ -704,6 +705,17 @@
         "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."
         "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."
       }
       }
     },
     },
+    "paystack": {
+      "description": "Pagamento com Paystack pelo link da fatura, na sua própria conta Paystack. O cliente paga com cartão, transferência bancária ou dinheiro móvel, conforme o país, e a fatura se marca como paga sozinha.",
+      "fields": {
+        "secretKey": "Chave secreta",
+        "secretKeyHelp": "No painel do Paystack, em Settings, API Keys & Webhooks. Uma chave de teste (sk_test_) não processa nada real, então teste um pagamento com ela primeiro e troque para a chave de produção depois."
+      },
+      "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."
+      }
+    },
     "quickbooks": {
     "quickbooks": {
       "description": "Envie faturas emitidas, seus clientes e seus pagamentos para o QuickBooks Online e traga de volta os pagamentos registrados lá.",
       "description": "Envie faturas emitidas, seus clientes e seus pagamentos para o QuickBooks Online e traga de volta os pagamentos registrados lá.",
       "tenantHelp": "Crie um app no portal de desenvolvedores da Intuit. Copie o client id e o secret em Keys and credentials e informe a URI de redirecionamento abaixo em Settings, Redirect URIs. Chaves de desenvolvimento alcançam empresas sandbox, chaves de produção as reais.",
       "tenantHelp": "Crie um app no portal de desenvolvedores da Intuit. Copie o client id e o secret em Keys and credentials e informe a URI de redirecionamento abaixo em Settings, Redirect URIs. Chaves de desenvolvimento alcançam empresas sandbox, chaves de produção as reais.",

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

@@ -1021,7 +1021,7 @@
     "paymentTermsPlaceholder": "Líquido 14 dias",
     "paymentTermsPlaceholder": "Líquido 14 dias",
     "paymentTermsHint": "Texto das condições de pagamento nas faturas (ex. \"Líquido 14 dias\", \"No recebimento\")",
     "paymentTermsHint": "Texto das condições de pagamento nas faturas (ex. \"Líquido 14 dias\", \"No recebimento\")",
     "onlinePayments": "Pagamentos online",
     "onlinePayments": "Pagamentos online",
-    "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.",
+    "onlinePaymentsDescription": "Deixe os clientes pagarem faturas pelo link que você envia. Stripe, Vipps, PayPal e Paystack 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.",
     "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",
     "offered": "Nos links de fatura",
     "paused": "Conectado, não oferecido",
     "paused": "Conectado, não oferecido",

+ 2 - 0
messages/pt-BR/share.json

@@ -12,6 +12,8 @@
     "payWithCard": "Pagar {amount} com Cartão",
     "payWithCard": "Pagar {amount} com Cartão",
     "payWithVipps": "Pagar {amount} com Vipps",
     "payWithVipps": "Pagar {amount} com Vipps",
     "payWithPaypal": "Pagar {amount} com PayPal",
     "payWithPaypal": "Pagar {amount} com PayPal",
+    "payWithPaystack": "Pagar {amount} com Paystack",
+    "payWithVendor": "Pagar com <vendor></vendor>",
     "errorInvalidAmount": "Por favor, insira um valor válido.",
     "errorInvalidAmount": "Por favor, insira um valor válido.",
     "errorExceedsBalance": "O valor não pode exceder {amount}.",
     "errorExceedsBalance": "O valor não pode exceder {amount}.",
     "errorVerifyFailed": "Não foi possível verificar o pagamento. Ele ainda pode estar sendo processado.",
     "errorVerifyFailed": "Não foi possível verificar o pagamento. Ele ainda pode estar sendo processado.",

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

@@ -131,7 +131,7 @@
       },
       },
       "pay": {
       "pay": {
         "title": "Os clientes pagam através de um link",
         "title": "Os clientes pagam através de um link",
-        "description": "Envie a fatura e receba online com Stripe, Vipps ou PayPal. O cliente não precisa de conta."
+        "description": "Envie a fatura e receba online com Stripe, Vipps, PayPal ou Paystack. O cliente não precisa de conta."
       }
       }
     },
     },
     "proof": {
     "proof": {

+ 12 - 0
messages/pt-PT/integrations.json

@@ -154,6 +154,7 @@
     "inboundHintStripe": "Adicione este URL no painel do Stripe: abra Workbench, depois Webhooks, escolha Create an event destination e subscreva checkout.session.completed. O Stripe mostra então um segredo de assinatura para o endpoint; cole-o aqui no campo do segredo de assinatura para que cada notificação seja verificada pela assinatura. Até lá, o Torqvoice confirma cada notificação consultando o checkout ao Stripe.",
     "inboundHintStripe": "Adicione este URL no painel do Stripe: abra Workbench, depois Webhooks, escolha Create an event destination e subscreva checkout.session.completed. O Stripe mostra então um segredo de assinatura para o endpoint; cole-o aqui no campo do segredo de assinatura para que cada notificação seja verificada pela assinatura. Até lá, o Torqvoice confirma cada notificação consultando o checkout ao Stripe.",
     "inboundHintVipps": "Indique este URL como URL de callback no portal do Vipps MobilePay. O Vipps também exige termos de venda publicados, definidos em Definições, Pagamento.",
     "inboundHintVipps": "Indique este URL como URL de callback no portal do Vipps MobilePay. O Vipps também exige termos de venda publicados, definidos em Definições, Pagamento.",
     "inboundHintPaypal": "Adicione este URL no painel de programador do PayPal em Webhooks, subscrito a PAYMENT.CAPTURE.COMPLETED.",
     "inboundHintPaypal": "Adicione este URL no painel de programador do PayPal em Webhooks, subscrito a PAYMENT.CAPTURE.COMPLETED.",
+    "inboundHintPaystack": "Cole este URL como Webhook URL no painel do Paystack, em Settings, API Keys & Webhooks. O Paystack guarda um para o modo de teste e outro para o modo de produção, por isso defina-o no modo a que a sua chave pertence.",
     "copy": "Copiar",
     "copy": "Copiar",
     "copied": "Copiado",
     "copied": "Copiado",
     "updateKeys": "Atualizar chaves",
     "updateKeys": "Atualizar chaves",
@@ -704,6 +705,17 @@
         "sandboxHelp": "Utilizar o sandbox do PayPal com as chaves da app de sandbox. Deixe ativado até um pagamento completo funcionar; depois desative e introduza as chaves da app real."
         "sandboxHelp": "Utilizar o sandbox do PayPal com as chaves da app de sandbox. Deixe ativado até um pagamento completo funcionar; depois desative e introduza as chaves da app real."
       }
       }
     },
     },
+    "paystack": {
+      "description": "Pagamento com Paystack a partir da ligação da fatura, para a sua própria conta Paystack. O cliente paga com cartão, transferência bancária ou dinheiro móvel, consoante o país, e a fatura fica marcada como paga automaticamente.",
+      "fields": {
+        "secretKey": "Chave secreta",
+        "secretKeyHelp": "No painel do Paystack, em Settings, API Keys & Webhooks. Uma chave de teste (sk_test_) não processa nada real, por isso experimente primeiro um pagamento com ela e mude depois para a chave de produção."
+      },
+      "settings": {
+        "enabled": "Oferecer nos links de fatura",
+        "enabledHelp": "Desative para retirar temporariamente o botão de pagamento das faturas partilhadas, sem desligar. Os pagamentos já iniciados continuam a ser registados."
+      }
+    },
     "quickbooks": {
     "quickbooks": {
       "description": "Envie as faturas emitidas, os respetivos clientes e pagamentos para o QuickBooks Online e receba de volta os pagamentos lá registados.",
       "description": "Envie as faturas emitidas, os respetivos clientes e pagamentos para o QuickBooks Online e receba de volta os pagamentos lá registados.",
       "tenantHelp": "Crie uma app no portal de programadores da Intuit. Copie o client id e o secret em Keys and credentials e indique o URI de redirecionamento abaixo em Settings, Redirect URIs. As chaves de desenvolvimento dão acesso a empresas sandbox, as de produção às reais.",
       "tenantHelp": "Crie uma app no portal de programadores da Intuit. Copie o client id e o secret em Keys and credentials e indique o URI de redirecionamento abaixo em Settings, Redirect URIs. As chaves de desenvolvimento dão acesso a empresas sandbox, as de produção às reais.",

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

@@ -1021,7 +1021,7 @@
     "paymentTermsPlaceholder": "Pagamento a 14 dias",
     "paymentTermsPlaceholder": "Pagamento a 14 dias",
     "paymentTermsHint": "Texto das condições de pagamento apresentado nas faturas (ex.: \"Pagamento a 14 dias\", \"Pronto pagamento\")",
     "paymentTermsHint": "Texto das condições de pagamento apresentado nas faturas (ex.: \"Pagamento a 14 dias\", \"Pronto pagamento\")",
     "onlinePayments": "Pagamentos online",
     "onlinePayments": "Pagamentos online",
-    "onlinePaymentsDescription": "Permita que os clientes paguem as faturas a partir do link que lhes envia. Stripe, Vipps e PayPal ligam-se em Integrações e podem funcionar em simultâneo.",
+    "onlinePaymentsDescription": "Permita que os clientes paguem as faturas a partir do link que lhes envia. Stripe, Vipps, PayPal e Paystack ligam-se em Integrações e podem funcionar em simultâneo.",
     "onlinePaymentsHint": "Quando um cliente paga, o fornecedor avisa o Torqvoice e a fatura fica marcada como paga automaticamente. A página de cada fornecedor mostra o URL de notificação de que precisa.",
     "onlinePaymentsHint": "Quando um cliente paga, o fornecedor avisa o Torqvoice e a fatura fica marcada como paga automaticamente. A página de cada fornecedor mostra o URL de notificação de que precisa.",
     "offered": "Nos links de fatura",
     "offered": "Nos links de fatura",
     "paused": "Ligado, não disponibilizado",
     "paused": "Ligado, não disponibilizado",

+ 2 - 0
messages/pt-PT/share.json

@@ -12,6 +12,8 @@
     "payWithCard": "Pagar {amount} com Cartão",
     "payWithCard": "Pagar {amount} com Cartão",
     "payWithVipps": "Pagar {amount} com Vipps",
     "payWithVipps": "Pagar {amount} com Vipps",
     "payWithPaypal": "Pagar {amount} com PayPal",
     "payWithPaypal": "Pagar {amount} com PayPal",
+    "payWithPaystack": "Pagar {amount} com Paystack",
+    "payWithVendor": "Pagar com <vendor></vendor>",
     "errorInvalidAmount": "Introduza um valor válido.",
     "errorInvalidAmount": "Introduza um valor válido.",
     "errorExceedsBalance": "O valor não pode ser superior a {amount}.",
     "errorExceedsBalance": "O valor não pode ser superior a {amount}.",
     "errorVerifyFailed": "Não foi possível confirmar o pagamento. Pode ainda estar a ser processado.",
     "errorVerifyFailed": "Não foi possível confirmar o pagamento. Pode ainda estar a ser processado.",

+ 1 - 1
messages/ru/auth.json

@@ -131,7 +131,7 @@
       },
       },
       "pay": {
       "pay": {
         "title": "Клиенты платят по ссылке",
         "title": "Клиенты платят по ссылке",
-        "description": "Отправьте счёт и получите оплату онлайн через Stripe, Vipps или PayPal. Аккаунт клиента не нужен."
+        "description": "Отправьте счёт и получите оплату онлайн через Stripe, Vipps, PayPal или Paystack. Аккаунт клиента не нужен."
       }
       }
     },
     },
     "proof": {
     "proof": {

+ 12 - 0
messages/ru/integrations.json

@@ -154,6 +154,7 @@
     "inboundHintStripe": "Добавьте этот URL в панели Stripe: откройте Workbench, затем Webhooks, выберите Create an event destination и подпишите его на checkout.session.completed. После этого Stripe покажет секрет подписи для этой конечной точки; вставьте его здесь в поле секрета подписи, чтобы каждое уведомление проверялось по подписи. До тех пор Torqvoice подтверждает каждое уведомление, запрашивая у Stripe сам платёж.",
     "inboundHintStripe": "Добавьте этот URL в панели Stripe: откройте Workbench, затем Webhooks, выберите Create an event destination и подпишите его на checkout.session.completed. После этого Stripe покажет секрет подписи для этой конечной точки; вставьте его здесь в поле секрета подписи, чтобы каждое уведомление проверялось по подписи. До тех пор Torqvoice подтверждает каждое уведомление, запрашивая у Stripe сам платёж.",
     "inboundHintVipps": "Укажите этот URL как callback-URL в портале Vipps MobilePay. Vipps также требует опубликованных условий продажи, они задаются в разделе Настройки, Оплата.",
     "inboundHintVipps": "Укажите этот URL как callback-URL в портале Vipps MobilePay. Vipps также требует опубликованных условий продажи, они задаются в разделе Настройки, Оплата.",
     "inboundHintPaypal": "Добавьте этот URL в панели разработчика PayPal в разделе Webhooks с подпиской на PAYMENT.CAPTURE.COMPLETED.",
     "inboundHintPaypal": "Добавьте этот URL в панели разработчика PayPal в разделе Webhooks с подпиской на PAYMENT.CAPTURE.COMPLETED.",
+    "inboundHintPaystack": "Вставьте этот URL как Webhook URL в панели Paystack, в разделе Settings, API Keys & Webhooks. Paystack хранит один для тестового режима и один для рабочего, поэтому укажите его в том режиме, к которому относится ваш ключ.",
     "copy": "Копировать",
     "copy": "Копировать",
     "copied": "Скопировано",
     "copied": "Скопировано",
     "updateKeys": "Обновить ключи",
     "updateKeys": "Обновить ключи",
@@ -704,6 +705,17 @@
         "sandboxHelp": "Использовать песочницу PayPal с ключами приложения песочницы. Оставьте включённым, пока не пройдёт полный платёж, затем выключите и введите ключи боевого приложения."
         "sandboxHelp": "Использовать песочницу PayPal с ключами приложения песочницы. Оставьте включённым, пока не пройдёт полный платёж, затем выключите и введите ключи боевого приложения."
       }
       }
     },
     },
+    "paystack": {
+      "description": "Оплата через Paystack по ссылке на счёт, на ваш собственный аккаунт Paystack. Клиент платит картой, банковским переводом или мобильными деньгами, в зависимости от страны, и счёт сам отмечается оплаченным.",
+      "fields": {
+        "secretKey": "Секретный ключ",
+        "secretKeyHelp": "В панели Paystack, в разделе Settings, API Keys & Webhooks. Тестовый ключ (sk_test_) не проводит реальных платежей, поэтому сначала попробуйте оплату с ним, а затем замените его на рабочий ключ."
+      },
+      "settings": {
+        "enabled": "Предлагать в ссылках на счета",
+        "enabledHelp": "Выключите, чтобы на время убрать кнопку оплаты с общих счетов, не отключая интеграцию. Уже начатые платежи всё равно записываются."
+      }
+    },
     "quickbooks": {
     "quickbooks": {
       "description": "Отправляйте выставленные счета, их клиентов и платежи в QuickBooks Online и получайте обратно платежи, записанные там.",
       "description": "Отправляйте выставленные счета, их клиентов и платежи в QuickBooks Online и получайте обратно платежи, записанные там.",
       "tenantHelp": "Создайте приложение на портале Intuit Developer. Скопируйте client id и secret из Keys and credentials, а URI перенаправления ниже введите в Settings, Redirect URIs. Ключи разработки работают с sandbox-компаниями, production-ключи с настоящими.",
       "tenantHelp": "Создайте приложение на портале Intuit Developer. Скопируйте client id и secret из Keys and credentials, а URI перенаправления ниже введите в Settings, Redirect URIs. Ключи разработки работают с sandbox-компаниями, production-ключи с настоящими.",

+ 1 - 1
messages/ru/settings.json

@@ -1021,7 +1021,7 @@
     "paymentTermsPlaceholder": "14 дней",
     "paymentTermsPlaceholder": "14 дней",
     "paymentTermsHint": "Текст условий оплаты, отображаемый на счетах (напр. «14 дней», «По получении»)",
     "paymentTermsHint": "Текст условий оплаты, отображаемый на счетах (напр. «14 дней», «По получении»)",
     "onlinePayments": "Онлайн-платежи",
     "onlinePayments": "Онлайн-платежи",
-    "onlinePaymentsDescription": "Позвольте клиентам оплачивать счета по ссылке, которую вы им отправляете. Stripe, Vipps и PayPal подключаются в разделе Интеграции и могут работать одновременно.",
+    "onlinePaymentsDescription": "Позвольте клиентам оплачивать счета по ссылке, которую вы им отправляете. Stripe, Vipps, PayPal и Paystack подключаются в разделе Интеграции и могут работать одновременно.",
     "onlinePaymentsHint": "Когда клиент платит, провайдер сообщает Torqvoice, и счёт сам отмечается оплаченным. На странице каждого провайдера показан нужный ему URL уведомлений.",
     "onlinePaymentsHint": "Когда клиент платит, провайдер сообщает Torqvoice, и счёт сам отмечается оплаченным. На странице каждого провайдера показан нужный ему URL уведомлений.",
     "offered": "В ссылках на счета",
     "offered": "В ссылках на счета",
     "paused": "Подключено, не предлагается",
     "paused": "Подключено, не предлагается",

+ 2 - 0
messages/ru/share.json

@@ -12,6 +12,8 @@
     "payWithCard": "Оплатить {amount} картой",
     "payWithCard": "Оплатить {amount} картой",
     "payWithVipps": "Оплатить {amount} через Vipps",
     "payWithVipps": "Оплатить {amount} через Vipps",
     "payWithPaypal": "Оплатить {amount} через PayPal",
     "payWithPaypal": "Оплатить {amount} через PayPal",
+    "payWithPaystack": "Оплатить {amount} через Paystack",
+    "payWithVendor": "Оплатить через <vendor></vendor>",
     "errorInvalidAmount": "Введите корректную сумму.",
     "errorInvalidAmount": "Введите корректную сумму.",
     "errorExceedsBalance": "Сумма не может превышать {amount}.",
     "errorExceedsBalance": "Сумма не может превышать {amount}.",
     "errorVerifyFailed": "Не удалось подтвердить оплату. Возможно, она ещё обрабатывается.",
     "errorVerifyFailed": "Не удалось подтвердить оплату. Возможно, она ещё обрабатывается.",

+ 1 - 1
messages/tr/auth.json

@@ -131,7 +131,7 @@
       },
       },
       "pay": {
       "pay": {
         "title": "Müşteriler bağlantıdan öder",
         "title": "Müşteriler bağlantıdan öder",
-        "description": "Faturayı gönderin, Stripe, Vipps veya PayPal ile çevrimiçi ödeme alın. Müşteri hesabı gerekmez."
+        "description": "Faturayı gönderin, Stripe, Vipps, PayPal veya Paystack ile çevrimiçi ödeme alın. Müşteri hesabı gerekmez."
       }
       }
     },
     },
     "proof": {
     "proof": {

+ 12 - 0
messages/tr/integrations.json

@@ -154,6 +154,7 @@
     "inboundHintStripe": "Bu URL'yi Stripe panelinde ekleyin: Workbench'i, ardından Webhooks'u açın, Create an event destination'ı seçin ve checkout.session.completed olayına abone olun. Stripe daha sonra bu uç nokta için bir imza sırrı gösterir; her bildirimin imzasıyla doğrulanması için onu buradaki imza sırrı alanına yapıştırın. O zamana kadar Torqvoice her bildirimi, ödemeyi Stripe'a sorarak doğrular.",
     "inboundHintStripe": "Bu URL'yi Stripe panelinde ekleyin: Workbench'i, ardından Webhooks'u açın, Create an event destination'ı seçin ve checkout.session.completed olayına abone olun. Stripe daha sonra bu uç nokta için bir imza sırrı gösterir; her bildirimin imzasıyla doğrulanması için onu buradaki imza sırrı alanına yapıştırın. O zamana kadar Torqvoice her bildirimi, ödemeyi Stripe'a sorarak doğrular.",
     "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.",
     "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.",
     "inboundHintPaypal": "Bu URL'yi PayPal geliştirici panelinde Webhooks altında PAYMENT.CAPTURE.COMPLETED olayına abone olarak ekleyin.",
+    "inboundHintPaystack": "Bu URL'yi Paystack panelinde Settings, API Keys & Webhooks altında Webhook URL olarak yapıştırın. Paystack test modu için bir, canlı mod için bir adres tutar; anahtarınızın ait olduğu modda ayarlayın.",
     "copy": "Kopyala",
     "copy": "Kopyala",
     "copied": "Kopyalandı",
     "copied": "Kopyalandı",
     "updateKeys": "Anahtarları güncelle",
     "updateKeys": "Anahtarları güncelle",
@@ -704,6 +705,17 @@
         "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."
         "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."
       }
       }
     },
     },
+    "paystack": {
+      "description": "Fatura bağlantısından Paystack ile ödeme, kendi Paystack hesabınıza. Müşteri ülkeye göre kartla, banka havalesiyle veya mobil parayla öder ve fatura kendiliğinden ödendi olarak işaretlenir.",
+      "fields": {
+        "secretKey": "Gizli anahtar",
+        "secretKeyHelp": "Paystack panelinde Settings, API Keys & Webhooks altında. Test anahtarı (sk_test_) gerçek hiçbir şey işlemez, bu yüzden önce onunla bir ödeme deneyin, sonra canlı anahtara geçin."
+      },
+      "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."
+      }
+    },
     "quickbooks": {
     "quickbooks": {
       "description": "Kesilen faturaları, müşterilerini ve ödemelerini QuickBooks Online'a gönderin ve orada kaydedilen ödemeleri geri alın.",
       "description": "Kesilen faturaları, müşterilerini ve ödemelerini QuickBooks Online'a gönderin ve orada kaydedilen ödemeleri geri alın.",
       "tenantHelp": "Intuit Developer portalında bir uygulama oluşturun. Client id ve secret'ı Keys and credentials'dan kopyalayın, aşağıdaki yönlendirme URI'sini Settings, Redirect URIs altına girin. Geliştirme anahtarları sandbox şirketlerine, üretim anahtarları gerçek şirketlere ulaşır.",
       "tenantHelp": "Intuit Developer portalında bir uygulama oluşturun. Client id ve secret'ı Keys and credentials'dan kopyalayın, aşağıdaki yönlendirme URI'sini Settings, Redirect URIs altına girin. Geliştirme anahtarları sandbox şirketlerine, üretim anahtarları gerçek şirketlere ulaşır.",

+ 1 - 1
messages/tr/settings.json

@@ -1021,7 +1021,7 @@
     "paymentTermsPlaceholder": "Net 14 gün",
     "paymentTermsPlaceholder": "Net 14 gün",
     "paymentTermsHint": "Faturalarda gösterilen ödeme koşulları metni (ör. \"Net 14 gün\", \"Teslimde ödeme\")",
     "paymentTermsHint": "Faturalarda gösterilen ödeme koşulları metni (ör. \"Net 14 gün\", \"Teslimde ödeme\")",
     "onlinePayments": "Online Ödemeler",
     "onlinePayments": "Online Ödemeler",
-    "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.",
+    "onlinePaymentsDescription": "Müşterilerin, gönderdiğiniz bağlantıdan fatura ödemesine izin verin. Stripe, Vipps, PayPal ve Paystack, 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.",
     "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",
     "offered": "Fatura bağlantılarında",
     "paused": "Bağlı, sunulmuyor",
     "paused": "Bağlı, sunulmuyor",

+ 2 - 0
messages/tr/share.json

@@ -12,6 +12,8 @@
     "payWithCard": "{amount} Kartla Öde",
     "payWithCard": "{amount} Kartla Öde",
     "payWithVipps": "{amount} Vipps ile Öde",
     "payWithVipps": "{amount} Vipps ile Öde",
     "payWithPaypal": "{amount} PayPal ile Öde",
     "payWithPaypal": "{amount} PayPal ile Öde",
+    "payWithPaystack": "{amount} Paystack ile Öde",
+    "payWithVendor": "<vendor></vendor> ile öde",
     "errorInvalidAmount": "Lütfen geçerli bir tutar girin.",
     "errorInvalidAmount": "Lütfen geçerli bir tutar girin.",
     "errorExceedsBalance": "Tutar {amount} miktarını aşamaz.",
     "errorExceedsBalance": "Tutar {amount} miktarını aşamaz.",
     "errorVerifyFailed": "Ödeme doğrulanamadı. İşlem hâlâ devam ediyor olabilir.",
     "errorVerifyFailed": "Ödeme doğrulanamadı. İşlem hâlâ devam ediyor olabilir.",

+ 1 - 0
public/images/integrations/paystack.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="Paystack"><title>Paystack</title><rect width="48" height="48" rx="11" fill="#011B33"/><g fill="#00C3F7"><rect x="11" y="11" width="26" height="4.6" rx="1.2"/><rect x="11" y="18.2" width="26" height="4.6" rx="1.2"/><rect x="11" y="25.4" width="26" height="4.6" rx="1.2"/><rect x="11" y="32.6" width="13.5" height="4.6" rx="1.2"/></g></svg>

+ 23 - 0
src/__tests__/app/api/webhooks-freeze.test.ts

@@ -6,6 +6,7 @@
  * replaces the workshop's rows. They answer 503 with Retry-After, so the
  * replaces the workshop's rows. They answer 503 with Retry-After, so the
  * vendor delivers the event again once the restore is done.
  * vendor delivers the event again once the restore is done.
  */
  */
+import { createHmac } from 'node:crypto'
 import { beforeEach, describe, expect, it, vi } from 'vitest'
 import { beforeEach, describe, expect, it, vi } from 'vitest'
 
 
 const { db, payments, stripe, recordVendorPayment, messaging, whatsapp, notify } = vi.hoisted(
 const { db, payments, stripe, recordVendorPayment, messaging, whatsapp, notify } = vi.hoisted(
@@ -47,6 +48,7 @@ vi.mock('@/lib/notify', () => ({ notify }))
 import { POST as stripePost } from '@/app/api/webhooks/stripe/route'
 import { POST as stripePost } from '@/app/api/webhooks/stripe/route'
 import { POST as vippsPost } from '@/app/api/webhooks/vipps/route'
 import { POST as vippsPost } from '@/app/api/webhooks/vipps/route'
 import { POST as paypalPost } from '@/app/api/webhooks/paypal/route'
 import { POST as paypalPost } from '@/app/api/webhooks/paypal/route'
+import { POST as paystackPost } from '@/app/api/webhooks/paystack/route'
 import { POST as twilioPost } from '@/app/api/webhooks/sms/twilio/route'
 import { POST as twilioPost } from '@/app/api/webhooks/sms/twilio/route'
 import { POST as vonagePost } from '@/app/api/webhooks/sms/vonage/route'
 import { POST as vonagePost } from '@/app/api/webhooks/sms/vonage/route'
 import { POST as telnyxPost } from '@/app/api/webhooks/sms/telnyx/route'
 import { POST as telnyxPost } from '@/app/api/webhooks/sms/telnyx/route'
@@ -134,6 +136,27 @@ describe('payment webhooks during a restore', () => {
     expect(verifyPayment).not.toHaveBeenCalled()
     expect(verifyPayment).not.toHaveBeenCalled()
     expect(recordVendorPayment).not.toHaveBeenCalled()
     expect(recordVendorPayment).not.toHaveBeenCalled()
   })
   })
+
+  it('Paystack answers 503 before verifying or booking the charge', async () => {
+    const body = JSON.stringify({
+      event: 'charge.success',
+      data: { reference: 'inv-sr_1-1', metadata: { orgId: ORG, serviceRecordId: 'sr_1' } },
+    })
+    const response = await paystackPost(
+      new Request('http://app.test/api/webhooks/paystack', {
+        method: 'POST',
+        headers: {
+          'content-type': 'application/json',
+          // Signed with the key the mocked connection holds.
+          'x-paystack-signature': createHmac('sha512', 'sk_test').update(body).digest('hex'),
+        },
+        body,
+      })
+    )
+    await expectRestoring(response)
+    expect(verifyPayment).not.toHaveBeenCalled()
+    expect(recordVendorPayment).not.toHaveBeenCalled()
+  })
 })
 })
 
 
 describe('SMS webhooks during a restore', () => {
 describe('SMS webhooks during a restore', () => {

+ 160 - 0
src/__tests__/app/api/webhooks-paystack.test.ts

@@ -0,0 +1,160 @@
+/**
+ * @vitest-environment node
+ *
+ * A Paystack notification books a payment only when it was signed with the
+ * workshop's own key and Paystack itself confirms the transaction was paid
+ * for that invoice. Anyone can post to the URL, so everything else is
+ * turned away without a row being written.
+ */
+import { createHmac } from 'node:crypto'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { db, payments, recordVendorPayment, verifyPayment } = vi.hoisted(() => ({
+  db: { serviceRecord: { findUnique: vi.fn() } },
+  payments: { paymentProviderFor: vi.fn() },
+  recordVendorPayment: vi.fn(),
+  verifyPayment: vi.fn(),
+}))
+
+vi.mock('@/lib/backup/freeze', () => ({
+  isRestoring: vi.fn(() => false),
+  RESTORE_IN_PROGRESS_MESSAGE: 'A restore of this workshop is in progress.',
+}))
+vi.mock('@/lib/db', () => ({ db }))
+vi.mock('@/features/integrations/Lib/payments', () => payments)
+vi.mock('@/lib/payment-providers/record-payment', () => ({ recordVendorPayment }))
+// Paystack itself is the stand-in; the signature check and metadata reading stay real.
+vi.mock('@/lib/payment-providers/paystack', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('@/lib/payment-providers/paystack')>()
+  return {
+    ...actual,
+    PaystackProvider: class {
+      lookup(reference: string) {
+        return verifyPayment(reference)
+      }
+    },
+  }
+})
+
+import { POST } from '@/app/api/webhooks/paystack/route'
+
+const ORG = 'org_1'
+const KEY = 'sk_test_workshop'
+
+function chargeSuccess(overrides: Record<string, unknown> = {}) {
+  return {
+    event: 'charge.success',
+    data: {
+      reference: 'inv-sr_1-1',
+      status: 'success',
+      amount: 125050,
+      metadata: { serviceRecordId: 'sr_1', orgId: ORG },
+      ...overrides,
+    },
+  }
+}
+
+function post(event: unknown, key: string | null = KEY) {
+  const body = JSON.stringify(event)
+  const headers: Record<string, string> = { 'content-type': 'application/json' }
+  if (key) headers['x-paystack-signature'] = createHmac('sha512', key).update(body).digest('hex')
+  return POST(
+    new Request('http://app.test/api/webhooks/paystack', { method: 'POST', headers, body })
+  )
+}
+
+beforeEach(() => {
+  vi.clearAllMocks()
+  db.serviceRecord.findUnique.mockResolvedValue({ id: 'sr_1', organizationId: ORG })
+  verifyPayment.mockResolvedValue({
+    paid: true,
+    amount: 1250.5,
+    serviceRecordId: 'sr_1',
+    organizationId: ORG,
+  })
+  payments.paymentProviderFor.mockResolvedValue({
+    setup: { credentials: { secretKey: KEY } },
+    provider: { verifyPayment },
+  })
+  recordVendorPayment.mockResolvedValue({ id: 'pay_1', created: true })
+})
+
+describe('a Paystack notification', () => {
+  it('books the amount Paystack confirms, on the invoice the transaction was started for', async () => {
+    const response = await post(chargeSuccess())
+
+    expect(response.status).toBe(200)
+    expect(verifyPayment).toHaveBeenCalledWith('inv-sr_1-1')
+    expect(recordVendorPayment).toHaveBeenCalledWith({
+      amount: 1250.5,
+      method: 'paystack',
+      provider: 'paystack',
+      externalId: 'inv-sr_1-1',
+      serviceRecordId: 'sr_1',
+    })
+  })
+
+  it('is turned away when it was not signed with the workshop key', async () => {
+    const response = await post(chargeSuccess(), 'sk_test_somebody_else')
+    expect(response.status).toBe(400)
+    expect(verifyPayment).not.toHaveBeenCalled()
+    expect(recordVendorPayment).not.toHaveBeenCalled()
+  })
+
+  it('is turned away when it carries no signature', async () => {
+    const response = await post(chargeSuccess(), null)
+    expect(response.status).toBe(400)
+    expect(recordVendorPayment).not.toHaveBeenCalled()
+  })
+
+  it('books nothing when Paystack says the transaction was not paid, whatever the notification claims', async () => {
+    verifyPayment.mockResolvedValue({
+      paid: false,
+      amount: 0,
+      serviceRecordId: 'sr_1',
+      organizationId: ORG,
+    })
+    const response = await post(chargeSuccess())
+    expect(response.status).toBe(200)
+    expect(recordVendorPayment).not.toHaveBeenCalled()
+  })
+
+  it('books nothing when Paystack says the transaction belongs to another invoice', async () => {
+    verifyPayment.mockResolvedValue({
+      paid: true,
+      amount: 1250.5,
+      serviceRecordId: 'sr_other',
+      organizationId: ORG,
+    })
+    const response = await post(chargeSuccess())
+    expect(response.status).toBe(400)
+    expect(recordVendorPayment).not.toHaveBeenCalled()
+  })
+
+  it('quietly accepts a charge made elsewhere on the same Paystack account', async () => {
+    const response = await post(chargeSuccess({ metadata: {} }))
+    expect(response.status).toBe(200)
+    expect(payments.paymentProviderFor).not.toHaveBeenCalled()
+    expect(recordVendorPayment).not.toHaveBeenCalled()
+  })
+
+  it('quietly accepts events that are not a successful charge', async () => {
+    const response = await post({ event: 'transfer.success', data: {} })
+    expect(response.status).toBe(200)
+    expect(recordVendorPayment).not.toHaveBeenCalled()
+  })
+
+  it('asks Paystack to send it again when Paystack cannot be reached to confirm the charge', async () => {
+    verifyPayment.mockRejectedValue(new Error('Paystack verify failed: 503'))
+    const response = await post(chargeSuccess())
+    expect(response.status).toBe(503)
+    expect(recordVendorPayment).not.toHaveBeenCalled()
+  })
+
+  it('answers 400 for a workshop that has not connected Paystack', async () => {
+    payments.paymentProviderFor.mockResolvedValue(null)
+    const response = await post(chargeSuccess())
+    expect(response.status).toBe(400)
+    expect(recordVendorPayment).not.toHaveBeenCalled()
+  })
+})

+ 6 - 2
src/__tests__/features/integrations/payments.test.ts

@@ -61,7 +61,7 @@ beforeEach(() => {
 
 
 describe('payment catalog', () => {
 describe('payment catalog', () => {
   it('declares a vendor for every id the old enabled list could hold, in the order the buttons had', () => {
   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'])
+    expect(PAYMENT_VENDORS.map((v) => v.id)).toEqual(['stripe', 'vipps', 'paypal', 'paystack'])
   })
   })
 
 
   it('names every old row it needs to read, once', () => {
   it('names every old row it needs to read, once', () => {
@@ -84,6 +84,10 @@ describe('payment catalog', () => {
     expect(paymentWebhook('paypal', 'https://shop.example')?.url).toBe(
     expect(paymentWebhook('paypal', 'https://shop.example')?.url).toBe(
       'https://shop.example/api/webhooks/paypal'
       'https://shop.example/api/webhooks/paypal'
     )
     )
+    expect(paymentWebhook('paystack', 'https://shop.example')).toEqual({
+      url: 'https://shop.example/api/webhooks/paystack',
+      note: 'inboundHintPaystack',
+    })
     expect(paymentWebhook('zoom', 'https://shop.example')).toBeNull()
     expect(paymentWebhook('zoom', 'https://shop.example')).toBeNull()
     expect(paymentWebhook('stripe', '')).toBeNull()
     expect(paymentWebhook('stripe', '')).toBeNull()
   })
   })
@@ -316,7 +320,7 @@ describe('plan gates', () => {
     const { PLAN_FEATURES } = await import('@/lib/features')
     const { PLAN_FEATURES } = await import('@/lib/features')
     const { getManifest } = await import('@/integrations/registry')
     const { getManifest } = await import('@/integrations/registry')
 
 
-    for (const id of ['stripe', 'vipps', 'paypal']) {
+    for (const id of ['stripe', 'vipps', 'paypal', 'paystack']) {
       const manifest = getManifest(id)
       const manifest = getManifest(id)
       expect(manifest?.plan).toBe('payments')
       expect(manifest?.plan).toBe('payments')
       expect(manifest?.category).toBe('payments')
       expect(manifest?.category).toBe('payments')

+ 47 - 8
src/__tests__/features/public/invoice-view.test.tsx

@@ -271,7 +271,7 @@ describe('InvoiceView', () => {
       }
       }
       renderView(props)
       renderView(props)
       // Payment provider buttons should not render when balance is zero
       // Payment provider buttons should not render when balance is zero
-      expect(screen.queryByRole('button', { name: /pay.*card/i })).not.toBeInTheDocument()
+      expect(screen.queryByRole('button', { name: /pay with stripe/i })).not.toBeInTheDocument()
     })
     })
 
 
     it('hides payment provider buttons when balance is zero from payments', () => {
     it('hides payment provider buttons when balance is zero from payments', () => {
@@ -284,22 +284,36 @@ describe('InvoiceView', () => {
         },
         },
       }
       }
       renderView(props)
       renderView(props)
-      expect(screen.queryByRole('button', { name: /pay.*card/i })).not.toBeInTheDocument()
+      expect(screen.queryByRole('button', { name: /pay with stripe/i })).not.toBeInTheDocument()
     })
     })
 
 
     it('shows Stripe pay button when stripe provider is enabled', () => {
     it('shows Stripe pay button when stripe provider is enabled', () => {
       renderView({ ...DEFAULT_PROPS, enabledProviders: ['stripe'] })
       renderView({ ...DEFAULT_PROPS, enabledProviders: ['stripe'] })
-      expect(screen.getByRole('button', { name: /pay.*card/i })).toBeInTheDocument()
+      expect(screen.getByRole('button', { name: /pay with stripe/i })).toBeInTheDocument()
     })
     })
 
 
     it('shows Vipps pay button when vipps provider is enabled', () => {
     it('shows Vipps pay button when vipps provider is enabled', () => {
       renderView({ ...DEFAULT_PROPS, enabledProviders: ['vipps'] })
       renderView({ ...DEFAULT_PROPS, enabledProviders: ['vipps'] })
-      expect(screen.getByRole('button', { name: /pay.*vipps/i })).toBeInTheDocument()
+      expect(screen.getByRole('button', { name: /pay with vipps/i })).toBeInTheDocument()
     })
     })
 
 
     it('shows PayPal pay button when paypal provider is enabled', () => {
     it('shows PayPal pay button when paypal provider is enabled', () => {
       renderView({ ...DEFAULT_PROPS, enabledProviders: ['paypal'] })
       renderView({ ...DEFAULT_PROPS, enabledProviders: ['paypal'] })
-      expect(screen.getByRole('button', { name: /pay.*paypal/i })).toBeInTheDocument()
+      expect(screen.getByRole('button', { name: /pay with paypal/i })).toBeInTheDocument()
+    })
+
+    it('shows Paystack pay button when paystack provider is enabled', () => {
+      renderView({ ...DEFAULT_PROPS, enabledProviders: ['paystack'] })
+      expect(screen.getByRole('button', { name: /pay with paystack/i })).toBeInTheDocument()
+    })
+
+    it('shows the cards each vendor takes under its button, and none for Vipps', () => {
+      renderView({ ...DEFAULT_PROPS, enabledProviders: ['stripe', 'vipps'] })
+      expect(screen.getByTitle('Visa')).toBeInTheDocument()
+      expect(screen.getByTitle('Mastercard')).toBeInTheDocument()
+      expect(screen.getByTitle('American Express')).toBeInTheDocument()
+      // The button still reads as the whole sentence, mark and all.
+      expect(screen.getByRole('button', { name: /pay with vipps/i })).toBeInTheDocument()
     })
     })
 
 
     it('shows Full amount and Partial payment toggle buttons', () => {
     it('shows Full amount and Partial payment toggle buttons', () => {
@@ -328,7 +342,7 @@ describe('InvoiceView', () => {
       const input = screen.getByLabelText(/enter amount/i)
       const input = screen.getByLabelText(/enter amount/i)
       // Use fireEvent for number inputs to avoid userEvent quirks with controlled inputs
       // Use fireEvent for number inputs to avoid userEvent quirks with controlled inputs
       fireEvent.change(input, { target: { value: '0' } })
       fireEvent.change(input, { target: { value: '0' } })
-      await userEvent.click(screen.getByRole('button', { name: /pay.*card/i }))
+      await userEvent.click(screen.getByRole('button', { name: /pay with stripe/i }))
       await waitFor(() => {
       await waitFor(() => {
         expect(screen.getByText(/valid amount/i)).toBeInTheDocument()
         expect(screen.getByText(/valid amount/i)).toBeInTheDocument()
       })
       })
@@ -346,7 +360,7 @@ describe('InvoiceView', () => {
         assign: vi.fn(),
         assign: vi.fn(),
       })
       })
       renderView({ ...DEFAULT_PROPS, enabledProviders: ['stripe'] })
       renderView({ ...DEFAULT_PROPS, enabledProviders: ['stripe'] })
-      await userEvent.click(screen.getByRole('button', { name: /pay.*card/i }))
+      await userEvent.click(screen.getByRole('button', { name: /pay with stripe/i }))
 
 
       await waitFor(() => {
       await waitFor(() => {
         expect(mockFetch).toHaveBeenCalledWith(
         expect(mockFetch).toHaveBeenCalledWith(
@@ -368,7 +382,7 @@ describe('InvoiceView', () => {
         json: () => Promise.resolve({ error: 'Provider unavailable' }),
         json: () => Promise.resolve({ error: 'Provider unavailable' }),
       })
       })
       renderView({ ...DEFAULT_PROPS, enabledProviders: ['stripe'] })
       renderView({ ...DEFAULT_PROPS, enabledProviders: ['stripe'] })
-      await userEvent.click(screen.getByRole('button', { name: /pay.*card/i }))
+      await userEvent.click(screen.getByRole('button', { name: /pay with stripe/i }))
 
 
       await waitFor(() => {
       await waitFor(() => {
         expect(screen.getByText('Provider unavailable')).toBeInTheDocument()
         expect(screen.getByText('Provider unavailable')).toBeInTheDocument()
@@ -424,6 +438,31 @@ describe('InvoiceView', () => {
       })
       })
     })
     })
 
 
+    it('verifies a Paystack payment when the customer comes back from Paystack', async () => {
+      // Paystack appends trxref and reference to the return address, which
+      // is the name Vipps uses too; the marker says whose it is.
+      const search = '?paystack=return&trxref=inv-sr-1&reference=inv-sr-1'
+      vi.stubGlobal('location', {
+        search,
+        pathname: '/share/invoice/org-1/tok-xyz',
+        href: `http://localhost/share/invoice/org-1/tok-xyz${search}`,
+      })
+      vi.stubGlobal('history', { replaceState: vi.fn() })
+      mockFetch.mockResolvedValue({
+        ok: true,
+        json: () => Promise.resolve({ verified: true, amount: 200 }),
+      })
+      renderView({ ...DEFAULT_PROPS, enabledProviders: ['paystack', 'vipps'] })
+
+      await waitFor(() => {
+        expect(screen.getByText(/payment received/i)).toBeInTheDocument()
+      })
+      const call = mockFetch.mock.calls.find(
+        (c: unknown[]) => c[0] === '/api/public/share/invoice/org-1/tok-xyz/verify'
+      )
+      expect(JSON.parse(call![1].body)).toEqual({ provider: 'paystack', externalId: 'inv-sr-1' })
+    })
+
     it('shows PAID IN FULL when balance is zero', () => {
     it('shows PAID IN FULL when balance is zero', () => {
       const props = {
       const props = {
         ...DEFAULT_PROPS,
         ...DEFAULT_PROPS,

+ 267 - 0
src/__tests__/lib/payment-providers/paystack.test.ts

@@ -0,0 +1,267 @@
+/**
+ * @vitest-environment node
+ */
+import { createHmac } from 'node:crypto'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+  PaystackProvider,
+  paystackConfigFrom,
+  paystackSignatureValid,
+} from '@/lib/payment-providers/paystack'
+
+/**
+ * What Paystack is asked for and what is believed of its answer. A wrong
+ * amount charges a customer the wrong money, and a wrong reading of the
+ * answer books a payment nobody made, so both are pinned here.
+ */
+
+const fetchMock = vi.fn()
+
+function answer(status: number, body: unknown) {
+  return new Response(JSON.stringify(body), {
+    status,
+    headers: { 'content-type': 'application/json' },
+  })
+}
+
+const REQUEST = {
+  amount: 1250.5,
+  currency: 'zar',
+  invoiceNumber: 'INV-1001',
+  description: 'Payment for INV-1001 - Brake service',
+  successUrl: 'https://shop.example/share/invoice/org_1/tok',
+  cancelUrl: 'https://shop.example/share/invoice/org_1/tok',
+  serviceRecordId: 'sr_1',
+  orgId: 'org_1',
+  payerEmail: 'thandi@example.com',
+}
+
+beforeEach(() => {
+  fetchMock.mockReset()
+  vi.stubGlobal('fetch', fetchMock)
+  delete process.env.PAYSTACK_API_BASE_URL
+})
+
+afterEach(() => {
+  vi.unstubAllGlobals()
+})
+
+describe('starting a Paystack checkout', () => {
+  it('asks for the amount in cents, for this invoice, and sends the customer to the link Paystack gives', async () => {
+    fetchMock.mockResolvedValue(
+      answer(200, {
+        status: true,
+        data: {
+          authorization_url: 'https://checkout.paystack.com/abc',
+          reference: 'ref_from_paystack',
+        },
+      })
+    )
+
+    const result = await new PaystackProvider({ secretKey: 'sk_test_x' }).createCheckout(REQUEST)
+
+    expect(result).toEqual({
+      redirectUrl: 'https://checkout.paystack.com/abc',
+      externalId: 'ref_from_paystack',
+    })
+    const [url, init] = fetchMock.mock.calls[0]
+    expect(url).toBe('https://api.paystack.co/transaction/initialize')
+    expect(init.headers.Authorization).toBe('Bearer sk_test_x')
+    const sent = JSON.parse(init.body)
+    expect(sent).toMatchObject({
+      email: 'thandi@example.com',
+      amount: 125050,
+      currency: 'ZAR',
+      callback_url: 'https://shop.example/share/invoice/org_1/tok?paystack=return',
+      metadata: { serviceRecordId: 'sr_1', orgId: 'org_1', invoiceNumber: 'INV-1001' },
+    })
+    // Paystack allows only these characters in a reference.
+    expect(sent.reference).toMatch(/^[A-Za-z0-9.=-]+$/)
+  })
+
+  it('sends Paystack the invoice number and nothing a person typed into the work order', async () => {
+    fetchMock.mockResolvedValue(
+      answer(200, {
+        status: true,
+        data: { authorization_url: 'https://checkout.paystack.com/abc' },
+      })
+    )
+
+    await new PaystackProvider({ secretKey: 'sk_test_x' }).createCheckout({
+      ...REQUEST,
+      description: 'Payment for INV-1001 - <p>Brake <strong>service</strong></p>',
+    })
+
+    const raw = fetchMock.mock.calls[0][1].body as string
+    expect(raw).not.toContain('<')
+    expect(raw).not.toContain('Brake')
+    expect(JSON.parse(raw).metadata.custom_fields).toEqual([
+      { display_name: 'Invoice', variable_name: 'invoice', value: 'INV-1001' },
+    ])
+  })
+
+  it('refuses in plain words when there is no email to give Paystack', async () => {
+    await expect(
+      new PaystackProvider({ secretKey: 'sk_test_x' }).createCheckout({
+        ...REQUEST,
+        payerEmail: null,
+      })
+    ).rejects.toThrow(/needs an email address/)
+    expect(fetchMock).not.toHaveBeenCalled()
+  })
+
+  it('explains a refused currency in words a workshop can act on', async () => {
+    fetchMock.mockResolvedValue(
+      answer(400, {
+        status: false,
+        message: 'No active channel to process transaction. Please contact merchant',
+      })
+    )
+    await expect(
+      new PaystackProvider({ secretKey: 'sk_test_x' }).createCheckout({
+        ...REQUEST,
+        currency: 'nok',
+      })
+    ).rejects.toThrow(/cannot take payments in NOK.*workshop currency/)
+  })
+
+  it("passes on Paystack's own reason when it refuses, such as a currency the account cannot charge", async () => {
+    fetchMock.mockResolvedValue(
+      answer(400, { status: false, message: 'Currency not supported by merchant' })
+    )
+    await expect(
+      new PaystackProvider({ secretKey: 'sk_test_x' }).createCheckout(REQUEST)
+    ).rejects.toThrow('Paystack checkout failed: 400 Currency not supported by merchant')
+  })
+})
+
+describe('checking a Paystack payment', () => {
+  it('reports a successful transaction as paid, in the main unit, for the record it was started for', async () => {
+    fetchMock.mockResolvedValue(
+      answer(200, {
+        status: true,
+        data: {
+          status: 'success',
+          amount: 125050,
+          currency: 'ZAR',
+          reference: 'inv-sr_1-1',
+          metadata: { serviceRecordId: 'sr_1', orgId: 'org_1' },
+        },
+      })
+    )
+
+    const result = await new PaystackProvider({ secretKey: 'sk_test_x' }).verifyPayment(
+      'inv-sr_1-1'
+    )
+
+    expect(fetchMock.mock.calls[0][0]).toBe('https://api.paystack.co/transaction/verify/inv-sr_1-1')
+    expect(result).toEqual({
+      paid: true,
+      amount: 1250.5,
+      serviceRecordId: 'sr_1',
+      organizationId: 'org_1',
+    })
+  })
+
+  it.each([
+    'abandoned',
+    'failed',
+    'ongoing',
+    'pending',
+  ])('does not count a transaction that is %s as paid', async (status) => {
+    fetchMock.mockResolvedValue(
+      answer(200, {
+        status: true,
+        data: { status, amount: 125050, metadata: { serviceRecordId: 'sr_1', orgId: 'org_1' } },
+      })
+    )
+    const result = await new PaystackProvider({ secretKey: 'sk_test_x' }).verifyPayment('r')
+    expect(result?.paid).toBe(false)
+  })
+
+  it('reads metadata that comes back as a JSON string', async () => {
+    fetchMock.mockResolvedValue(
+      answer(200, {
+        status: true,
+        data: {
+          status: 'success',
+          amount: 5000,
+          metadata: JSON.stringify({ serviceRecordId: 'sr_9', orgId: 'org_9' }),
+        },
+      })
+    )
+    const result = await new PaystackProvider({ secretKey: 'sk_test_x' }).verifyPayment('r')
+    expect(result).toMatchObject({ serviceRecordId: 'sr_9', organizationId: 'org_9' })
+  })
+
+  it('names nobody when the transaction carries no metadata, so it cannot be booked on an invoice', async () => {
+    fetchMock.mockResolvedValue(
+      answer(200, { status: true, data: { status: 'success', amount: 5000, metadata: '' } })
+    )
+    const result = await new PaystackProvider({ secretKey: 'sk_test_x' }).verifyPayment('r')
+    expect(result).toMatchObject({ serviceRecordId: null, organizationId: null })
+  })
+
+  it('throws when Paystack cannot be asked, so a webhook can ask for a retry', async () => {
+    const provider = new PaystackProvider({ secretKey: 'sk_test_x' })
+    fetchMock.mockResolvedValue(answer(503, { status: false, message: 'Service unavailable' }))
+    await expect(provider.lookup('r')).rejects.toThrow('Paystack verify failed: 503')
+    fetchMock.mockResolvedValue(answer(429, { status: false, message: 'Too many requests' }))
+    await expect(provider.lookup('r')).rejects.toThrow('429')
+    fetchMock.mockRejectedValue(new TypeError('fetch failed'))
+    await expect(provider.lookup('r')).rejects.toThrow('fetch failed')
+    // The invoice page's check stays quiet and lets the customer try again.
+    await expect(provider.verifyPayment('r')).resolves.toBeNull()
+  })
+
+  it('answers nothing when Paystack does not know the reference', async () => {
+    fetchMock.mockResolvedValue(answer(404, { status: false, message: 'Transaction not found' }))
+    await expect(
+      new PaystackProvider({ secretKey: 'sk_test_x' }).verifyPayment('nope')
+    ).resolves.toBeNull()
+  })
+})
+
+describe('checking a secret key', () => {
+  it('accepts a key Paystack accepts', async () => {
+    fetchMock.mockResolvedValue(answer(200, { status: true, data: [] }))
+    await expect(
+      new PaystackProvider({ secretKey: 'sk_test_x' }).checkKey()
+    ).resolves.toBeUndefined()
+  })
+
+  it('says the key was rejected when Paystack answers 401', async () => {
+    fetchMock.mockResolvedValue(answer(401, { status: false, message: 'Invalid key' }))
+    await expect(new PaystackProvider({ secretKey: 'sk_test_bad' }).checkKey()).rejects.toThrow(
+      'Paystack rejected the secret key'
+    )
+  })
+
+  it('says so when the public key was pasted instead of the secret key', async () => {
+    await expect(new PaystackProvider({ secretKey: 'pk_test_abc' }).checkKey()).rejects.toThrow(
+      /That is the public key/
+    )
+    expect(fetchMock).not.toHaveBeenCalled()
+  })
+
+  it('needs a key before anything is connected', () => {
+    expect(paystackConfigFrom({ secretKey: '   ' })).toBeNull()
+    expect(paystackConfigFrom({ secretKey: ' sk_live_x ' })).toEqual({ secretKey: 'sk_live_x' })
+  })
+})
+
+describe('the signature on a Paystack notification', () => {
+  const body = JSON.stringify({ event: 'charge.success', data: { reference: 'r' } })
+  const signed = createHmac('sha512', 'sk_test_x').update(body).digest('hex')
+
+  it('passes when the body was signed with the workshop key', () => {
+    expect(paystackSignatureValid(body, signed, 'sk_test_x')).toBe(true)
+  })
+
+  it('fails for another key, a changed body, or no signature at all', () => {
+    expect(paystackSignatureValid(body, signed, 'sk_test_other')).toBe(false)
+    expect(paystackSignatureValid(`${body} `, signed, 'sk_test_x')).toBe(false)
+    expect(paystackSignatureValid(body, null, 'sk_test_x')).toBe(false)
+    expect(paystackSignatureValid(body, 'not-hex', 'sk_test_x')).toBe(false)
+  })
+})

+ 14 - 1
src/__tests__/lib/payment-vendor-hosts.test.ts

@@ -1,5 +1,5 @@
 import { afterEach, describe, expect, it } from 'vitest'
 import { afterEach, describe, expect, it } from 'vitest'
-import { paypalApiBase, stripeClient } from '@/lib/payment-providers/vendor-hosts'
+import { paypalApiBase, paystackApiBase, stripeClient } from '@/lib/payment-providers/vendor-hosts'
 
 
 /**
 /**
  * Where a server sends a workshop's payment keys.
  * Where a server sends a workshop's payment keys.
@@ -13,6 +13,7 @@ import { paypalApiBase, stripeClient } from '@/lib/payment-providers/vendor-host
 const ORIGINAL = {
 const ORIGINAL = {
   stripe: process.env.STRIPE_API_BASE_URL,
   stripe: process.env.STRIPE_API_BASE_URL,
   paypal: process.env.PAYPAL_API_BASE_URL,
   paypal: process.env.PAYPAL_API_BASE_URL,
+  paystack: process.env.PAYSTACK_API_BASE_URL,
 }
 }
 
 
 afterEach(() => {
 afterEach(() => {
@@ -20,6 +21,8 @@ afterEach(() => {
   else process.env.STRIPE_API_BASE_URL = ORIGINAL.stripe
   else process.env.STRIPE_API_BASE_URL = ORIGINAL.stripe
   if (ORIGINAL.paypal === undefined) delete process.env.PAYPAL_API_BASE_URL
   if (ORIGINAL.paypal === undefined) delete process.env.PAYPAL_API_BASE_URL
   else process.env.PAYPAL_API_BASE_URL = ORIGINAL.paypal
   else process.env.PAYPAL_API_BASE_URL = ORIGINAL.paypal
+  if (ORIGINAL.paystack === undefined) delete process.env.PAYSTACK_API_BASE_URL
+  else process.env.PAYSTACK_API_BASE_URL = ORIGINAL.paystack
 })
 })
 
 
 /** The host and port a Stripe client was configured with. */
 /** The host and port a Stripe client was configured with. */
@@ -45,6 +48,11 @@ describe('with nothing set, as in production', () => {
     expect(paypalApiBase(true)).toBe('https://api-m.sandbox.paypal.com')
     expect(paypalApiBase(true)).toBe('https://api-m.sandbox.paypal.com')
   })
   })
 
 
+  it('talks to Paystack itself', () => {
+    delete process.env.PAYSTACK_API_BASE_URL
+    expect(paystackApiBase()).toBe('https://api.paystack.co')
+  })
+
   it('treats a blank variable as nothing set', () => {
   it('treats a blank variable as nothing set', () => {
     process.env.STRIPE_API_BASE_URL = '   '
     process.env.STRIPE_API_BASE_URL = '   '
     process.env.PAYPAL_API_BASE_URL = ''
     process.env.PAYPAL_API_BASE_URL = ''
@@ -63,6 +71,11 @@ describe('with a stand-in configured', () => {
     })
     })
   })
   })
 
 
+  it('sends Paystack calls to it', () => {
+    process.env.PAYSTACK_API_BASE_URL = 'http://127.0.0.1:8026/'
+    expect(paystackApiBase()).toBe('http://127.0.0.1:8026')
+  })
+
   it('sends PayPal calls to it whichever mode the workshop picked', () => {
   it('sends PayPal calls to it whichever mode the workshop picked', () => {
     process.env.PAYPAL_API_BASE_URL = 'http://127.0.0.1:8026/'
     process.env.PAYPAL_API_BASE_URL = 'http://127.0.0.1:8026/'
     expect(paypalApiBase(false)).toBe('http://127.0.0.1:8026')
     expect(paypalApiBase(false)).toBe('http://127.0.0.1:8026')

+ 16 - 1
src/__tests__/setup.ts

@@ -3,6 +3,7 @@ import { vi } from 'vitest'
 import fs from 'fs'
 import fs from 'fs'
 import path from 'path'
 import path from 'path'
 import { IntlMessageFormat } from 'intl-messageformat'
 import { IntlMessageFormat } from 'intl-messageformat'
+import { Fragment, type ReactNode, createElement } from 'react'
 
 
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
 // Load all English translation files so the next-intl mock resolves keys to
 // Load all English translation files so the next-intl mock resolves keys to
@@ -89,7 +90,21 @@ vi.mock('next-intl', () => ({
     t.raw = (key: string) => resolve(messages, key) ?? key
     t.raw = (key: string) => resolve(messages, key) ?? key
     t.rich = (key: string, values?: Record<string, unknown>) => {
     t.rich = (key: string, values?: Record<string, unknown>) => {
       const tpl = resolve(messages, key)
       const tpl = resolve(messages, key)
-      return tpl ? interpolate(tpl, values) : key
+      if (!tpl) return key
+      const text = interpolate(tpl, values)
+      // Tags whose value is a function render through it, as in next-intl:
+      // "Pay with <vendor></vendor>" puts the vendor's mark where the tag is.
+      const parts: unknown[] = []
+      let last = 0
+      for (const m of text.matchAll(/<(\w+)>(.*?)<\/\1>/g)) {
+        const render = values?.[m[1]]
+        if (typeof render !== 'function') continue
+        parts.push(text.slice(last, m.index), render(m[2]))
+        last = (m.index ?? 0) + m[0].length
+      }
+      if (parts.length === 0) return text
+      parts.push(text.slice(last))
+      return createElement(Fragment, null, ...(parts as ReactNode[]))
     }
     }
     t.markup = (key: string) => resolve(messages, key) ?? key
     t.markup = (key: string) => resolve(messages, key) ?? key
     t.has = (key: string) => resolve(messages, key) !== undefined
     t.has = (key: string) => resolve(messages, key) !== undefined

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

@@ -25,6 +25,18 @@ import {
   getVisibleFieldsForSection,
   getVisibleFieldsForSection,
 } from '@/features/settings/Schema/invoiceLayoutSchema'
 } from '@/features/settings/Schema/invoiceLayoutSchema'
 import { SpecSheet } from '@/features/invoice-designer/Render/SpecSheet'
 import { SpecSheet } from '@/features/invoice-designer/Render/SpecSheet'
+import { type PayVendor, VendorPayButton } from '@/features/payments/Components/VendorPayButton'
+
+/** The vendors in the order their buttons stand, with the sentence each one is read out as. */
+const PAY_VENDORS: {
+  id: PayVendor
+  label: 'payWithCard' | 'payWithVipps' | 'payWithPaypal' | 'payWithPaystack'
+}[] = [
+  { id: 'stripe', label: 'payWithCard' },
+  { id: 'vipps', label: 'payWithVipps' },
+  { id: 'paypal', label: 'payWithPaypal' },
+  { id: 'paystack', label: 'payWithPaystack' },
+]
 import type { DocumentSpec } from '@/features/invoice-designer/Spec/documentSpec'
 import type { DocumentSpec } from '@/features/invoice-designer/Spec/documentSpec'
 
 
 interface InvoiceRecord {
 interface InvoiceRecord {
@@ -445,9 +457,15 @@ export function InvoiceView({
     // PayPal sends the order id back as "token" beside the marker we set.
     // PayPal sends the order id back as "token" beside the marker we set.
     const paypalOrderId = params.get('paypal') === 'return' ? params.get('token') : null
     const paypalOrderId = params.get('paypal') === 'return' ? params.get('token') : null
 
 
+    // Paystack comes back with a reference too, so its marker is read first.
+    const paystackReference = params.get('paystack') === 'return' ? reference : null
+
     if (sessionId) {
     if (sessionId) {
       verifyPayment('stripe', sessionId)
       verifyPayment('stripe', sessionId)
       window.history.replaceState({}, '', window.location.pathname)
       window.history.replaceState({}, '', window.location.pathname)
+    } else if (paystackReference) {
+      verifyPayment('paystack', paystackReference)
+      window.history.replaceState({}, '', window.location.pathname)
     } else if (reference) {
     } else if (reference) {
       verifyPayment('vipps', reference)
       verifyPayment('vipps', reference)
       window.history.replaceState({}, '', window.location.pathname)
       window.history.replaceState({}, '', window.location.pathname)
@@ -648,55 +666,25 @@ export function InvoiceView({
 
 
             {paymentError && <p className="mb-3 text-sm text-red-600">{paymentError}</p>}
             {paymentError && <p className="mb-3 text-sm text-red-600">{paymentError}</p>}
 
 
-            {/* Provider buttons */}
-            <div className="flex flex-wrap gap-3">
-              {enabledProviders.includes('stripe') && (
-                <button
-                  onClick={() => handlePayment('stripe')}
-                  disabled={paymentLoading !== null}
-                  className="inline-flex flex-1 items-center justify-center gap-2 rounded-lg bg-indigo-600 px-5 py-3 text-sm font-semibold text-white transition-colors hover:bg-indigo-700 disabled:opacity-50 sm:flex-none"
-                >
-                  {paymentLoading === 'stripe' ? (
-                    <Loader2 className="h-4 w-4 animate-spin" />
-                  ) : (
-                    <CreditCard className="h-4 w-4" />
-                  )}
-                  {t('payWithCard', {
-                    amount: formatCurrency(Number.parseFloat(paymentAmount) || 0, currencyCode),
-                  })}
-                </button>
-              )}
-              {enabledProviders.includes('vipps') && (
-                <button
-                  onClick={() => handlePayment('vipps')}
-                  disabled={paymentLoading !== null}
-                  className="inline-flex flex-1 items-center justify-center gap-2 rounded-lg bg-[#ff5b24] px-5 py-3 text-sm font-semibold text-white transition-colors hover:bg-[#e54e1c] disabled:opacity-50 sm:flex-none"
-                >
-                  {paymentLoading === 'vipps' ? (
-                    <Loader2 className="h-4 w-4 animate-spin" />
-                  ) : (
-                    <span className="text-base font-black leading-none">V</span>
-                  )}
-                  {t('payWithVipps', {
-                    amount: formatCurrency(Number.parseFloat(paymentAmount) || 0, currencyCode),
-                  })}
-                </button>
-              )}
-              {enabledProviders.includes('paypal') && (
-                <button
-                  onClick={() => handlePayment('paypal')}
-                  disabled={paymentLoading !== null}
-                  className="inline-flex flex-1 items-center justify-center gap-2 rounded-lg bg-[#0070ba] px-5 py-3 text-sm font-semibold text-white transition-colors hover:bg-[#005ea6] disabled:opacity-50 sm:flex-none"
-                >
-                  {paymentLoading === 'paypal' ? (
-                    <Loader2 className="h-4 w-4 animate-spin" />
-                  ) : (
-                    <span className="text-base font-black leading-none">P</span>
-                  )}
-                  {t('payWithPaypal', {
-                    amount: formatCurrency(Number.parseFloat(paymentAmount) || 0, currencyCode),
-                  })}
-                </button>
+            {/* Provider buttons, each drawn like the vendor's own checkout
+                button. Its name is what it shows, "Pay with PayPal 1,000.00". */}
+            <div className="flex flex-wrap gap-x-3 gap-y-4">
+              {PAY_VENDORS.filter((vendor) => enabledProviders.includes(vendor.id)).map(
+                (vendor) => {
+                  const amount = formatCurrency(Number.parseFloat(paymentAmount) || 0, currencyCode)
+                  return (
+                    <VendorPayButton
+                      key={vendor.id}
+                      vendor={vendor.id}
+                      caption={(mark) => t.rich('payWithVendor', { vendor: () => mark })}
+                      amount={amount}
+                      label={t(vendor.label, { amount })}
+                      loading={paymentLoading === vendor.id}
+                      disabled={paymentLoading !== null}
+                      onClick={() => handlePayment(vendor.id)}
+                    />
+                  )
+                }
               )}
               )}
             </div>
             </div>
           </div>
           </div>

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

@@ -13,6 +13,7 @@ import { resolvePortalOrg } from '@/lib/portal-slug'
 import { taxableSubtotalOf } from '@/lib/taxable-lines'
 import { taxableSubtotalOf } from '@/lib/taxable-lines'
 import { calculateTotals, discountAmountFor } from '@/lib/tax'
 import { calculateTotals, discountAmountFor } from '@/lib/tax'
 import { getFeatures } from '@/lib/features'
 import { getFeatures } from '@/lib/features'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 
 
 const checkoutSchema = z.object({
 const checkoutSchema = z.object({
   provider: z.enum(PAYMENT_CONNECTOR_IDS as [string, ...string[]]),
   provider: z.enum(PAYMENT_CONNECTOR_IDS as [string, ...string[]]),
@@ -39,8 +40,9 @@ export async function POST(
         payments: true,
         payments: true,
         partItems: true,
         partItems: true,
         laborItems: true,
         laborItems: true,
+        customer: { select: { email: true } },
         vehicle: {
         vehicle: {
-          select: { organizationId: true },
+          select: { organizationId: true, customer: { select: { email: true } } },
         },
         },
       },
       },
     })
     })
@@ -114,6 +116,20 @@ export async function POST(
     const { currencyCode } = await workshopCurrencySettings(orgId)
     const { currencyCode } = await workshopCurrencySettings(orgId)
     const invoiceNumber = record.invoiceNumber || `INV-${record.id.slice(-8).toUpperCase()}`
     const invoiceNumber = record.invoiceNumber || `INV-${record.id.slice(-8).toUpperCase()}`
 
 
+    // Some vendors will not start a payment without an email. A customer who
+    // has none still gets to pay: the workshop's own address stands in, and
+    // the vendor's receipt goes there.
+    const customerEmail = (record.customer ?? record.vehicle?.customer)?.email?.trim()
+    const payerEmail =
+      customerEmail ||
+      (
+        await db.appSetting.findFirst({
+          where: { organizationId: orgId, key: SETTING_KEYS.WORKSHOP_EMAIL },
+          select: { value: true },
+        })
+      )?.value?.trim() ||
+      null
+
     const appUrl = process.env.NEXT_PUBLIC_APP_URL || ''
     const appUrl = process.env.NEXT_PUBLIC_APP_URL || ''
     const invoiceUrl = `${appUrl}/share/invoice/${orgId}/${token}`
     const invoiceUrl = `${appUrl}/share/invoice/${orgId}/${token}`
 
 
@@ -127,6 +143,7 @@ export async function POST(
         cancelUrl: invoiceUrl,
         cancelUrl: invoiceUrl,
         serviceRecordId: record.id,
         serviceRecordId: record.id,
         orgId,
         orgId,
+        payerEmail,
       })
       })
       return NextResponse.json(result)
       return NextResponse.json(result)
     } catch (error) {
     } catch (error) {

+ 110 - 0
src/app/api/webhooks/paystack/route.ts

@@ -0,0 +1,110 @@
+import { NextResponse } from 'next/server'
+import { paymentProviderFor } from '@/features/integrations/Lib/payments'
+import { isRestoring } from '@/lib/backup/freeze'
+import { restoringResponse } from '@/lib/backup/freeze-response'
+import { db } from '@/lib/db'
+import { paymentMatchesRecord } from '@/lib/payment-providers/attribution'
+import {
+  PaystackProvider,
+  paystackAttribution,
+  paystackSignatureValid,
+} from '@/lib/payment-providers/paystack'
+import { recordVendorPayment } from '@/lib/payment-providers/record-payment'
+
+interface PaystackEvent {
+  event?: unknown
+  data?: { reference?: unknown; metadata?: unknown }
+}
+
+export async function POST(request: Request) {
+  try {
+    const body = await request.text()
+    const signature = request.headers.get('x-paystack-signature')
+
+    // Parsed without trusting it, only to learn which workshop this is for,
+    // so that workshop's key can check the signature. Nothing from the body
+    // is recorded: the amount and the record come from Paystack's own answer.
+    let event: PaystackEvent
+    try {
+      event = JSON.parse(body) as PaystackEvent
+    } catch {
+      return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
+    }
+
+    if (event.event !== 'charge.success') {
+      return NextResponse.json({ received: true })
+    }
+
+    const reference = typeof event.data?.reference === 'string' ? event.data.reference : ''
+    const claimed = paystackAttribution({ metadata: event.data?.metadata })
+    // A charge that carries no record of ours was made somewhere else on the
+    // workshop's Paystack account: a payment page, another shop. Not an error.
+    if (!reference || !claimed.organizationId || !claimed.serviceRecordId) {
+      return NextResponse.json({ received: true })
+    }
+    const orgId = claimed.organizationId
+    const serviceRecordId = claimed.serviceRecordId
+
+    const connected = await paymentProviderFor(orgId, 'paystack')
+    const secretKey = connected?.setup.credentials.secretKey
+    if (!connected || typeof secretKey !== 'string' || !secretKey) {
+      return NextResponse.json({ error: 'Paystack not configured for this org' }, { status: 400 })
+    }
+
+    // Paystack signs with the account's secret key, so a valid signature
+    // means the sender holds this workshop's key.
+    if (!paystackSignatureValid(body, signature, secretKey.trim())) {
+      return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
+    }
+
+    // A payment booked now would be lost under the restore; Paystack retries after it.
+    if (isRestoring(orgId)) return restoringResponse()
+
+    // Asked of Paystack itself, so the amount and the record are its word
+    // and not the notification's.
+    // A Paystack that cannot be reached is not an unpaid charge: answering
+    // with an error makes Paystack send the notification again later.
+    let result: Awaited<ReturnType<PaystackProvider['lookup']>>
+    try {
+      result = await new PaystackProvider({ secretKey: secretKey.trim() }).lookup(reference)
+    } catch (error) {
+      console.error(`[Paystack Webhook] Could not verify ${reference}; asking for a retry`, error)
+      return NextResponse.json({ error: 'Verification unavailable' }, { status: 503 })
+    }
+    if (!result || !result.paid) {
+      return NextResponse.json({ received: true, status: 'not_paid' })
+    }
+
+    if (!paymentMatchesRecord(result, { serviceRecordId, organizationId: orgId })) {
+      console.warn(
+        `[Paystack Webhook] Transaction ${reference} was not created for record ${serviceRecordId} of organization ${orgId}; ignoring`
+      )
+      return NextResponse.json(
+        { error: 'Transaction does not belong to this record' },
+        { status: 400 }
+      )
+    }
+
+    const record = await db.serviceRecord.findUnique({
+      where: { id: serviceRecordId },
+      select: { id: true, organizationId: true },
+    })
+
+    if (record && record.organizationId === orgId) {
+      // Once, however many reports arrive together: Paystack retries, and
+      // the customer coming back to the invoice reports the same reference.
+      await recordVendorPayment({
+        amount: result.amount,
+        method: 'paystack',
+        provider: 'paystack',
+        externalId: reference,
+        serviceRecordId,
+      })
+    }
+
+    return NextResponse.json({ received: true })
+  } catch (error) {
+    console.error('[Paystack Webhook] Error:', error)
+    return NextResponse.json({ error: 'Webhook processing failed' }, { status: 500 })
+  }
+}

+ 1 - 1
src/features/integrations/Lib/payments.ts

@@ -123,7 +123,7 @@ export async function legacyPaymentSetups(
 
 
     const credentials: Record<string, string> = {}
     const credentials: Record<string, string> = {}
     for (const field of vendor.credentials) {
     for (const field of vendor.credentials) {
-      const value = values.get(field.legacy)?.trim()
+      const value = field.legacy ? values.get(field.legacy)?.trim() : undefined
       if (value) credentials[field.key] = value
       if (value) credentials[field.key] = value
     }
     }
     if (vendor.credentials.some((f) => f.required && !credentials[f.key])) continue
     if (vendor.credentials.some((f) => f.required && !credentials[f.key])) continue

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 130 - 0
src/features/payments/Components/VendorPayButton.tsx


+ 9 - 1
src/features/settings/Lib/searchIndex/payment.ts

@@ -35,7 +35,15 @@ export const paymentEntries: SettingsEntry[] = [
     title: 'payment.onlinePayments',
     title: 'payment.onlinePayments',
     description: 'payment.onlinePaymentsDescription',
     description: 'payment.onlinePaymentsDescription',
     anchor: 'payment-online',
     anchor: 'payment-online',
-    keywords: ['online payments', 'card payments', 'stripe', 'vipps', 'paypal', 'pay online'],
+    keywords: [
+      'online payments',
+      'card payments',
+      'stripe',
+      'vipps',
+      'paypal',
+      'paystack',
+      'pay online',
+    ],
   },
   },
   {
   {
     id: 'payment.termsOfSale',
     id: 'payment.termsOfSale',

+ 30 - 4
src/integrations/payments/catalog.ts

@@ -18,9 +18,12 @@
 import type { CredentialField, SettingField } from '@/features/integrations/Lib/types'
 import type { CredentialField, SettingField } from '@/features/integrations/Lib/types'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 
 
-/** A credential field plus the settings row it used to be stored in. */
+/**
+ * A credential field plus the settings row it used to be stored in. A vendor
+ * added after the move never had such a row.
+ */
 export interface PaymentCredential extends CredentialField {
 export interface PaymentCredential extends CredentialField {
-  legacy: string
+  legacy?: string
 }
 }
 
 
 export interface PaymentSetting extends SettingField {
 export interface PaymentSetting extends SettingField {
@@ -41,7 +44,11 @@ export interface PaymentVendor {
    */
    */
   webhookPath: string
   webhookPath: string
   /** i18n key under integrations.connection describing what to do with the URL. */
   /** i18n key under integrations.connection describing what to do with the URL. */
-  webhookNote: 'inboundHintStripe' | 'inboundHintVipps' | 'inboundHintPaypal'
+  webhookNote:
+    | 'inboundHintStripe'
+    | 'inboundHintVipps'
+    | 'inboundHintPaypal'
+    | 'inboundHintPaystack'
 }
 }
 
 
 /** The value of `payment.providersEnabled` before the move, as a list. */
 /** The value of `payment.providersEnabled` before the move, as a list. */
@@ -179,6 +186,25 @@ export const PAYMENT_VENDORS: readonly PaymentVendor[] = [
     webhookPath: '/api/webhooks/paypal',
     webhookPath: '/api/webhooks/paypal',
     webhookNote: 'inboundHintPaypal',
     webhookNote: 'inboundHintPaypal',
   },
   },
+  {
+    // Added after the move, so there are no old rows to adopt. The key itself
+    // says test or live (sk_test_ or sk_live_), so there is no mode switch.
+    id: 'paystack',
+    name: 'Paystack',
+    credentials: [
+      {
+        key: 'secretKey',
+        label: 'secretKey',
+        type: 'password',
+        required: true,
+        placeholder: 'sk_live_...',
+        help: 'secretKeyHelp',
+      },
+    ],
+    settings: [OFFERED_SETTING],
+    webhookPath: '/api/webhooks/paystack',
+    webhookNote: 'inboundHintPaystack',
+  },
 ]
 ]
 
 
 export const PAYMENT_CONNECTOR_IDS = PAYMENT_VENDORS.map((v) => v.id)
 export const PAYMENT_CONNECTOR_IDS = PAYMENT_VENDORS.map((v) => v.id)
@@ -195,7 +221,7 @@ export function isPaymentConnector(connectorId: string): boolean {
 export function legacyPaymentKeys(): string[] {
 export function legacyPaymentKeys(): string[] {
   const keys = new Set<string>([LEGACY_ENABLED_KEY])
   const keys = new Set<string>([LEGACY_ENABLED_KEY])
   for (const vendor of PAYMENT_VENDORS) {
   for (const vendor of PAYMENT_VENDORS) {
-    for (const c of vendor.credentials) keys.add(c.legacy)
+    for (const c of vendor.credentials) if (c.legacy) keys.add(c.legacy)
     for (const s of vendor.settings) if (s.legacy) keys.add(s.legacy)
     for (const s of vendor.settings) if (s.legacy) keys.add(s.legacy)
   }
   }
   return [...keys]
   return [...keys]

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

@@ -0,0 +1,10 @@
+import { paymentManifest } from '../payments/build'
+
+/**
+ * Paystack checkout from the invoice link, into the workshop's own Paystack
+ * account. Paystack only opens accounts for businesses in these countries.
+ */
+export const manifest = paymentManifest('paystack', {
+  countries: ['NG', 'GH', 'ZA', 'KE', 'CI'],
+  logo: '/images/integrations/paystack.svg',
+})

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

@@ -0,0 +1,31 @@
+import type { ConnectorContext, ConnectorServer } from '@/features/integrations/Lib/types'
+import { PaystackProvider, paystackConfigFrom } from '@/lib/payment-providers/paystack'
+import { manifest } from './manifest'
+
+function providerOf(ctx: ConnectorContext): PaystackProvider | null {
+  const config = paystackConfigFrom(ctx.credentials)
+  return config ? new PaystackProvider(config) : null
+}
+
+/**
+ * Reading one row of the account's own transactions is the cheapest call
+ * that proves a secret key. A test key and a live key both pass: which one
+ * it is shows in the key itself, and a workshop is meant to try a payment on
+ * the test key first.
+ */
+export const connector: ConnectorServer = {
+  manifest,
+
+  async test(ctx) {
+    const provider = providerOf(ctx)
+    if (!provider) return { ok: false, message: 'Paystack: a secret key is required' }
+    try {
+      await provider.checkKey()
+      return { ok: true }
+    } catch (err) {
+      return { ok: false, message: err instanceof Error ? err.message : 'Paystack did not answer' }
+    }
+  },
+
+  jobs: {},
+}

+ 2 - 0
src/integrations/registry.ts

@@ -23,6 +23,7 @@ import { manifest as openaiCompatible } from './openai-compatible/manifest'
 import { manifest as openapiAutomotive } from './openapi-automotive/manifest'
 import { manifest as openapiAutomotive } from './openapi-automotive/manifest'
 import { manifest as openLaborProject } from './openlaborproject/manifest'
 import { manifest as openLaborProject } from './openlaborproject/manifest'
 import { manifest as paypal } from './paypal/manifest'
 import { manifest as paypal } from './paypal/manifest'
+import { manifest as paystack } from './paystack/manifest'
 import { manifest as postmark } from './postmark/manifest'
 import { manifest as postmark } from './postmark/manifest'
 import { manifest as quickbooks } from './quickbooks/manifest'
 import { manifest as quickbooks } from './quickbooks/manifest'
 import { manifest as xero } from './xero/manifest'
 import { manifest as xero } from './xero/manifest'
@@ -81,6 +82,7 @@ const ALL_ENTRIES: readonly RegistryEntry[] = [
   { manifest: stripe, load: () => import('./stripe/server') },
   { manifest: stripe, load: () => import('./stripe/server') },
   { manifest: vipps, load: () => import('./vipps/server') },
   { manifest: vipps, load: () => import('./vipps/server') },
   { manifest: paypal, load: () => import('./paypal/server') },
   { manifest: paypal, load: () => import('./paypal/server') },
+  { manifest: paystack, load: () => import('./paystack/server') },
   { manifest: quickbooks, load: () => import('./quickbooks/server') },
   { manifest: quickbooks, load: () => import('./quickbooks/server') },
   { manifest: xero, load: () => import('./xero/server') },
   { manifest: xero, load: () => import('./xero/server') },
   { manifest: fiken, load: () => import('./fiken/server') },
   { manifest: fiken, load: () => import('./fiken/server') },

+ 5 - 0
src/lib/payment-providers/index.ts

@@ -2,6 +2,7 @@ import type { PaymentProvider } from './types'
 import { StripeProvider } from './stripe'
 import { StripeProvider } from './stripe'
 import { VippsProvider, vippsConfigFrom } from './vipps'
 import { VippsProvider, vippsConfigFrom } from './vipps'
 import { PayPalProvider, paypalConfigFrom } from './paypal'
 import { PayPalProvider, paypalConfigFrom } from './paypal'
+import { PaystackProvider, paystackConfigFrom } from './paystack'
 
 
 export type { PaymentProvider, CheckoutRequest, CheckoutResult, VerifyResult } from './types'
 export type { PaymentProvider, CheckoutRequest, CheckoutResult, VerifyResult } from './types'
 
 
@@ -30,6 +31,10 @@ export function buildPaymentProvider(
       const config = paypalConfigFrom(credentials, settings)
       const config = paypalConfigFrom(credentials, settings)
       return config ? new PayPalProvider(config) : null
       return config ? new PayPalProvider(config) : null
     }
     }
+    case 'paystack': {
+      const config = paystackConfigFrom(credentials)
+      return config ? new PaystackProvider(config) : null
+    }
     default:
     default:
       return null
       return null
   }
   }

+ 224 - 0
src/lib/payment-providers/paystack.ts

@@ -0,0 +1,224 @@
+import { createHmac, timingSafeEqual } from 'node:crypto'
+import type { CheckoutRequest, CheckoutResult, PaymentProvider, VerifyResult } from './types'
+import { fromPaystackAmount, toPaystackAmount } from './vendor-amounts'
+import { paystackApiBase } from './vendor-hosts'
+
+export interface PaystackConfig {
+  secretKey: string
+}
+
+function text(value: unknown): string {
+  return typeof value === 'string' ? value.trim() : ''
+}
+
+/** The config a connection's credentials amount to, or null without a secret key. */
+export function paystackConfigFrom(credentials: Record<string, unknown>): PaystackConfig | null {
+  const secretKey = text(credentials.secretKey)
+  return secretKey ? { secretKey } : null
+}
+
+/** What Paystack said went wrong, in its own words when it gave any. */
+async function paystackError(res: Response, what: string): Promise<Error> {
+  let message = ''
+  try {
+    const body = (await res.json()) as { message?: unknown }
+    message = text(body.message)
+  } catch {
+    // Not JSON; the status is all there is.
+  }
+  return new Error(`Paystack ${what} failed: ${res.status}${message ? ` ${message}` : ''}`)
+}
+
+export class PaystackProvider implements PaymentProvider {
+  private config: PaystackConfig
+  private baseUrl: string
+
+  constructor(config: PaystackConfig) {
+    this.config = config
+    this.baseUrl = paystackApiBase()
+  }
+
+  private headers(): Record<string, string> {
+    return {
+      Authorization: `Bearer ${this.config.secretKey}`,
+      'Content-Type': 'application/json',
+    }
+  }
+
+  /**
+   * The cheapest call that proves a secret key: one row of the account's own
+   * transactions. Paystack answers a bad key with 401.
+   */
+  async checkKey(): Promise<void> {
+    // The dashboard shows the public key in the clear and the secret key
+    // masked, so the public one is the easy one to copy by mistake.
+    if (this.config.secretKey.startsWith('pk_')) {
+      throw new Error(
+        'That is the public key. Paystack needs the secret key, which starts with sk_test_ or sk_live_.'
+      )
+    }
+    const res = await fetch(`${this.baseUrl}/transaction?perPage=1`, { headers: this.headers() })
+    if (res.status === 401) throw new Error('Paystack rejected the secret key')
+    if (!res.ok) throw await paystackError(res, 'key check')
+  }
+
+  async createCheckout(req: CheckoutRequest): Promise<CheckoutResult> {
+    // Paystack refuses a transaction without an email; the checkout route
+    // falls back to the workshop's own when the customer has none.
+    if (!req.payerEmail) {
+      throw new Error(
+        'Paystack needs an email address for the payer. Add one to the customer, or set the workshop email under Settings.'
+      )
+    }
+
+    // Paystack allows only -, ., = and alphanumerics. The reference is just
+    // a unique name; which invoice it pays for is carried in the metadata.
+    const reference = `inv-${req.serviceRecordId.replace(/[^A-Za-z0-9.=-]/g, '')}-${Date.now()}`
+
+    const res = await fetch(`${this.baseUrl}/transaction/initialize`, {
+      method: 'POST',
+      headers: this.headers(),
+      body: JSON.stringify({
+        email: req.payerEmail,
+        amount: toPaystackAmount(req.amount),
+        currency: req.currency.toUpperCase(),
+        reference,
+        // Paystack appends trxref and reference to whatever is given. The
+        // marker tells the invoice page which vendor it is back from, since
+        // Vipps comes back with a reference too.
+        callback_url: `${req.successUrl}?paystack=return`,
+        metadata: {
+          serviceRecordId: req.serviceRecordId,
+          orgId: req.orgId,
+          invoiceNumber: req.invoiceNumber,
+          // What the workshop sees on the transaction in its Paystack
+          // dashboard. The invoice number only: titles and notes are written
+          // in a rich text editor and are not sent anywhere.
+          custom_fields: [
+            { display_name: 'Invoice', variable_name: 'invoice', value: req.invoiceNumber },
+          ],
+          // Where the customer goes when they close the checkout unpaid.
+          cancel_action: req.cancelUrl,
+        },
+      }),
+    })
+
+    if (!res.ok) {
+      const error = await paystackError(res, 'checkout')
+      // Paystack's answer when the account has no way to take this currency.
+      // An account charges in its own country's currency, so this is almost
+      // always the workshop currency not matching the Paystack account.
+      if (/no active channel/i.test(error.message)) {
+        throw new Error(
+          `${error.message}. This Paystack account cannot take payments in ${req.currency.toUpperCase()}. A Paystack account charges in the currency of the country it was opened in, so the workshop currency under Settings, Localization must match it.`
+        )
+      }
+      throw error
+    }
+
+    const body = (await res.json()) as {
+      status?: boolean
+      message?: string
+      data?: { authorization_url?: string; reference?: string }
+    }
+    const url = body.data?.authorization_url
+    if (!body.status || !url) {
+      throw new Error(
+        `Paystack did not return a checkout link${body.message ? `: ${body.message}` : ''}`
+      )
+    }
+
+    return { redirectUrl: url, externalId: body.data?.reference || reference }
+  }
+
+  async verifyPayment(reference: string): Promise<VerifyResult | null> {
+    try {
+      return await this.lookup(reference)
+    } catch {
+      return null
+    }
+  }
+
+  /**
+   * What Paystack says about a transaction. Null when Paystack does not
+   * know the reference, which is a final answer. Throws when Paystack could
+   * not be asked (network, 429, 5xx), so a webhook can answer with an error
+   * and get the notification again, instead of losing a paid charge.
+   */
+  async lookup(reference: string): Promise<VerifyResult | null> {
+    const res = await fetch(`${this.baseUrl}/transaction/verify/${encodeURIComponent(reference)}`, {
+      headers: this.headers(),
+    })
+    if (res.status === 429 || res.status >= 500) throw await paystackError(res, 'verify')
+    if (!res.ok) return null
+
+    const body = (await res.json()) as { status?: boolean; data?: PaystackTransaction }
+    const data = body.data
+    if (!body.status || !data) return null
+
+    return {
+      paid: data.status === 'success',
+      amount: fromPaystackAmount(typeof data.amount === 'number' ? data.amount : 0),
+      ...paystackAttribution(data),
+    }
+  }
+}
+
+interface PaystackTransaction {
+  status?: unknown
+  amount?: unknown
+  reference?: unknown
+  metadata?: unknown
+}
+
+function textOrNull(value: unknown): string | null {
+  return typeof value === 'string' && value.trim() ? value.trim() : null
+}
+
+/**
+ * Who a transaction was created for, from the metadata createCheckout sent.
+ * Paystack hands metadata back as an object, or as a JSON string on some
+ * responses, so both are read.
+ */
+export function paystackAttribution(data: PaystackTransaction): {
+  serviceRecordId: string | null
+  organizationId: string | null
+} {
+  let metadata: unknown = data.metadata
+  if (typeof metadata === 'string') {
+    try {
+      metadata = JSON.parse(metadata)
+    } catch {
+      metadata = null
+    }
+  }
+  const fields = (metadata && typeof metadata === 'object' ? metadata : {}) as {
+    serviceRecordId?: unknown
+    orgId?: unknown
+  }
+  return {
+    serviceRecordId: textOrNull(fields.serviceRecordId),
+    organizationId: textOrNull(fields.orgId),
+  }
+}
+
+/**
+ * Whether a notification was signed by the holder of this secret key.
+ * Paystack signs the raw body with HMAC SHA512 and sends the hex digest in
+ * `x-paystack-signature`.
+ */
+export function paystackSignatureValid(
+  rawBody: string,
+  signature: string | null,
+  secretKey: string
+): boolean {
+  if (!signature || !secretKey) return false
+  const expected = createHmac('sha512', secretKey).update(rawBody).digest()
+  let given: Buffer
+  try {
+    given = Buffer.from(signature.trim(), 'hex')
+  } catch {
+    return false
+  }
+  return given.length === expected.length && timingSafeEqual(given, expected)
+}

+ 5 - 0
src/lib/payment-providers/types.ts

@@ -7,6 +7,11 @@ export interface CheckoutRequest {
   cancelUrl: string
   cancelUrl: string
   serviceRecordId: string
   serviceRecordId: string
   orgId: string
   orgId: string
+  /**
+   * An email for the payer, for vendors that will not start a payment
+   * without one: the customer's, or the workshop's own when they have none.
+   */
+  payerEmail?: string | null
 }
 }
 
 
 export interface CheckoutResult {
 export interface CheckoutResult {

+ 15 - 0
src/lib/payment-providers/vendor-amounts.ts

@@ -81,3 +81,18 @@ export function toPayPalValue(amount: number, currency: string): string {
     ? String(Math.round(amount))
     ? String(Math.round(amount))
     : amount.toFixed(2)
     : amount.toFixed(2)
 }
 }
+
+/**
+ * Paystack takes every amount in the currency's subunit, a hundredth of the
+ * main unit, whatever the currency: kobo, pesewa, cents. From the Initialize
+ * Transaction reference: "Amount should be in the subunit of the supported
+ * currency".
+ */
+export function toPaystackAmount(amount: number): number {
+  return Math.round(amount * 100)
+}
+
+/** The integer Paystack reports, back in the app's terms. */
+export function fromPaystackAmount(amount: number): number {
+  return amount / 100
+}

+ 9 - 2
src/lib/payment-providers/vendor-hosts.ts

@@ -3,8 +3,8 @@ import Stripe from 'stripe'
 /**
 /**
  * Where the payment vendors' APIs are, for this server.
  * Where the payment vendors' APIs are, for this server.
  *
  *
- * Always the vendors' own hosts in production. `STRIPE_API_BASE_URL` and
- * `PAYPAL_API_BASE_URL` point a server at a stand-in instead: the end-to-end
+ * Always the vendors' own hosts in production. `STRIPE_API_BASE_URL`,
+ * `PAYPAL_API_BASE_URL` and `PAYSTACK_API_BASE_URL` point a server at a stand-in instead: the end-to-end
  * suite's fake vendor (`e2e/payment-sink.ts`), or stripe-mock on a
  * suite's fake vendor (`e2e/payment-sink.ts`), or stripe-mock on a
  * developer's machine. They are read from the environment only. A workshop
  * developer's machine. They are read from the environment only. A workshop
  * cannot set them, so no tenant can send its keys, or anyone else's,
  * cannot set them, so no tenant can send its keys, or anyone else's,
@@ -29,3 +29,10 @@ export function paypalApiBase(useSandbox: boolean): string {
   if (base) return base.replace(/\/+$/, '')
   if (base) return base.replace(/\/+$/, '')
   return useSandbox ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com'
   return useSandbox ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com'
 }
 }
+
+/** Paystack's REST base, the same for test and live keys, unless a stand-in is configured. */
+export function paystackApiBase(): string {
+  const base = process.env.PAYSTACK_API_BASE_URL?.trim()
+  if (base) return base.replace(/\/+$/, '')
+  return 'https://api.paystack.co'
+}

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно