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

Online payments are booked once, with end-to-end tests for Stripe and PayPal (#377)

A customer's payment is reported twice by design: their browser comes back to
the invoice, and the vendor sends a notification, usually in the same second.
Every path that records a payment looked for it first and wrote it second, so
two reports arriving together could both write a row and the invoice showed
twice what was paid. Against the running app, with nothing to stop it, every
payment in the test was booked four times.

Payments now carry a unique key on (serviceRecordId, provider, externalId), and
recordVendorPayment writes with skipDuplicates, so the losing report is handed
the existing row instead of raising an error. The return page, the Stripe,
PayPal and Vipps webhooks and the QuickBooks pull all use it, and only the
report that wrote the payment sends the notification. The migration removes any
duplicates already written, keeping the earliest, before creating the index.
Rows with no provider, such as payments typed in by hand or imported, are not
held to the key.

e2e/payment-sink.ts stands in for Stripe and PayPal, reached through
STRIPE_API_BASE_URL and PAYPAL_API_BASE_URL, which only the environment can set.
checkout.spec.ts connects both vendors, shares an invoice, pays part by card
and the rest through PayPal, and holds the books to one payment each through
repeated reports, an unsigned webhook, an abandoned checkout and an overpayment.
booked-once.spec.ts proves the race: the database refuses a second row, and
payments reported by the browser and three webhooks at once end with one row.

Also fixed: an announcement card could be drawn with its only button below the
bottom of a laptop screen. It now brings the row it points at into view and
stays inside the window. The suite's helpers wait out the redirect from a new
work order, name the dialogs they look for, and close announcements at setup.
Bernt Christian Egeland 2 недель назад
Родитель
Сommit
4e6793da2f

+ 2 - 0
e2e/README.md

@@ -12,6 +12,7 @@ e2e/
   auth.setup.ts        signs in once; every spec starts with that session
   prepare-db.ts        reset + seed, run ahead of the server
   mail-sink.ts         a mail server that delivers nothing and keeps everything
+  payment-sink.ts      Stripe and PayPal as far as the app can tell; no money moves
   support/             helpers specs share: reading mail, reading a PDF's text,
                        database peeks, TOTP, work order driving
   specs/
@@ -28,6 +29,7 @@ e2e/
     inventory/         a stocked part leaves the shelf exactly once
     reminders/         a due time survives being displayed and re-saved
     email/             the email template designer, and the mail it sends
+    payments/          a customer pays online, and the payment is booked once
     tech/              the technician app's API contract
     smoke/             the build is alive
 ```

+ 18 - 0
e2e/auth.setup.ts

@@ -22,6 +22,24 @@ setup('sign in as the workshop owner', async ({ page, context }) => {
   await page.waitForURL((url) => !url.pathname.startsWith('/auth'), { timeout: 30_000 })
   await expect(page.locator('#password')).toHaveCount(0)
 
+  // A workshop that predates a feature is greeted with an announcement card
+  // on its first page ("Your emails have a new look"). It is a non-modal
+  // dialog, so a spec looking for "the dialog" finds it as well as its own.
+  // Closed here through its own button, which the app records for the whole
+  // workshop, so no spec starts with one on screen. Announcements queue one
+  // at a time, hence the loop.
+  const gotIt = page.getByRole('button', { name: 'Got it', exact: true })
+  for (let shown = 0; shown < 5; shown++) {
+    const open = await gotIt
+      .first()
+      .waitFor({ state: 'visible', timeout: 3_000 })
+      .then(() => true)
+      .catch(() => false)
+    if (!open) break
+    await gotIt.first().click()
+    await expect(gotIt).toHaveCount(0, { timeout: 10_000 })
+  }
+
   // The app reads its language from this cookie before Accept-Language. Set
   // here rather than per test so the saved state carries it everywhere.
   const { hostname } = new URL(page.url())

+ 371 - 0
e2e/payment-sink.ts

@@ -0,0 +1,371 @@
+import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
+
+/**
+ * A payment vendor that moves no money.
+ *
+ * Stripe and PayPal are reached over the network with a workshop's own keys,
+ * and neither can be used from a test run: no account to charge, and a hosted
+ * checkout page that is theirs to change. So this stands in for both, speaking
+ * just the calls the app makes, with the same shapes the vendors answer with,
+ * and a checkout page of its own where a spec clicks "Pay" the way a customer
+ * would. The app is pointed here by STRIPE_API_BASE_URL and
+ * PAYPAL_API_BASE_URL, which only the environment can set.
+ *
+ * It keeps what it is given in memory and hands it back over /state, so a spec
+ * can check that the amount a customer was charged is the amount the invoice
+ * showed. A key or secret containing "wrong" is refused, the way a vendor
+ * refuses one it does not know. Run on its own with `npx tsx e2e/payment-sink.ts`.
+ */
+
+const PORT = Number(process.env.E2E_PAYMENT_PORT ?? 8026)
+const SELF = `http://127.0.0.1:${PORT}`
+
+interface StripeSession {
+  id: string
+  object: 'checkout.session'
+  mode: 'payment'
+  status: 'open' | 'complete' | 'expired'
+  payment_status: 'unpaid' | 'paid'
+  amount_total: number
+  currency: string
+  metadata: Record<string, string>
+  success_url: string
+  cancel_url: string
+  url: string
+}
+
+interface PayPalOrder {
+  id: string
+  intent: 'CAPTURE'
+  status: 'PAYER_ACTION_REQUIRED' | 'APPROVED' | 'COMPLETED'
+  amount: { currency_code: string; value: string }
+  custom_id: string
+  invoice_id: string
+  return_url: string
+  cancel_url: string
+}
+
+const state = {
+  stripe: [] as StripeSession[],
+  paypal: [] as PayPalOrder[],
+  /** Every request the app made, oldest first: "POST /v1/checkout/sessions". */
+  calls: [] as string[],
+}
+let counter = 0
+/**
+ * Stamped into every id, because the counter starts again with each run and
+ * the database does not. A session called `cs_test_e2e_5` in this run is not
+ * the one of that name an earlier run paid, and the app keys payments on
+ * these ids exactly as it keys them on Stripe's and PayPal's.
+ */
+const RUN = Date.now().toString(36)
+
+function json(res: ServerResponse, status: number, body: unknown): void {
+  res.writeHead(status, { 'content-type': 'application/json' })
+  res.end(JSON.stringify(body))
+}
+
+function html(res: ServerResponse, body: string): void {
+  res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
+  res.end(
+    `<!doctype html><html><head><meta charset="utf-8"><title>E2E checkout</title></head><body>${body}</body></html>`
+  )
+}
+
+function redirect(res: ServerResponse, to: string): void {
+  res.writeHead(303, { location: to })
+  res.end()
+}
+
+async function readBody(req: IncomingMessage): Promise<string> {
+  const chunks: Buffer[] = []
+  for await (const chunk of req) chunks.push(chunk as Buffer)
+  return Buffer.concat(chunks).toString('utf8')
+}
+
+/**
+ * Stripe's form encoding, `metadata[orgId]=…&line_items[0][price_data][unit_amount]=…`,
+ * as the nested object it describes.
+ */
+function parseStripeForm(body: string): Record<string, unknown> {
+  const out: Record<string, unknown> = {}
+  for (const [key, value] of new URLSearchParams(body)) {
+    const path = key.split(/[[\]]+/).filter(Boolean)
+    let node = out
+    path.forEach((segment, i) => {
+      if (i === path.length - 1) {
+        node[segment] = value
+      } else {
+        node[segment] = (node[segment] as Record<string, unknown>) ?? {}
+        node = node[segment] as Record<string, unknown>
+      }
+    })
+  }
+  return out
+}
+
+function stripeRefuses(req: IncomingMessage): boolean {
+  const key = (req.headers.authorization ?? '').replace(/^Bearer\s+/i, '')
+  return !key || key.includes('wrong')
+}
+
+function money(cents: number, currency: string): string {
+  return `${(cents / 100).toFixed(2)} ${currency.toUpperCase()}`
+}
+
+async function handleStripe(req: IncomingMessage, res: ServerResponse, url: URL): Promise<boolean> {
+  if (!url.pathname.startsWith('/v1/') || url.pathname.startsWith('/v1/oauth2')) return false
+
+  if (stripeRefuses(req)) {
+    json(res, 401, {
+      error: {
+        type: 'invalid_request_error',
+        code: 'api_key_invalid',
+        message: 'Invalid API Key provided',
+      },
+    })
+    return true
+  }
+
+  if (req.method === 'GET' && url.pathname === '/v1/account') {
+    json(res, 200, {
+      id: 'acct_e2e',
+      object: 'account',
+      email: 'payments@e2e.test',
+      settings: { dashboard: { display_name: 'E2E Stripe account' } },
+    })
+    return true
+  }
+
+  if (req.method === 'POST' && url.pathname === '/v1/checkout/sessions') {
+    const form = parseStripeForm(await readBody(req)) as {
+      line_items?: Record<string, { price_data?: { currency?: string; unit_amount?: string } }>
+      metadata?: Record<string, string>
+      success_url?: string
+      cancel_url?: string
+    }
+    const line = form.line_items?.['0']?.price_data
+    const id = `cs_test_e2e_${RUN}_${++counter}`
+    const session: StripeSession = {
+      id,
+      object: 'checkout.session',
+      mode: 'payment',
+      status: 'open',
+      payment_status: 'unpaid',
+      amount_total: Number(line?.unit_amount ?? 0),
+      currency: line?.currency ?? 'usd',
+      metadata: form.metadata ?? {},
+      success_url: form.success_url ?? '',
+      cancel_url: form.cancel_url ?? '',
+      url: `${SELF}/pay/stripe/${id}`,
+    }
+    state.stripe.push(session)
+    json(res, 200, session)
+    return true
+  }
+
+  const retrieve = url.pathname.match(/^\/v1\/checkout\/sessions\/([^/]+)$/)
+  if (req.method === 'GET' && retrieve) {
+    const session = state.stripe.find((s) => s.id === retrieve[1])
+    if (!session) {
+      json(res, 404, {
+        error: {
+          type: 'invalid_request_error',
+          message: `No such checkout.session: '${retrieve[1]}'`,
+        },
+      })
+    } else {
+      json(res, 200, session)
+    }
+    return true
+  }
+
+  json(res, 404, { error: { type: 'invalid_request_error', message: `Unknown ${url.pathname}` } })
+  return true
+}
+
+function paypalOrderBody(order: PayPalOrder) {
+  const captures =
+    order.status === 'COMPLETED'
+      ? [
+          {
+            id: `CAP-${order.id}`,
+            status: 'COMPLETED',
+            amount: order.amount,
+            custom_id: order.custom_id,
+          },
+        ]
+      : undefined
+  return {
+    id: order.id,
+    intent: order.intent,
+    status: order.status,
+    purchase_units: [
+      {
+        reference_id: 'default',
+        custom_id: order.custom_id,
+        invoice_id: order.invoice_id,
+        amount: order.amount,
+        ...(captures ? { payments: { captures } } : {}),
+      },
+    ],
+  }
+}
+
+async function handlePayPal(req: IncomingMessage, res: ServerResponse, url: URL): Promise<boolean> {
+  if (req.method === 'POST' && url.pathname === '/v1/oauth2/token') {
+    const basic = (req.headers.authorization ?? '').replace(/^Basic\s+/i, '')
+    const [clientId, secret] = Buffer.from(basic, 'base64').toString('utf8').split(':')
+    if (!clientId || !secret || secret.includes('wrong')) {
+      json(res, 401, { error: 'invalid_client', error_description: 'Client Authentication failed' })
+    } else {
+      json(res, 200, { access_token: 'E2E-ACCESS-TOKEN', token_type: 'Bearer', expires_in: 32400 })
+    }
+    return true
+  }
+
+  if (!url.pathname.startsWith('/v2/checkout/orders')) return false
+
+  if (req.headers.authorization !== 'Bearer E2E-ACCESS-TOKEN') {
+    json(res, 401, { name: 'AUTHENTICATION_FAILURE', message: 'Authentication failed' })
+    return true
+  }
+
+  if (req.method === 'POST' && url.pathname === '/v2/checkout/orders') {
+    const body = JSON.parse((await readBody(req)) || '{}')
+    const unit = body.purchase_units?.[0] ?? {}
+    const context = body.payment_source?.paypal?.experience_context ?? {}
+    const id = `E2EORDER${RUN.toUpperCase()}${++counter}`
+    const order: PayPalOrder = {
+      id,
+      intent: 'CAPTURE',
+      status: 'PAYER_ACTION_REQUIRED',
+      amount: unit.amount ?? { currency_code: 'USD', value: '0.00' },
+      custom_id: unit.custom_id ?? '',
+      invoice_id: unit.invoice_id ?? '',
+      return_url: context.return_url ?? '',
+      cancel_url: context.cancel_url ?? '',
+    }
+    state.paypal.push(order)
+    json(res, 200, {
+      id,
+      status: order.status,
+      links: [
+        { rel: 'self', href: `${SELF}/v2/checkout/orders/${id}`, method: 'GET' },
+        { rel: 'payer-action', href: `${SELF}/pay/paypal/${id}`, method: 'GET' },
+      ],
+    })
+    return true
+  }
+
+  const capture = url.pathname.match(/^\/v2\/checkout\/orders\/([^/]+)\/capture$/)
+  if (req.method === 'POST' && capture) {
+    const order = state.paypal.find((o) => o.id === capture[1])
+    if (!order) {
+      json(res, 404, { name: 'RESOURCE_NOT_FOUND' })
+    } else if (order.status === 'COMPLETED') {
+      json(res, 422, {
+        name: 'UNPROCESSABLE_ENTITY',
+        details: [{ issue: 'ORDER_ALREADY_CAPTURED' }],
+      })
+    } else if (order.status !== 'APPROVED') {
+      json(res, 422, { name: 'UNPROCESSABLE_ENTITY', details: [{ issue: 'ORDER_NOT_APPROVED' }] })
+    } else {
+      order.status = 'COMPLETED'
+      const body = paypalOrderBody(order)
+      // A capture answer carries the capture, not the unit's own custom_id.
+      json(res, 201, {
+        id: body.id,
+        status: body.status,
+        purchase_units: [{ reference_id: 'default', payments: body.purchase_units[0].payments }],
+      })
+    }
+    return true
+  }
+
+  const show = url.pathname.match(/^\/v2\/checkout\/orders\/([^/]+)$/)
+  if (req.method === 'GET' && show) {
+    const order = state.paypal.find((o) => o.id === show[1])
+    if (!order) json(res, 404, { name: 'RESOURCE_NOT_FOUND' })
+    else json(res, 200, paypalOrderBody(order))
+    return true
+  }
+
+  json(res, 404, { name: 'RESOURCE_NOT_FOUND', message: `Unknown ${url.pathname}` })
+  return true
+}
+
+/** The page a customer lands on at the vendor, with the one decision a customer makes there. */
+async function handleCheckoutPage(
+  req: IncomingMessage,
+  res: ServerResponse,
+  url: URL
+): Promise<boolean> {
+  const stripe = url.pathname.match(/^\/pay\/stripe\/([^/]+)$/)
+  if (stripe) {
+    const session = state.stripe.find((s) => s.id === stripe[1])
+    if (!session) return json(res, 404, { error: 'no such session' }), true
+    if (req.method === 'POST') {
+      session.status = 'complete'
+      session.payment_status = 'paid'
+      redirect(res, session.success_url.replace('{CHECKOUT_SESSION_ID}', session.id))
+      return true
+    }
+    html(
+      res,
+      `<h1>Stripe checkout</h1><p id="amount">${money(session.amount_total, session.currency)}</p>` +
+        `<form method="post"><button type="submit">Pay</button></form>` +
+        `<a href="${session.cancel_url}">Cancel</a>`
+    )
+    return true
+  }
+
+  const paypal = url.pathname.match(/^\/pay\/paypal\/([^/]+)$/)
+  if (paypal) {
+    const order = state.paypal.find((o) => o.id === paypal[1])
+    if (!order) return json(res, 404, { error: 'no such order' }), true
+    if (req.method === 'POST') {
+      order.status = 'APPROVED'
+      // PayPal appends its own token and PayerID to whatever return URL it was given.
+      const joiner = order.return_url.includes('?') ? '&' : '?'
+      redirect(res, `${order.return_url}${joiner}token=${order.id}&PayerID=E2EPAYER`)
+      return true
+    }
+    html(
+      res,
+      `<h1>PayPal checkout</h1><p id="amount">${order.amount.value} ${order.amount.currency_code}</p>` +
+        `<form method="post"><button type="submit">Pay</button></form>` +
+        `<a href="${order.cancel_url}">Cancel</a>`
+    )
+    return true
+  }
+  return false
+}
+
+const server = createServer(async (req, res) => {
+  const url = new URL(req.url ?? '/', SELF)
+  try {
+    if (url.pathname === '/health') return json(res, 200, { ok: true })
+    if (url.pathname === '/state') {
+      if (req.method === 'DELETE') {
+        state.stripe.length = 0
+        state.paypal.length = 0
+        state.calls.length = 0
+        return json(res, 200, { cleared: true })
+      }
+      return json(res, 200, state)
+    }
+
+    state.calls.push(`${req.method} ${url.pathname}`)
+    if (await handleCheckoutPage(req, res, url)) return
+    if (await handlePayPal(req, res, url)) return
+    if (await handleStripe(req, res, url)) return
+    json(res, 404, { error: `payment sink has nothing at ${url.pathname}` })
+  } catch (error) {
+    json(res, 500, { error: error instanceof Error ? error.message : String(error) })
+  }
+})
+
+server.listen(PORT, '127.0.0.1', () => {
+  console.log(`[payment-sink] Stripe and PayPal stand-in on ${SELF}`)
+})

+ 2 - 2
e2e/specs/email/sending.spec.ts

@@ -158,10 +158,10 @@ test.describe('a message to a customer', () => {
     const typed = `Ready for collection <b>today</b> ${stamp}`
     await expect(async () => {
       await page.getByRole('button', { name: 'Notify' }).click()
-      await expect(page.getByRole('dialog')).toBeVisible({ timeout: 2_000 })
+      await expect(page.getByRole('dialog', { name: /^Notify / })).toBeVisible({ timeout: 2_000 })
     }).toPass({ timeout: 30_000 })
 
-    const dialog = page.getByRole('dialog')
+    const dialog = page.getByRole('dialog', { name: /^Notify / })
     await dialog.getByRole('textbox').first().fill(typed)
     const email = dialog.locator('#notify-email')
     if ((await email.getAttribute('data-state')) !== 'checked') await email.click()

+ 139 - 0
e2e/specs/payments/booked-once.spec.ts

@@ -0,0 +1,139 @@
+import { expect, test } from '@playwright/test'
+import Stripe from 'stripe'
+import {
+  deleteVendorPaymentRows,
+  forgetConnections,
+  insertVendorPaymentRow,
+  vendorPaymentRows,
+} from '../../support/db'
+import { connectVendor, expectConnection, paymentSink } from '../../support/payments'
+import {
+  addPart,
+  newWorkOrder,
+  saveWorkOrder,
+  seededVehicleUrl,
+  shareLink,
+} from '../../support/work-order'
+
+/**
+ * One payment, one row, however many times it is reported at once.
+ *
+ * A customer who pays is reported twice by design: their browser comes back
+ * to the invoice and asks for the payment to be checked, and the vendor sends
+ * a notification of its own, often in the same second. Stripe retries its
+ * notifications as well. Every one of those paths used to ask "is this
+ * payment recorded yet?" and then record it, as two separate steps, so two
+ * reports arriving together could both see nothing and both write a row: the
+ * invoice then showed twice the money the customer had paid. It happened two
+ * times in five when this was first tried against the running app.
+ *
+ * The sequential case is in `checkout.spec.ts`. This file is the concurrent
+ * one, and it has to be: a race is only caught by racing.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+const stamp = Date.now()
+const SECRET = `whsec_once_${stamp}`
+const ROUNDS = 6
+/** The browser coming back, plus Stripe's notification and two of its retries. */
+const WEBHOOKS_PER_ROUND = 3
+
+let jobId = ''
+let org = ''
+let token = ''
+
+test.beforeAll(async ({ browser }) => {
+  await forgetConnections(['stripe', 'paypal'])
+  const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
+  await connectVendor(page, 'stripe', { secretKey: `sk_test_once_${stamp}`, webhookSecret: SECRET })
+  await expectConnection('stripe', 'active')
+
+  const jobUrl = await newWorkOrder(page, await seededVehicleUrl(page), `E2E booked once ${stamp}`)
+  jobId = jobUrl.split('/').pop() ?? ''
+  await addPart(page, { name: `E2E once part ${stamp}`, quantity: 1, unitPrice: 800 })
+  await saveWorkOrder(page)
+  await page.goto(jobUrl)
+  ;[org, token] = new URL(await shareLink(page)).pathname.split('/').slice(-2)
+  await page.close()
+})
+
+test.afterAll(async () => {
+  await forgetConnections(['stripe', 'paypal'])
+})
+
+test('the database refuses a second row for a payment it already holds', async () => {
+  // Not timing, so not luck: whatever the app does, the table itself must
+  // not hold the same vendor payment against the same invoice twice.
+  const externalId = `cs_test_constraint_${stamp}`
+  try {
+    const first = await insertVendorPaymentRow({
+      serviceRecordId: jobId,
+      provider: 'stripe',
+      externalId,
+      amount: 1,
+    })
+    expect(first, 'the first row goes in').toBeNull()
+
+    const second = await insertVendorPaymentRow({
+      serviceRecordId: jobId,
+      provider: 'stripe',
+      externalId,
+      amount: 1,
+    })
+    expect(second, 'the second is refused as a unique violation').toBe('23505')
+    expect(await vendorPaymentRows(jobId, externalId)).toBe(1)
+  } finally {
+    await deleteVendorPaymentRows(externalId)
+  }
+})
+
+test('a payment reported by the browser and by Stripe at the same moment is booked once', async ({
+  request,
+}) => {
+  const rows: number[] = []
+
+  for (let round = 0; round < ROUNDS; round++) {
+    const checkout = await request.post(`/api/public/share/invoice/${org}/${token}/checkout`, {
+      data: { provider: 'stripe', amount: 10 },
+    })
+    expect(checkout.ok(), `checkout in round ${round}`).toBe(true)
+    const { externalId } = (await checkout.json()) as { externalId: string }
+
+    // The customer pays at the vendor.
+    await request.post(`http://127.0.0.1:8026/pay/stripe/${externalId}`, { maxRedirects: 0 })
+    const session = (await paymentSink()).stripe.find((s) => s.id === externalId)
+    expect(session?.payment_status, `paid at the vendor in round ${round}`).toBe('paid')
+
+    const payload = JSON.stringify({
+      id: `evt_once_${stamp}_${round}`,
+      object: 'event',
+      type: 'checkout.session.completed',
+      data: { object: session },
+    })
+    const signature = new Stripe('sk_test_unused').webhooks.generateTestHeaderString({
+      payload,
+      secret: SECRET,
+    })
+    const notify = () =>
+      request.post('/api/webhooks/stripe', {
+        data: payload,
+        headers: { 'content-type': 'application/json', 'stripe-signature': signature },
+      })
+
+    // Everything at once, the way it arrives when it goes wrong.
+    const answers = await Promise.all([
+      request.post(`/api/public/share/invoice/${org}/${token}/verify`, {
+        data: { provider: 'stripe', externalId },
+      }),
+      ...Array.from({ length: WEBHOOKS_PER_ROUND }, notify),
+    ])
+    for (const answer of answers) {
+      expect(answer.status(), 'no report is turned away with an error').toBeLessThan(500)
+    }
+
+    rows.push(await vendorPaymentRows(jobId, externalId))
+  }
+
+  expect(rows, `rows per payment across ${ROUNDS} rounds`).toEqual(Array(ROUNDS).fill(1))
+})

+ 328 - 0
e2e/specs/payments/checkout.spec.ts

@@ -0,0 +1,328 @@
+import { expect, type Page, test } from '@playwright/test'
+import Stripe from 'stripe'
+import { forgetConnections, ownerOrganizationId, paymentsFor } from '../../support/db'
+import { settle } from '../../support/hydration'
+import {
+  clearPaymentSink,
+  connectVendor,
+  expectConnection,
+  paymentSink,
+} from '../../support/payments'
+import {
+  addPart,
+  newWorkOrder,
+  saveWorkOrder,
+  seededVehicleUrl,
+  shareLink,
+} from '../../support/work-order'
+
+/**
+ * A customer pays an invoice online, and the workshop's books follow.
+ *
+ * The chain this file walks is the one a workshop depends on without ever
+ * seeing it: keys typed into Settings → Integrations, a pay button on the
+ * shared invoice for exactly what is owed, the customer sent to the vendor
+ * and back, and a payment recorded once, against the right invoice, however
+ * many times the vendor or the browser reports it. Getting any link wrong
+ * either loses money quietly or books money twice.
+ *
+ * Stripe and PayPal are played by `e2e/payment-sink.ts`, which answers the
+ * calls the app makes with the shapes the vendors use and has a checkout page
+ * a spec pays on. What it records is how the amount charged is checked against
+ * the amount the invoice showed.
+ *
+ * Pinned on the seeded workshop's 25% exclusive tax in dollars: one part at
+ * 800 makes a total of 1,000.00. 400 is paid by card, then 600 through PayPal.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+const stamp = Date.now()
+const STRIPE_KEY = `sk_test_e2e_${stamp}`
+const WEBHOOK_SECRET = `whsec_e2e_${stamp}`
+
+let jobUrl = ''
+let jobId = ''
+let invoiceUrl = ''
+let organizationId = ''
+
+/** The shared invoice, hydrated, as the customer opens it. */
+async function openInvoice(page: Page, url = invoiceUrl): Promise<void> {
+  await page.goto(url)
+  await settle(page)
+}
+
+/** The badge the work order's payments panel shows: Unpaid, Partial or Paid. */
+async function expectWorkOrderPaymentState(
+  page: Page,
+  state: 'Unpaid' | 'Partial' | 'Paid'
+): Promise<void> {
+  await page.goto(jobUrl)
+  await settle(page)
+  const panel = page
+    .getByRole('heading', { name: 'Payments', exact: true })
+    .locator('xpath=ancestor::div[contains(@class,"rounded-lg")][1]')
+  await expect(
+    panel.getByText(state, { exact: true }),
+    `the work order reads ${state}`
+  ).toBeVisible()
+}
+
+/** 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> {
+  await openInvoice(page)
+  await expect(async () => {
+    await page.getByRole('button', { name: 'Partial payment', exact: true }).click()
+    await expect(page.locator('#payAmount')).toBeVisible({ timeout: 2_000 })
+  }).toPass({ timeout: 30_000 })
+  await page.locator('#payAmount').fill(amount)
+  await page.getByRole('button', { name: new RegExp(`with ${vendor}$`) }).click()
+  await expect(page.getByRole('heading', { name: /checkout/ })).toBeVisible({ timeout: 30_000 })
+}
+
+/** A Stripe notification, signed with the workshop's webhook secret unless told otherwise. */
+function stripeNotification(session: unknown, secret = WEBHOOK_SECRET) {
+  const payload = JSON.stringify({
+    id: `evt_e2e_${Date.now()}`,
+    object: 'event',
+    type: 'checkout.session.completed',
+    data: { object: session },
+  })
+  const signature = new Stripe('sk_test_unused').webhooks.generateTestHeaderString({
+    payload,
+    secret,
+  })
+  return { payload, signature }
+}
+
+test.beforeAll(async ({ browser }) => {
+  await clearPaymentSink()
+  // A spec that failed halfway must not leave a connection behind, and this
+  // one must start from none.
+  await forgetConnections(['stripe', 'paypal'])
+  organizationId = await ownerOrganizationId()
+
+  const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
+  const vehicleUrl = await seededVehicleUrl(page)
+  jobUrl = await newWorkOrder(page, vehicleUrl, `E2E paid online ${stamp}`)
+  jobId = jobUrl.split('/').pop() ?? ''
+  await addPart(page, { name: `E2E alternator ${stamp}`, quantity: 1, unitPrice: 800 })
+  await saveWorkOrder(page)
+  await page.close()
+})
+
+test.afterAll(async () => {
+  // Every other spec shares invoices, and a connected vendor would put a pay
+  // button on all of them.
+  await forgetConnections(['stripe', 'paypal'])
+})
+
+test.describe('connecting a vendor', () => {
+  test('refuses a Stripe key that Stripe does not accept', async ({ page }) => {
+    await connectVendor(page, 'stripe', { secretKey: 'sk_test_wrong' })
+
+    // Checked against the vendor before anything is stored as live, and the
+    // workshop is told why, where they typed it.
+    await expect(page.getByText('Stripe rejected the secret key')).toBeVisible({ timeout: 30_000 })
+    await expectConnection('stripe', 'error')
+  })
+
+  test('connects Stripe with a key it does accept', async ({ page }) => {
+    await connectVendor(page, 'stripe', { secretKey: STRIPE_KEY, webhookSecret: WEBHOOK_SECRET })
+    await expectConnection('stripe', 'active')
+
+    // And shows where Stripe must send its notifications, which is the step a
+    // workshop most often misses.
+    await page.goto('/settings/integrations/stripe')
+    await settle(page)
+    await expect(page.getByText('Inbound webhook URL')).toBeVisible()
+    await expect(page.getByText(/\/api\/webhooks\/stripe/).first()).toBeVisible()
+  })
+
+  test('refuses a PayPal secret that PayPal does not accept', async ({ page }) => {
+    await connectVendor(page, 'paypal', { clientId: 'e2e-client', clientSecret: 'wrong-secret' })
+    await expect(page.getByText(/PayPal auth failed/)).toBeVisible({ timeout: 30_000 })
+    await expectConnection('paypal', 'error')
+  })
+
+  test('connects PayPal with an id and secret it does accept', async ({ page }) => {
+    await connectVendor(page, 'paypal', {
+      clientId: `e2e-client-${stamp}`,
+      clientSecret: `e2e-secret-${stamp}`,
+    })
+    await expectConnection('paypal', 'active')
+  })
+})
+
+test.describe('the invoice a customer is sent', () => {
+  test('shows what is owed, and offers both vendors for exactly that', async ({ page }) => {
+    await page.goto(jobUrl)
+    invoiceUrl = await shareLink(page)
+
+    await openInvoice(page)
+    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.getByRole('button', { name: /Pay \$1,?000\.00 with Card/ })).toBeVisible()
+    await expect(page.getByRole('button', { name: /Pay \$1,?000\.00 with PayPal/ })).toBeVisible()
+  })
+
+  test('refuses to charge more than is owed', async ({ request }) => {
+    const [org, token] = new URL(invoiceUrl).pathname.split('/').slice(-2)
+    const before = (await paymentSink()).stripe.length
+
+    const response = await request.post(`/api/public/share/invoice/${org}/${token}/checkout`, {
+      data: { provider: 'stripe', amount: 1000.5 },
+    })
+    expect(response.status(), 'more than the balance').toBe(400)
+    // Refused before the vendor was asked for anything.
+    expect((await paymentSink()).stripe.length).toBe(before)
+  })
+})
+
+test.describe('paying part of it by card', () => {
+  test('charges what the customer chose, for this invoice', async ({ page }) => {
+    await startPayment(page, 'Card', '400')
+
+    // 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.
+    const session = (await paymentSink()).stripe.at(-1)
+    expect(session?.amount_total, 'the amount sent to Stripe').toBe(40000)
+    expect(session?.currency).toBe('usd')
+    expect(session?.metadata.serviceRecordId).toBe(jobId)
+    expect(session?.metadata.orgId).toBe(organizationId)
+    await expect(page.locator('#amount')).toHaveText('400.00 USD')
+  })
+
+  test('is recorded when the customer comes back, and the invoice says what is left', async ({
+    page,
+  }) => {
+    await startPayment(page, 'Card', '400')
+    await page.getByRole('button', { name: 'Pay', exact: true }).click()
+
+    // Back on the invoice, which checks with Stripe before believing it.
+    await expect(page.getByText('Payment received!')).toBeVisible({ timeout: 30_000 })
+    await expect(page.getByText(/\$400\.00 has been applied/)).toBeVisible()
+
+    const recorded = await paymentsFor(jobId)
+    expect(recorded.map((p) => [p.provider, p.amount])).toEqual([['stripe', 400]])
+
+    await expectWorkOrderPaymentState(page, 'Partial')
+
+    // The customer's copy owes the rest, and offers it.
+    await openInvoice(page)
+    await expect(page.getByRole('button', { name: /Pay \$600\.00 with Card/ })).toBeVisible()
+  })
+
+  test('is not counted twice when the same payment is reported again', async ({
+    page,
+    request,
+  }) => {
+    const paid = (await paymentSink()).stripe.filter((s) => s.payment_status === 'paid')
+    const session = paid.at(-1)
+    expect(session, 'a paid session from the test before').toBeTruthy()
+
+    // The customer reloads the page Stripe sent them back to.
+    await page.goto(`${invoiceUrl}?session_id=${session?.id}`)
+    await settle(page)
+    await expect(page.getByText(/Payment received!|could not be verified/)).toBeVisible({
+      timeout: 30_000,
+    })
+
+    // And Stripe's own notification for the same session arrives afterwards,
+    // as it always does in real life: two reports of one payment.
+    const { payload, signature } = stripeNotification(session)
+    const notified = await request.post('/api/webhooks/stripe', {
+      data: payload,
+      headers: { 'content-type': 'application/json', 'stripe-signature': signature },
+    })
+    expect(notified.status()).toBe(200)
+
+    expect(
+      (await paymentsFor(jobId)).map((p) => [p.provider, p.amount]),
+      'still one payment of 400'
+    ).toEqual([['stripe', 400]])
+  })
+
+  test('ignores a notification that Stripe did not sign', async ({ request }) => {
+    // A forged "this invoice is paid", which is what the signature is for.
+    const forged = {
+      id: `cs_test_forged_${stamp}`,
+      object: 'checkout.session',
+      payment_status: 'paid',
+      amount_total: 60000,
+      metadata: { serviceRecordId: jobId, orgId: organizationId },
+    }
+    const { payload, signature } = stripeNotification(forged, 'whsec_not_the_workshops')
+    const response = await request.post('/api/webhooks/stripe', {
+      data: payload,
+      headers: { 'content-type': 'application/json', 'stripe-signature': signature },
+    })
+    expect(response.status(), 'the signature does not verify').toBe(400)
+    expect((await paymentsFor(jobId)).length, 'nothing recorded').toBe(1)
+  })
+})
+
+test.describe('a customer who changes their mind at the vendor', () => {
+  test('pays nothing, and nothing is recorded', async ({ page }) => {
+    await startPayment(page, 'PayPal', '600')
+    await page.getByRole('link', { name: 'Cancel' }).click()
+
+    // 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 settle(page)
+    await expect(page.getByRole('button', { name: /Pay \$600\.00 with PayPal/ })).toBeVisible()
+    expect((await paymentsFor(jobId)).length).toBe(1)
+  })
+})
+
+test.describe('paying the rest through PayPal', () => {
+  test('settles the invoice', async ({ page }) => {
+    await startPayment(page, 'PayPal', '600')
+    const order = (await paymentSink()).paypal.at(-1)
+    expect(order?.amount, 'the amount sent to PayPal').toEqual({
+      currency_code: 'USD',
+      value: '600.00',
+    })
+    expect(order?.custom_id).toBe(`${jobId}:${organizationId}`)
+
+    await page.getByRole('button', { name: 'Pay', exact: true }).click()
+    await expect(page.getByText('Payment received!')).toBeVisible({ timeout: 30_000 })
+
+    expect((await paymentsFor(jobId)).map((p) => [p.provider, p.amount])).toEqual([
+      ['stripe', 400],
+      ['paypal', 600],
+    ])
+    await expectWorkOrderPaymentState(page, 'Paid')
+
+    // Nothing is owed, so the customer is offered nothing to pay.
+    await openInvoice(page)
+    await expect(page.getByRole('button', { name: /with Card|with PayPal/ })).toHaveCount(0)
+  })
+
+  test('is not counted twice when PayPal reports it too', async ({ request }) => {
+    const order = (await paymentSink()).paypal.find((o) => o.status === 'COMPLETED')
+    expect(order, 'the order paid in the test before').toBeTruthy()
+
+    const response = await request.post('/api/webhooks/paypal', {
+      data: {
+        event_type: 'PAYMENT.CAPTURE.COMPLETED',
+        resource: {
+          id: `CAP-${order?.id}`,
+          custom_id: order?.custom_id,
+          supplementary_data: { related_ids: { order_id: order?.id } },
+        },
+      },
+    })
+    expect(response.status()).toBe(200)
+    expect((await paymentsFor(jobId)).length, 'still two payments').toBe(2)
+  })
+
+  test('refuses more money on an invoice that is paid in full', async ({ request }) => {
+    const [org, token] = new URL(invoiceUrl).pathname.split('/').slice(-2)
+    const response = await request.post(`/api/public/share/invoice/${org}/${token}/checkout`, {
+      data: { provider: 'paypal', amount: 1 },
+    })
+    expect(response.status()).toBe(400)
+    expect(await response.json()).toMatchObject({ error: 'Invoice is already paid in full' })
+  })
+})

+ 102 - 0
e2e/support/db.ts

@@ -413,3 +413,105 @@ export async function customerOfVehicle(
     return customer
   })
 }
