checkout.spec.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325
  1. import { expect, type Page, test } from '@playwright/test'
  2. import Stripe from 'stripe'
  3. import { forgetConnections, ownerOrganizationId, paymentsFor } from '../../support/db'
  4. import { settle } from '../../support/hydration'
  5. import {
  6. clearPaymentSink,
  7. connectVendor,
  8. expectConnection,
  9. paymentSink,
  10. } from '../../support/payments'
  11. import {
  12. addPart,
  13. newWorkOrder,
  14. saveWorkOrder,
  15. seededVehicleUrl,
  16. shareLink,
  17. } from '../../support/work-order'
  18. /**
  19. * A customer pays an invoice online, and the workshop's books follow.
  20. *
  21. * The chain this file walks is the one a workshop depends on without ever
  22. * seeing it: keys typed into Settings → Integrations, a pay button on the
  23. * shared invoice for exactly what is owed, the customer sent to the vendor
  24. * and back, and a payment recorded once, against the right invoice, however
  25. * many times the vendor or the browser reports it. Getting any link wrong
  26. * either loses money quietly or books money twice.
  27. *
  28. * Stripe and PayPal are played by `e2e/payment-sink.ts`, which answers the
  29. * calls the app makes with the shapes the vendors use and has a checkout page
  30. * a spec pays on. What it records is how the amount charged is checked against
  31. * the amount the invoice showed.
  32. *
  33. * Pinned on the seeded workshop's 25% exclusive tax in dollars: one part at
  34. * 800 makes a total of 1,000.00. 400 is paid by card, then 600 through PayPal.
  35. */
  36. test.describe.configure({ mode: 'serial' })
  37. const stamp = Date.now()
  38. const STRIPE_KEY = `sk_test_e2e_${stamp}`
  39. const WEBHOOK_SECRET = `whsec_e2e_${stamp}`
  40. let jobUrl = ''
  41. let jobId = ''
  42. let invoiceUrl = ''
  43. let organizationId = ''
  44. /** The shared invoice, hydrated, as the customer opens it. */
  45. async function openInvoice(page: Page, url = invoiceUrl): Promise<void> {
  46. await page.goto(url)
  47. await settle(page)
  48. }
  49. /** The badge on the work order's invoice card: Unpaid, Partial or Paid. */
  50. async function expectWorkOrderPaymentState(
  51. page: Page,
  52. state: 'Unpaid' | 'Partial' | 'Paid'
  53. ): Promise<void> {
  54. await page.goto(jobUrl)
  55. await settle(page)
  56. await expect(
  57. page.getByTestId('payment-status').filter({ hasText: new RegExp(`^${state}$`) }),
  58. `the work order reads ${state}`
  59. ).toBeVisible()
  60. }
  61. /** Starts a payment of `amount` with a vendor, and lands on its checkout page. */
  62. async function startPayment(page: Page, vendor: 'Card' | 'PayPal', amount: string): Promise<void> {
  63. await openInvoice(page)
  64. await expect(async () => {
  65. await page.getByRole('button', { name: 'Partial payment', exact: true }).click()
  66. await expect(page.locator('#payAmount')).toBeVisible({ timeout: 2_000 })
  67. }).toPass({ timeout: 30_000 })
  68. await page.locator('#payAmount').fill(amount)
  69. await page.getByRole('button', { name: new RegExp(`with ${vendor}$`) }).click()
  70. await expect(page.getByRole('heading', { name: /checkout/ })).toBeVisible({ timeout: 30_000 })
  71. }
  72. /** A Stripe notification, signed with the workshop's webhook secret unless told otherwise. */
  73. function stripeNotification(session: unknown, secret = WEBHOOK_SECRET) {
  74. const payload = JSON.stringify({
  75. id: `evt_e2e_${Date.now()}`,
  76. object: 'event',
  77. type: 'checkout.session.completed',
  78. data: { object: session },
  79. })
  80. const signature = new Stripe('sk_test_unused').webhooks.generateTestHeaderString({
  81. payload,
  82. secret,
  83. })
  84. return { payload, signature }
  85. }
  86. test.beforeAll(async ({ browser }) => {
  87. await clearPaymentSink()
  88. // A spec that failed halfway must not leave a connection behind, and this
  89. // one must start from none.
  90. await forgetConnections(['stripe', 'paypal'])
  91. organizationId = await ownerOrganizationId()
  92. const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
  93. const vehicleUrl = await seededVehicleUrl(page)
  94. jobUrl = await newWorkOrder(page, vehicleUrl, `E2E paid online ${stamp}`)
  95. jobId = jobUrl.split('/').pop() ?? ''
  96. await addPart(page, { name: `E2E alternator ${stamp}`, quantity: 1, unitPrice: 800 })
  97. await saveWorkOrder(page)
  98. await page.close()
  99. })
  100. test.afterAll(async () => {
  101. // Every other spec shares invoices, and a connected vendor would put a pay
  102. // button on all of them.
  103. await forgetConnections(['stripe', 'paypal'])
  104. })
  105. test.describe('connecting a vendor', () => {
  106. test('refuses a Stripe key that Stripe does not accept', async ({ page }) => {
  107. await connectVendor(page, 'stripe', { secretKey: 'sk_test_wrong' })
  108. // Checked against the vendor before anything is stored as live, and the
  109. // workshop is told why, where they typed it.
  110. await expect(page.getByText('Stripe rejected the secret key')).toBeVisible({ timeout: 30_000 })
  111. await expectConnection('stripe', 'error')
  112. })
  113. test('connects Stripe with a key it does accept', async ({ page }) => {
  114. await connectVendor(page, 'stripe', { secretKey: STRIPE_KEY, webhookSecret: WEBHOOK_SECRET })
  115. await expectConnection('stripe', 'active')
  116. // And shows where Stripe must send its notifications, which is the step a
  117. // workshop most often misses.
  118. await page.goto('/settings/integrations/stripe')
  119. await settle(page)
  120. await expect(page.getByText('Inbound webhook URL')).toBeVisible()
  121. await expect(page.getByText(/\/api\/webhooks\/stripe/).first()).toBeVisible()
  122. })
  123. test('refuses a PayPal secret that PayPal does not accept', async ({ page }) => {
  124. await connectVendor(page, 'paypal', { clientId: 'e2e-client', clientSecret: 'wrong-secret' })
  125. await expect(page.getByText(/PayPal auth failed/)).toBeVisible({ timeout: 30_000 })
  126. await expectConnection('paypal', 'error')
  127. })
  128. test('connects PayPal with an id and secret it does accept', async ({ page }) => {
  129. await connectVendor(page, 'paypal', {
  130. clientId: `e2e-client-${stamp}`,
  131. clientSecret: `e2e-secret-${stamp}`,
  132. })
  133. await expectConnection('paypal', 'active')
  134. })
  135. })
  136. test.describe('the invoice a customer is sent', () => {
  137. test('shows what is owed, and offers both vendors for exactly that', async ({ page }) => {
  138. await page.goto(jobUrl)
  139. invoiceUrl = await shareLink(page)
  140. await openInvoice(page)
  141. await expect(page.getByText('Balance Due').first()).toBeVisible()
  142. await expect(page.getByText(/\$1,?000\.00/).first(), 'the total the job came to').toBeVisible()
  143. await expect(page.getByRole('button', { name: /Pay \$1,?000\.00 with Card/ })).toBeVisible()
  144. await expect(page.getByRole('button', { name: /Pay \$1,?000\.00 with PayPal/ })).toBeVisible()
  145. })
  146. test('refuses to charge more than is owed', async ({ request }) => {
  147. const [org, token] = new URL(invoiceUrl).pathname.split('/').slice(-2)
  148. const before = (await paymentSink()).stripe.length
  149. const response = await request.post(`/api/public/share/invoice/${org}/${token}/checkout`, {
  150. data: { provider: 'stripe', amount: 1000.5 },
  151. })
  152. expect(response.status(), 'more than the balance').toBe(400)
  153. // Refused before the vendor was asked for anything.
  154. expect((await paymentSink()).stripe.length).toBe(before)
  155. })
  156. })
  157. test.describe('paying part of it by card', () => {
  158. test('charges what the customer chose, for this invoice', async ({ page }) => {
  159. await startPayment(page, 'Card', '400')
  160. // The vendor was asked for exactly that, in cents, and told whose invoice
  161. // it is: the metadata is what the notification is matched on later.
  162. const session = (await paymentSink()).stripe.at(-1)
  163. expect(session?.amount_total, 'the amount sent to Stripe').toBe(40000)
  164. expect(session?.currency).toBe('usd')
  165. expect(session?.metadata.serviceRecordId).toBe(jobId)
  166. expect(session?.metadata.orgId).toBe(organizationId)
  167. await expect(page.locator('#amount')).toHaveText('400.00 USD')
  168. })
  169. test('is recorded when the customer comes back, and the invoice says what is left', async ({
  170. page,
  171. }) => {
  172. await startPayment(page, 'Card', '400')
  173. await page.getByRole('button', { name: 'Pay', exact: true }).click()
  174. // Back on the invoice, which checks with Stripe before believing it.
  175. await expect(page.getByText('Payment received!')).toBeVisible({ timeout: 30_000 })
  176. await expect(page.getByText(/\$400\.00 has been applied/)).toBeVisible()
  177. const recorded = await paymentsFor(jobId)
  178. expect(recorded.map((p) => [p.provider, p.amount])).toEqual([['stripe', 400]])
  179. await expectWorkOrderPaymentState(page, 'Partial')
  180. // The customer's copy owes the rest, and offers it.
  181. await openInvoice(page)
  182. await expect(page.getByRole('button', { name: /Pay \$600\.00 with Card/ })).toBeVisible()
  183. })
  184. test('is not counted twice when the same payment is reported again', async ({
  185. page,
  186. request,
  187. }) => {
  188. const paid = (await paymentSink()).stripe.filter((s) => s.payment_status === 'paid')
  189. const session = paid.at(-1)
  190. expect(session, 'a paid session from the test before').toBeTruthy()
  191. // The customer reloads the page Stripe sent them back to.
  192. await page.goto(`${invoiceUrl}?session_id=${session?.id}`)
  193. await settle(page)
  194. await expect(page.getByText(/Payment received!|could not be verified/)).toBeVisible({
  195. timeout: 30_000,
  196. })
  197. // And Stripe's own notification for the same session arrives afterwards,
  198. // as it always does in real life: two reports of one payment.
  199. const { payload, signature } = stripeNotification(session)
  200. const notified = await request.post('/api/webhooks/stripe', {
  201. data: payload,
  202. headers: { 'content-type': 'application/json', 'stripe-signature': signature },
  203. })
  204. expect(notified.status()).toBe(200)
  205. expect(
  206. (await paymentsFor(jobId)).map((p) => [p.provider, p.amount]),
  207. 'still one payment of 400'
  208. ).toEqual([['stripe', 400]])
  209. })
  210. test('ignores a notification that Stripe did not sign', async ({ request }) => {
  211. // A forged "this invoice is paid", which is what the signature is for.
  212. const forged = {
  213. id: `cs_test_forged_${stamp}`,
  214. object: 'checkout.session',
  215. payment_status: 'paid',
  216. amount_total: 60000,
  217. metadata: { serviceRecordId: jobId, orgId: organizationId },
  218. }
  219. const { payload, signature } = stripeNotification(forged, 'whsec_not_the_workshops')
  220. const response = await request.post('/api/webhooks/stripe', {
  221. data: payload,
  222. headers: { 'content-type': 'application/json', 'stripe-signature': signature },
  223. })
  224. expect(response.status(), 'the signature does not verify').toBe(400)
  225. expect((await paymentsFor(jobId)).length, 'nothing recorded').toBe(1)
  226. })
  227. })
  228. test.describe('a customer who changes their mind at the vendor', () => {
  229. test('pays nothing, and nothing is recorded', async ({ page }) => {
  230. await startPayment(page, 'PayPal', '600')
  231. await page.getByRole('link', { name: 'Cancel' }).click()
  232. // Back on the invoice with the same balance, and no payment on the books.
  233. await expect(page).toHaveURL(new RegExp(new URL(invoiceUrl).pathname))
  234. await settle(page)
  235. await expect(page.getByRole('button', { name: /Pay \$600\.00 with PayPal/ })).toBeVisible()
  236. expect((await paymentsFor(jobId)).length).toBe(1)
  237. })
  238. })
  239. test.describe('paying the rest through PayPal', () => {
  240. test('settles the invoice', async ({ page }) => {
  241. await startPayment(page, 'PayPal', '600')
  242. const order = (await paymentSink()).paypal.at(-1)
  243. expect(order?.amount, 'the amount sent to PayPal').toEqual({
  244. currency_code: 'USD',
  245. value: '600.00',
  246. })
  247. expect(order?.custom_id).toBe(`${jobId}:${organizationId}`)
  248. await page.getByRole('button', { name: 'Pay', exact: true }).click()
  249. await expect(page.getByText('Payment received!')).toBeVisible({ timeout: 30_000 })
  250. expect((await paymentsFor(jobId)).map((p) => [p.provider, p.amount])).toEqual([
  251. ['stripe', 400],
  252. ['paypal', 600],
  253. ])
  254. await expectWorkOrderPaymentState(page, 'Paid')
  255. // Nothing is owed, so the customer is offered nothing to pay.
  256. await openInvoice(page)
  257. await expect(page.getByRole('button', { name: /with Card|with PayPal/ })).toHaveCount(0)
  258. })
  259. test('is not counted twice when PayPal reports it too', async ({ request }) => {
  260. const order = (await paymentSink()).paypal.find((o) => o.status === 'COMPLETED')
  261. expect(order, 'the order paid in the test before').toBeTruthy()
  262. const response = await request.post('/api/webhooks/paypal', {
  263. data: {
  264. event_type: 'PAYMENT.CAPTURE.COMPLETED',
  265. resource: {
  266. id: `CAP-${order?.id}`,
  267. custom_id: order?.custom_id,
  268. supplementary_data: { related_ids: { order_id: order?.id } },
  269. },
  270. },
  271. })
  272. expect(response.status()).toBe(200)
  273. expect((await paymentsFor(jobId)).length, 'still two payments').toBe(2)
  274. })
  275. test('refuses more money on an invoice that is paid in full', async ({ request }) => {
  276. const [org, token] = new URL(invoiceUrl).pathname.split('/').slice(-2)
  277. const response = await request.post(`/api/public/share/invoice/${org}/${token}/checkout`, {
  278. data: { provider: 'paypal', amount: 1 },
  279. })
  280. expect(response.status()).toBe(400)
  281. expect(await response.json()).toMatchObject({ error: 'Invoice is already paid in full' })
  282. })
  283. })