+
+/** Where a workshop's connection to a vendor stands: active, pending, error, or none at all. */
+export async function connectionStatus(connectorId: string): Promise<string | null> {
+  const organizationId = await ownerOrganizationId()
+  return withDb(async (db) => {
+    const result = await db.query<{ status: string }>(
+      `select status from integration_connections
+        where "organizationId" = $1 and "connectorId" = $2`,
+      [organizationId, connectorId]
+    )
+    return result.rows[0]?.status ?? null
+  })
+}
+
+/**
+ * Every connection a spec made to a vendor, gone. The payment specs connect
+ * Stripe and PayPal to the seeded workshop, and a connection left behind puts
+ * pay buttons on every invoice the rest of the suite shares.
+ */
+export async function forgetConnections(connectorIds: string[]): Promise<void> {
+  const organizationId = await ownerOrganizationId()
+  await withDb((db) =>
+    db.query(
+      `delete from integration_connections
+        where "organizationId" = $1 and "connectorId" = any($2::text[])`,
+      [organizationId, connectorIds]
+    )
+  )
+}
+
+export interface RecordedPayment {
+  amount: number
+  provider: string | null
+  method: string
+  externalId: string | null
+}
+
+/** The money recorded against one work order, oldest first. */
+export async function paymentsFor(serviceRecordId: string): Promise<RecordedPayment[]> {
+  return withDb(async (db) => {
+    const result = await db.query<RecordedPayment>(
+      `select amount, provider, method, "externalId" from payments
+        where "serviceRecordId" = $1
+        order by "createdAt", id`,
+      [serviceRecordId]
+    )
+    return result.rows.map((row) => ({ ...row, amount: Number(row.amount) }))
+  })
+}
+
+/**
+ * Writes a vendor payment row straight into the table, bypassing the app.
+ *
+ * For the one question only the database can answer: whether it refuses a
+ * second row for a payment it already holds. Returns the Postgres error code
+ * when the insert is refused, or null when it went in.
+ */
+export async function insertVendorPaymentRow(row: {
+  serviceRecordId: string
+  provider: string
+  externalId: string
+  amount: number
+}): Promise<string | null> {
+  return withDb(async (db) => {
+    try {
+      await db.query(
+        `insert into payments (id, amount, method, provider, "externalId", "serviceRecordId", "updatedAt")
+         values (md5(random()::text || clock_timestamp()::text), $1, $2, $2, $3, $4, now())`,
+        [row.amount, row.provider, row.externalId, row.serviceRecordId]
+      )
+      return null
+    } catch (error) {
+      return (error as { code?: string }).code ?? 'unknown'
+    }
+  })
+}
+
+/**
+ * How many rows one invoice holds for one vendor payment.
+ *
+ * Counted against the invoice as well as the id: a vendor's id means one
+ * payment on one invoice, and a count across the whole table also finds any
+ * other invoice that happens to carry the same id, which is not a duplicate.
+ */
+export async function vendorPaymentRows(
+  serviceRecordId: string,
+  externalId: string
+): Promise<number> {
+  return withDb(async (db) => {
+    const result = await db.query<{ n: number }>(
+      `select count(*)::int as n from payments
+        where "serviceRecordId" = $1 and "externalId" = $2`,
+      [serviceRecordId, externalId]
+    )
+    return result.rows[0]?.n ?? 0
+  })
+}
+
+/** Removes the rows a spec wrote for one vendor payment. */
+export async function deleteVendorPaymentRows(externalId: string): Promise<void> {
+  await withDb((db) => db.query(`delete from payments where "externalId" = $1`, [externalId]))
+}

+ 77 - 0
e2e/support/payments.ts

@@ -0,0 +1,77 @@
+import { expect, type Page } from '@playwright/test'
+import { connectionStatus } from './db'
+import { settle } from './hydration'
+
+/**
+ * Online payments, from both ends.
+ *
+ * The workshop connects a vendor in Settings → Integrations; the customer pays
+ * from the shared invoice. Between them sits the stand-in vendor in
+ * `e2e/payment-sink.ts`, whose state a spec reads to check what the customer
+ * was actually charged.
+ */
+
+const sink = process.env.E2E_PAYMENT_SINK ?? 'http://127.0.0.1:8026'
+
+export interface SinkStripeSession {
+  id: string
+  payment_status: 'unpaid' | 'paid'
+  amount_total: number
+  currency: string
+  metadata: Record<string, string>
+}
+
+export interface SinkPayPalOrder {
+  id: string
+  status: string
+  amount: { currency_code: string; value: string }
+  custom_id: string
+}
+
+export interface SinkState {
+  stripe: SinkStripeSession[]
+  paypal: SinkPayPalOrder[]
+  calls: string[]
+}
+
+/** Everything the stand-in vendor was asked for so far. */
+export async function paymentSink(): Promise<SinkState> {
+  const response = await fetch(`${sink}/state`)
+  if (!response.ok) {
+    throw new Error(`the payment sink answered ${response.status}. Is e2e/payment-sink.ts running?`)
+  }
+  return response.json()
+}
+
+export async function clearPaymentSink(): Promise<void> {
+  await fetch(`${sink}/state`, { method: 'DELETE' })
+}
+
+/**
+ * Types a vendor's keys into its connection page and presses Connect.
+ *
+ * The page tests the keys against the vendor before it stores anything as
+ * live, so the outcome is read back as the connection's status rather than
+ * from a toast: `active` when the vendor accepted them, anything else when not.
+ */
+export async function connectVendor(
+  page: Page,
+  vendor: 'stripe' | 'paypal',
+  credentials: Record<string, string>
+): Promise<void> {
+  await page.goto(`/settings/integrations/${vendor}`)
+  await settle(page)
+  for (const [key, value] of Object.entries(credentials)) {
+    const field = page.locator(`input[name="${vendor}-${key}"]`)
+    await expect(field, `${vendor} asks for ${key}`).toBeVisible()
+    await field.fill(value)
+  }
+  await page.getByRole('button', { name: 'Connect', exact: true }).click()
+}
+
+/** Waits for the connection to settle into the status a spec expects. */
+export async function expectConnection(vendor: string, status: string): Promise<void> {
+  await expect
+    .poll(() => connectionStatus(vendor), { timeout: 30_000, message: `${vendor} is ${status}` })
+    .toBe(status)
+}

+ 8 - 1
e2e/support/work-order.ts

@@ -1,4 +1,5 @@
 import { expect, type Locator, type Page } from '@playwright/test'
+import { settle } from './hydration'
 
 /**
  * Driving a work order through the browser the way a workshop does.
@@ -24,6 +25,10 @@ export async function seededVehicleUrl(page: Page, search = 'Camry'): Promise<st
 export async function newWorkOrder(page: Page, vehicleUrl: string, title: string): Promise<string> {
   await page.goto(`${vehicleUrl}/service/new`)
   await page.waitForURL(/\/vehicles\/[^/]+\/service\/[^/]+$/)
+  // `/service/new` makes the draft and redirects to it, and for a moment the
+  // page being left and the page arriving are both in the document: two title
+  // fields, and a strict-mode error instead of a retry. Settled, there is one.
+  await settle(page)
   const titleField = page.locator('input[name="title"]')
   await expect(titleField).toBeVisible()
   await titleField.fill(title)
@@ -180,7 +185,9 @@ export async function shareLink(page: Page): Promise<string> {
     await expired.getByRole('button', { name: /proceed without changes/i }).click()
   }
 
-  const dialog = page.getByRole('dialog')
+  // By name: a workshop-wide announcement is a dialog too, and can be open on
+  // the same page.
+  const dialog = page.getByRole('dialog', { name: 'Share Invoice' })
   await expect(dialog).toBeVisible()
   const generate = dialog.getByRole('button', { name: /generate public link/i })
   if (await generate.isVisible()) {

+ 25 - 1
playwright.config.ts

@@ -27,6 +27,25 @@ const mailApiPort = process.env.E2E_MAIL_API_PORT ?? '8025'
  * posted. Started whether or not the suite starts the app: pointed at a
  * server somebody else launched, that server is told to send here too.
  */
+/** Where the stand-in payment vendor listens, for the app and for the specs. */
+const paymentPort = process.env.E2E_PAYMENT_PORT ?? '8026'
+const paymentSinkUrl = `http://127.0.0.1:${paymentPort}`
+
+/**
+ * Stripe and PayPal as far as the app can tell, with a checkout page a spec
+ * can pay on. Started in every mode, like the mail sink: a server somebody
+ * else launched is told to use it through the two base URLs below.
+ */
+const paymentSink = {
+  command: 'npx tsx e2e/payment-sink.ts',
+  url: `${paymentSinkUrl}/health`,
+  reuseExistingServer: !process.env.CI,
+  timeout: 60_000,
+  stdout: 'pipe' as const,
+  stderr: 'pipe' as const,
+  env: { E2E_PAYMENT_PORT: paymentPort },
+}
+
 const mailSink = {
   command: 'npx tsx e2e/mail-sink.ts',
   url: `http://127.0.0.1:${mailApiPort}/health`,
@@ -83,9 +102,10 @@ export default defineConfig({
    * and it is not the artifact that ships anyway.
    */
   webServer: process.env.E2E_BASE_URL
-    ? [mailSink]
+    ? [mailSink, paymentSink]
     : [
         mailSink,
+        paymentSink,
         {
           // The database first, then the server, in one command: Playwright
           // starts this before global setup, and a server on an empty schema
@@ -128,6 +148,10 @@ export default defineConfig({
             SMTP_PORT: smtpPort,
             SMTP_FROM_EMAIL: 'workshop@e2e.test',
             SMTP_SECURE: 'false',
+            // Payments go to the stand-in vendor. Read only from the
+            // environment, so nothing a workshop stores can redirect a key.
+            STRIPE_API_BASE_URL: paymentSinkUrl,
+            PAYPAL_API_BASE_URL: paymentSinkUrl,
           },
         },
       ],

+ 45 - 0
prisma/migrations/20260911090000_payments_booked_once/migration.sql

@@ -0,0 +1,45 @@
+-- A payment a vendor reports is booked once per invoice.
+--
+-- The customer's browser coming back to the invoice and the vendor's own
+-- notification both report a payment, usually in the same second, and every
+-- path that records one looked for it first and wrote second. Two reports
+-- arriving together could both find nothing and both write, so the invoice
+-- showed twice what the customer paid. The unique key below makes the write
+-- itself the check.
+--
+-- The key includes the invoice, not only the vendor's id, because one payment
+-- can legitimately be split across several invoices: a QuickBooks payment with
+-- a line per invoice, or an imported payment applied to more than one. Rows
+-- with no provider or no external id, which is every payment typed in by hand,
+-- are not held to it, since Postgres counts nulls as distinct.
+--
+-- One transaction: Prisma does not wrap a migration in one, and the index
+-- cannot be created while duplicates remain.
+
+BEGIN;
+
+-- Duplicates the race has already written: the same vendor payment on the
+-- same invoice more than once. They are the same money reported twice, so the
+-- earliest row is kept and the later copies are removed. Nothing references a
+-- payment row, so nothing else changes with them.
+DELETE FROM "payments" AS p
+USING (
+  SELECT "id"
+  FROM (
+    SELECT
+      "id",
+      row_number() OVER (
+        PARTITION BY "serviceRecordId", "provider", "externalId"
+        ORDER BY "createdAt", "id"
+      ) AS "copy"
+    FROM "payments"
+    WHERE "provider" IS NOT NULL AND "externalId" IS NOT NULL
+  ) AS ranked
+  WHERE ranked."copy" > 1
+) AS duplicate
+WHERE p."id" = duplicate."id";
+
+-- CreateIndex
+CREATE UNIQUE INDEX "payments_serviceRecordId_provider_externalId_key" ON "payments"("serviceRecordId", "provider", "externalId");
+
+COMMIT;

+ 6 - 0
prisma/schema/service-records.prisma

@@ -200,6 +200,12 @@ model Payment {
   serviceRecordId String
   serviceRecord   ServiceRecord @relation(fields: [serviceRecordId], references: [id], onDelete: Cascade)
 
+  /// A payment a vendor or a ledger reported is booked once per invoice. The
+  /// browser coming back and the vendor's notification report the same payment
+  /// in the same second, and looking before writing let both write. Rows with
+  /// no provider or no external id, which is every payment typed in by hand,
+  /// are not held to it: Postgres counts nulls as distinct.
+  @@unique([serviceRecordId, provider, externalId])
   @@index([serviceRecordId])
   @@map("payments")
 }

+ 34 - 1
src/__tests__/components/feature-hint-card.test.tsx

@@ -7,7 +7,7 @@
  * the screen must not spend that on everybody's behalf.
  */
 
-import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
 import { render, screen, act } from '@testing-library/react'
 
 const dismissFeatureHint = vi.fn().mockResolvedValue({ success: true, data: { seen: [] } })
@@ -134,3 +134,36 @@ describe('a hint about something just switched on', () => {
     expect(dismissFeatureHint).toHaveBeenCalledWith('designer.v1')
   })
 })
+
+describe('where an announcement is drawn', () => {
+  // jsdom cannot scroll, so the method is lent to elements for these tests
+  // and taken away again, leaving the other tests on the path where it is
+  // missing, which the card must also survive.
+  const scroll = vi.fn()
+  beforeEach(() => {
+    scroll.mockClear()
+    Element.prototype.scrollIntoView = scroll
+  })
+  afterEach(() => {
+    // Restoring jsdom's own prototype, which has no such method.
+    delete (Element.prototype as { scrollIntoView?: unknown }).scrollIntoView
+  })
+
+  it('brings the thing it points at into view first', async () => {
+    // The Settings row sits below the fold of a sidebar that scrolls, and the
+    // card used to be drawn beside it with its only button off the bottom of
+    // the window.
+    await renderCard({ variant: 'announcement' })
+    expect(scroll).toHaveBeenCalledWith({ block: 'nearest' })
+    expect(scroll.mock.contexts[0], 'the anchor is what scrolled').toBe(
+      screen.getByRole('button', { name: 'Settings' })
+    )
+  })
+
+  it('leaves a hint where it is', async () => {
+    // A hint follows something the person just did, so its anchor is already
+    // on screen, and moving the page under them would be the interruption.
+    await renderCard()
+    expect(scroll).not.toHaveBeenCalled()
+  })
+})

+ 71 - 0
src/__tests__/lib/payment-vendor-hosts.test.ts

@@ -0,0 +1,71 @@
+import { afterEach, describe, expect, it } from 'vitest'
+import { paypalApiBase, stripeClient } from '@/lib/payment-providers/vendor-hosts'
+
+/**
+ * Where a server sends a workshop's payment keys.
+ *
+ * The override exists so the end-to-end suite can put a stand-in vendor in
+ * front of the app. The risk it carries is the one worth pinning: a
+ * production server with nothing set must talk to Stripe and PayPal
+ * themselves, and nothing but the environment may change that.
+ */
+
+const ORIGINAL = {
+  stripe: process.env.STRIPE_API_BASE_URL,
+  paypal: process.env.PAYPAL_API_BASE_URL,
+}
+
+afterEach(() => {
+  if (ORIGINAL.stripe === undefined) delete process.env.STRIPE_API_BASE_URL
+  else process.env.STRIPE_API_BASE_URL = ORIGINAL.stripe
+  if (ORIGINAL.paypal === undefined) delete process.env.PAYPAL_API_BASE_URL
+  else process.env.PAYPAL_API_BASE_URL = ORIGINAL.paypal
+})
+
+/** The host and port a Stripe client was configured with. */
+function stripeTarget(client: ReturnType<typeof stripeClient>) {
+  const settings = (
+    client as unknown as { _api: { host: string; port: string | number; protocol: string } }
+  )._api
+  return { host: settings.host, port: String(settings.port), protocol: settings.protocol }
+}
+
+describe('with nothing set, as in production', () => {
+  it('talks to Stripe itself', () => {
+    delete process.env.STRIPE_API_BASE_URL
+    expect(stripeTarget(stripeClient('sk_test_x'))).toMatchObject({
+      host: 'api.stripe.com',
+      protocol: 'https',
+    })
+  })
+
+  it('talks to PayPal itself, live or sandbox as the workshop chose', () => {
+    delete process.env.PAYPAL_API_BASE_URL
+    expect(paypalApiBase(false)).toBe('https://api-m.paypal.com')
+    expect(paypalApiBase(true)).toBe('https://api-m.sandbox.paypal.com')
+  })
+
+  it('treats a blank variable as nothing set', () => {
+    process.env.STRIPE_API_BASE_URL = '   '
+    process.env.PAYPAL_API_BASE_URL = ''
+    expect(stripeTarget(stripeClient('sk_test_x')).host).toBe('api.stripe.com')
+    expect(paypalApiBase(false)).toBe('https://api-m.paypal.com')
+  })
+})
+
+describe('with a stand-in configured', () => {
+  it('sends Stripe calls to it, over plain http when that is what it says', () => {
+    process.env.STRIPE_API_BASE_URL = 'http://127.0.0.1:8026'
+    expect(stripeTarget(stripeClient('sk_test_x'))).toEqual({
+      host: '127.0.0.1',
+      port: '8026',
+      protocol: 'http',
+    })
+  })
+
+  it('sends PayPal calls to it whichever mode the workshop picked', () => {
+    process.env.PAYPAL_API_BASE_URL = 'http://127.0.0.1:8026/'
+    expect(paypalApiBase(false)).toBe('http://127.0.0.1:8026')
+    expect(paypalApiBase(true)).toBe('http://127.0.0.1:8026')
+  })
+})

+ 95 - 0
src/__tests__/lib/record-vendor-payment.test.ts

@@ -0,0 +1,95 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+/**
+ * Booking a reported payment once.
+ *
+ * The database is what refuses the second row, and the end-to-end test
+ * `e2e/specs/payments/booked-once.spec.ts` races real reports against it.
+ * These pin what the code does around that: skip a row the key already holds
+ * rather than raise on it, hand back the row that won, say it was not created,
+ * and never pass off a different failure as a duplicate.
+ */
+
+const createManyAndReturn = vi.fn()
+const findFirst = vi.fn()
+vi.mock('@/lib/db', () => ({
+  db: {
+    payment: {
+      createManyAndReturn: (a: unknown) => createManyAndReturn(a),
+      findFirst: (a: unknown) => findFirst(a),
+    },
+  },
+}))
+
+const { recordVendorPayment } = await import('@/lib/payment-providers/record-payment')
+
+const input = {
+  serviceRecordId: 'job_1',
+  provider: 'stripe',
+  externalId: 'cs_test_1',
+  amount: 400,
+  method: 'stripe',
+}
+
+beforeEach(() => {
+  createManyAndReturn.mockReset()
+  findFirst.mockReset()
+})
+
+describe('a payment reported for the first time', () => {
+  it('is written, and said to be new', async () => {
+    createManyAndReturn.mockResolvedValue([{ id: 'pay_1' }])
+    await expect(recordVendorPayment(input)).resolves.toEqual({ id: 'pay_1', created: true })
+    expect(findFirst).not.toHaveBeenCalled()
+  })
+
+  it('is written so that a row the key already holds is skipped, not raised on', async () => {
+    // A duplicate is the normal case for a card payment, so it must not be an
+    // error: raised and caught, it would be logged for most payments taken.
+    createManyAndReturn.mockResolvedValue([{ id: 'pay_1' }])
+    await recordVendorPayment(input)
+    expect(createManyAndReturn).toHaveBeenCalledWith(
+      expect.objectContaining({
+        skipDuplicates: true,
+        data: [
+          expect.objectContaining({
+            serviceRecordId: 'job_1',
+            provider: 'stripe',
+            externalId: 'cs_test_1',
+            amount: 400,
+          }),
+        ],
+      })
+    )
+  })
+})
+
+describe('a payment another report has just written', () => {
+  it('comes back as the row that won, not as a second one', async () => {
+    createManyAndReturn.mockResolvedValue([])
+    findFirst.mockResolvedValue({ id: 'pay_1' })
+
+    await expect(recordVendorPayment(input)).resolves.toEqual({ id: 'pay_1', created: false })
+    // Looked up by the same three things the database keeps unique.
+    expect(findFirst).toHaveBeenCalledWith(
+      expect.objectContaining({
+        where: { serviceRecordId: 'job_1', provider: 'stripe', externalId: 'cs_test_1' },
+      })
+    )
+  })
+})
+
+describe('anything else going wrong', () => {
+  it('is not mistaken for a duplicate', async () => {
+    const down = Object.assign(new Error('Connection lost'), { code: 'P1001' })
+    createManyAndReturn.mockRejectedValue(down)
+    await expect(recordVendorPayment(input)).rejects.toBe(down)
+    expect(findFirst).not.toHaveBeenCalled()
+  })
+
+  it('is not swallowed when nothing was written and no row holds the payment', async () => {
+    createManyAndReturn.mockResolvedValue([])
+    findFirst.mockResolvedValue(null)
+    await expect(recordVendorPayment(input)).rejects.toThrow(/was not written and no row holds it/)
+  })
+})

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

@@ -3,6 +3,7 @@ import { z } from 'zod'
 import { db } from '@/lib/db'
 import { PAYMENT_CONNECTOR_IDS, paymentProviderFor } from '@/features/integrations/Lib/payments'
 import { paymentMatchesRecord } from '@/lib/payment-providers/attribution'
+import { recordVendorPayment } from '@/lib/payment-providers/record-payment'
 import { rateLimit } from '@/lib/rate-limit'
 import { notify } from '@/lib/notify'
 import { resolvePortalOrg } from '@/lib/portal-slug'
@@ -75,22 +76,17 @@ export async function POST(
       )
     }
 
-    // Idempotent: check if payment with this externalId already exists
-    const existing = await db.payment.findFirst({
-      where: { externalId },
+    // Once, however many reports arrive together: the vendor's notification
+    // usually lands in the same second as the customer coming back.
+    const { created } = await recordVendorPayment({
+      amount: result.amount,
+      method: provider,
+      provider,
+      externalId,
+      serviceRecordId: record.id,
     })
 
-    if (!existing) {
-      await db.payment.create({
-        data: {
-          amount: result.amount,
-          method: provider,
-          provider,
-          externalId,
-          serviceRecordId: record.id,
-        },
-      })
-
+    if (created) {
       notify({
         organizationId: orgId,
         type: 'invoice_payment',

+ 10 - 9
src/app/api/webhooks/paypal/route.ts

@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
 import { db } from '@/lib/db'
 import { paymentProviderFor } from '@/features/integrations/Lib/payments'
 import { paymentMatchesRecord } from '@/lib/payment-providers/attribution'
+import { recordVendorPayment } from '@/lib/payment-providers/record-payment'
 
 export async function POST(request: Request) {
   try {
@@ -40,7 +41,9 @@ export async function POST(request: Request) {
 }
 
 async function processPayPalPayment(orderId: string, orgId: string, serviceRecordId: string) {
-  // Idempotent check
+  // A shortcut, not the guarantee: an order already on the books is not sent
+  // back to PayPal to be captured again. The write below is what keeps two
+  // reports arriving together from both booking it.
   const existing = await db.payment.findFirst({
     where: { externalId: orderId },
   })
@@ -77,14 +80,12 @@ async function processPayPalPayment(orderId: string, orgId: string, serviceRecor
   })
 
   if (record && record.organizationId === orgId) {
-    await db.payment.create({
-      data: {
-        amount: result.amount,
-        method: 'paypal',
-        provider: 'paypal',
-        externalId: orderId,
-        serviceRecordId,
-      },
+    await recordVendorPayment({
+      amount: result.amount,
+      method: 'paypal',
+      provider: 'paypal',
+      externalId: orderId,
+      serviceRecordId,
     })
   }
 

+ 22 - 27
src/app/api/webhooks/stripe/route.ts

@@ -3,6 +3,8 @@ import Stripe from 'stripe'
 import { db } from '@/lib/db'
 import { paymentProviderFor } from '@/features/integrations/Lib/payments'
 import { paymentMatchesRecord } from '@/lib/payment-providers/attribution'
+import { stripeClient } from '@/lib/payment-providers/vendor-hosts'
+import { recordVendorPayment } from '@/lib/payment-providers/record-payment'
 
 export async function POST(request: Request) {
   try {
@@ -42,7 +44,7 @@ export async function POST(request: Request) {
     const webhookSecret = connected?.setup.credentials.webhookSecret
     const signingSecret = typeof webhookSecret === 'string' ? webhookSecret : ''
 
-    const stripe = new Stripe(secretKey)
+    const stripe = stripeClient(secretKey)
 
     // Establish an AUTHENTIC session object. Two trust paths, never the body:
     //  - webhook secret configured → verify the signature over the raw body;
@@ -87,35 +89,28 @@ export async function POST(request: Request) {
       return NextResponse.json({ received: true })
     }
 
-    // Idempotent: check if payment already recorded
-    const existing = await db.payment.findFirst({
-      where: { externalId: session.id },
+    // Verify service record exists and belongs to this org
+    const record = await db.serviceRecord.findUnique({
+      where: { id: serviceRecordId },
+      select: { id: true, organizationId: true },
     })
 
-    if (!existing) {
-      // Verify service record exists and belongs to this org
-      const record = await db.serviceRecord.findUnique({
-        where: { id: serviceRecordId },
-        select: { id: true, organizationId: true },
+    if (
+      record &&
+      paymentMatchesRecord(
+        { serviceRecordId, organizationId: orgId },
+        { serviceRecordId: record.id, organizationId: record.organizationId ?? '' }
+      )
+    ) {
+      // Once, however many reports arrive together: Stripe retries, and the
+      // customer coming back to the invoice reports the same session.
+      await recordVendorPayment({
+        amount: (session.amount_total ?? 0) / 100,
+        method: 'stripe',
+        provider: 'stripe',
+        externalId: session.id,
+        serviceRecordId,
       })
-
-      if (
-        record &&
-        paymentMatchesRecord(
-          { serviceRecordId, organizationId: orgId },
-          { serviceRecordId: record.id, organizationId: record.organizationId ?? '' }
-        )
-      ) {
-        await db.payment.create({
-          data: {
-            amount: (session.amount_total ?? 0) / 100,
-            method: 'stripe',
-            provider: 'stripe',
-            externalId: session.id,
-            serviceRecordId,
-          },
-        })
-      }
     }
 
     return NextResponse.json({ received: true })

+ 10 - 9
src/app/api/webhooks/vipps/route.ts

@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
 import { db } from '@/lib/db'
 import { paymentProviderFor } from '@/features/integrations/Lib/payments'
 import { paymentMatchesRecord } from '@/lib/payment-providers/attribution'
+import { recordVendorPayment } from '@/lib/payment-providers/record-payment'
 
 export async function POST(request: Request) {
   try {
@@ -39,7 +40,9 @@ export async function POST(request: Request) {
 }
 
 async function processVippsPayment(reference: string, orgId: string, serviceRecordId: string) {
-  // Idempotent check
+  // A shortcut, not the guarantee: a payment already on the books is not
+  // checked with Vipps again. The write below is what keeps two reports
+  // arriving together from both booking it.
   const existing = await db.payment.findFirst({
     where: { externalId: reference },
   })
@@ -75,14 +78,12 @@ async function processVippsPayment(reference: string, orgId: string, serviceReco
   })
 
   if (record && record.organizationId === orgId) {
-    await db.payment.create({
-      data: {
-        amount: result.amount,
-        method: 'vipps',
-        provider: 'vipps',
-        externalId: reference,
-        serviceRecordId,
-      },
+    await recordVendorPayment({
+      amount: result.amount,
+      method: 'vipps',
+      provider: 'vipps',
+      externalId: reference,
+      serviceRecordId,
     })
   }
 

+ 20 - 2
src/components/feature-hint/feature-hint.tsx

@@ -1,6 +1,6 @@
 'use client'
 
-import { useEffect } from 'react'
+import { useEffect, useRef } from 'react'
 import Link from 'next/link'
 import { useTranslations } from 'next-intl'
 import { Popover, PopoverAnchor, PopoverArrow, PopoverContent } from '@/components/ui/popover'
@@ -98,9 +98,23 @@ export function FeatureHint({
     return () => document.removeEventListener('keydown', onKeyDown)
   }, [open, dismiss])
 
+  // An announcement can point at something the person cannot see. The
+  // Settings row sits at the foot of a sidebar that scrolls, and on a laptop
+  // screen it is below the fold: the card was drawn beside it, half off the
+  // bottom of the window, with the only button that closes it out of reach.
+  // So the row is brought into view first, and the card lands where it can be
+  // read and closed. Only for announcements, which nobody asked for; a hint
+  // follows something the person just did, so its anchor is already in view.
+  const anchorRef = useRef<HTMLDivElement>(null)
+  useEffect(() => {
+    // Optional, because not every environment the card renders in can
+    // scroll: a card that throws on mount is worse than one that stays put.
+    if (open && loud) anchorRef.current?.scrollIntoView?.({ block: 'nearest' })
+  }, [open, loud])
+
   return (
     <Popover open={open}>
-      <PopoverAnchor asChild>
+      <PopoverAnchor asChild ref={anchorRef}>
         {typeof children === 'function' ? children(open) : children}
       </PopoverAnchor>
       <PopoverContent
@@ -108,6 +122,10 @@ export function FeatureHint({
         align="center"
         sideOffset={10}
         collisionPadding={12}
+        // Kept inside the window even when that means leaving the anchor's
+        // side: a card whose button is off-screen cannot be closed at all,
+        // which is worse than an arrow that points a little off.
+        sticky="always"
         // Focus stays where the person put it.
         onOpenAutoFocus={(event) => event.preventDefault()}
         onCloseAutoFocus={(event) => event.preventDefault()}

+ 14 - 12
src/features/integrations/Lib/accounting-sync.ts

@@ -296,21 +296,23 @@ export async function recordPulledPayment(
     select: { id: true },
   })
   if (existing) return { id: existing.id, created: false }
-  const created = await db.payment.create({
-    data: {
-      serviceRecordId: input.serviceRecordId,
-      amount: input.amount,
-      date: input.date,
-      method: input.method,
-      note: input.note,
-      provider: input.provider,
-      externalId: input.externalId,
-    },
-    select: { id: true },
+  // Two pulls overlapping can both get past the lookup above; the write is
+  // what books the payment once, and only the one that wrote issues the
+  // invoice.
+  const { recordVendorPayment } = await import('@/lib/payment-providers/record-payment')
+  const recorded = await recordVendorPayment({
+    serviceRecordId: input.serviceRecordId,
+    amount: input.amount,
+    date: input.date,
+    method: input.method,
+    note: input.note,
+    provider: input.provider,
+    externalId: input.externalId,
   })
+  if (!recorded.created) return recorded
   const { issueInvoice } = await import('@/features/invoices/Lib/issueInvoice')
   await issueInvoice(input.serviceRecordId, organizationId, 'paid')
-  return { id: created.id, created: true }
+  return recorded
 }
 
 /** Undo a pulled payment the ledger has since deleted. Only rows a pull made are touched. */

+ 3 - 2
src/integrations/stripe/server.ts

@@ -1,5 +1,6 @@
 import Stripe from 'stripe'
 import type { ConnectorContext, ConnectorServer } from '@/features/integrations/Lib/types'
+import { stripeClient } from '@/lib/payment-providers/vendor-hosts'
 import { manifest } from './manifest'
 
 function secretKeyOf(ctx: ConnectorContext): string {
@@ -29,7 +30,7 @@ export const connector: ConnectorServer = {
     const secretKey = secretKeyOf(ctx)
     if (!secretKey) return { ok: false, message: 'Stripe: a secret key is required' }
     try {
-      await new Stripe(secretKey).accounts.retrieve()
+      await stripeClient(secretKey).accounts.retrieve()
       return { ok: true }
     } catch (err) {
       if (err instanceof Stripe.errors.StripeAuthenticationError) {
@@ -40,7 +41,7 @@ export const connector: ConnectorServer = {
   },
 
   async identify(ctx) {
-    const account = await new Stripe(secretKeyOf(ctx)).accounts.retrieve()
+    const account = await stripeClient(secretKeyOf(ctx)).accounts.retrieve()
     return { id: account.id, name: accountName(account) }
   },
 

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

@@ -1,4 +1,5 @@
 import type { PaymentProvider, CheckoutRequest, CheckoutResult, VerifyResult } from './types'
+import { paypalApiBase } from './vendor-hosts'
 
 export interface PayPalConfig {
   clientId: string
@@ -27,9 +28,7 @@ export class PayPalProvider implements PaymentProvider {
 
   constructor(config: PayPalConfig) {
     this.config = config
-    this.baseUrl = config.useSandbox
-      ? 'https://api-m.sandbox.paypal.com'
-      : 'https://api-m.paypal.com'
+    this.baseUrl = paypalApiBase(config.useSandbox)
   }
 
   async getAccessToken(): Promise<string> {

+ 70 - 0
src/lib/payment-providers/record-payment.ts

@@ -0,0 +1,70 @@
+import { db } from '@/lib/db'
+
+export interface VendorPaymentInput {
+  serviceRecordId: string
+  /** Who reported it: stripe, paypal, vipps, or a ledger such as quickbooks. */
+  provider: string
+  /** The reporter's own id for the payment. */
+  externalId: string
+  amount: number
+  /** One of the app's methods, which for a vendor is usually its own name. */
+  method: string
+  date?: Date
+  note?: string | null
+}
+
+/**
+ * Records a payment somebody else reported, exactly once.
+ *
+ * A customer's payment is reported more than once by design: their browser
+ * comes back to the invoice and asks for it to be checked, the vendor sends a
+ * notification, and the vendor retries that notification. They arrive in the
+ * same second. Every path used to look for the payment and then write it, as
+ * two steps, and two reports could both find nothing and both write a row.
+ *
+ * The table holds a unique key on (serviceRecordId, provider, externalId), and
+ * the write skips a row that key already holds, in the same statement. So the
+ * write is the check, and a report that loses the race is not an error: most
+ * card payments are reported twice, and a conflict raised and caught for each
+ * would put an error in the server log for most payments a workshop takes.
+ * The report that loses gets back the row that won, marked as not created, and
+ * a caller that tells somebody about a new payment does so only for the one
+ * that was.
+ */
+export async function recordVendorPayment(
+  input: VendorPaymentInput
+): Promise<{ id: string; created: boolean }> {
+  const [row] = await db.payment.createManyAndReturn({
+    data: [
+      {
+        serviceRecordId: input.serviceRecordId,
+        provider: input.provider,
+        externalId: input.externalId,
+        amount: input.amount,
+        method: input.method,
+        ...(input.date ? { date: input.date } : {}),
+        ...(input.note !== undefined ? { note: input.note } : {}),
+      },
+    ],
+    skipDuplicates: true,
+    select: { id: true },
+  })
+  if (row) return { id: row.id, created: true }
+
+  const existing = await db.payment.findFirst({
+    where: {
+      serviceRecordId: input.serviceRecordId,
+      provider: input.provider,
+      externalId: input.externalId,
+    },
+    select: { id: true },
+  })
+  // Skipped as a duplicate with no row holding it would mean a conflict on
+  // something other than the payment key, which is not the race this handles.
+  if (!existing) {
+    throw new Error(
+      `A ${input.provider} payment ${input.externalId} was not written and no row holds it`
+    )
+  }
+  return { id: existing.id, created: false }
+}

+ 3 - 2
src/lib/payment-providers/stripe.ts

@@ -1,11 +1,12 @@
-import Stripe from 'stripe'
+import type Stripe from 'stripe'
+import { stripeClient } from './vendor-hosts'
 import type { PaymentProvider, CheckoutRequest, CheckoutResult, VerifyResult } from './types'
 
 export class StripeProvider implements PaymentProvider {
   private stripe: Stripe
 
   constructor(secretKey: string) {
-    this.stripe = new Stripe(secretKey)
+    this.stripe = stripeClient(secretKey)
   }
 
   async createCheckout(req: CheckoutRequest): Promise<CheckoutResult> {

+ 31 - 0
src/lib/payment-providers/vendor-hosts.ts

@@ -0,0 +1,31 @@
+import Stripe from 'stripe'
+
+/**
+ * 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
+ * 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
+ * cannot set them, so no tenant can send its keys, or anyone else's,
+ * somewhere other than the vendor.
+ */
+
+/** Stripe's client, aimed at the stand-in when one is configured. */
+export function stripeClient(secretKey: string): Stripe {
+  const base = process.env.STRIPE_API_BASE_URL?.trim()
+  if (!base) return new Stripe(secretKey)
+  const url = new URL(base)
+  return new Stripe(secretKey, {
+    host: url.hostname,
+    port: url.port ? Number(url.port) : undefined,
+    protocol: url.protocol === 'http:' ? 'http' : 'https',
+  })
+}
+
+/** PayPal's REST base, sandbox or live, unless a stand-in is configured. */
+export function paypalApiBase(useSandbox: boolean): string {
+  const base = process.env.PAYPAL_API_BASE_URL?.trim()
+  if (base) return base.replace(/\/+$/, '')
+  return useSandbox ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com'
+}