payment-attribution.spec.ts 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  1. import { expect, type Page, test } from '@playwright/test'
  2. import { forgetConnections, ownerOrganizationId, paymentsFor } from '../../support/db'
  3. import { settle } from '../../support/hydration'
  4. import {
  5. clearPaymentSink,
  6. connectVendor,
  7. expectConnection,
  8. paymentSink,
  9. type SinkPayPalOrder,
  10. } from '../../support/payments'
  11. import {
  12. addPart,
  13. newWorkOrder,
  14. saveWorkOrder,
  15. seededVehicleUrl,
  16. shareLink,
  17. } from '../../support/work-order'
  18. /**
  19. * A payment settles the invoice it was made for, and no other.
  20. *
  21. * The return page and the vendor's notification both name an order and an
  22. * invoice, and the audit found the app checked that the order was paid but
  23. * never that it was created for that invoice. Paying one unit on your own
  24. * invoice and posting the order against somebody else's marked theirs as
  25. * paid, and the idempotency key then blocked the real payment. The vendor's
  26. * own record of who the order was for is compared now, as Stripe's always
  27. * was.
  28. *
  29. * Two invoices, one payment on the first, and the paid order pointed at the
  30. * second through both doors.
  31. */
  32. test.describe.configure({ mode: 'serial' })
  33. const stamp = Date.now()
  34. const sinkUrl = process.env.E2E_PAYMENT_SINK ?? 'http://127.0.0.1:8026'
  35. let organizationId = ''
  36. /** The invoice that is paid, and the one the payment is pointed at. */
  37. let paidJobId = ''
  38. let paidInvoiceUrl = ''
  39. let otherJobId = ''
  40. let otherInvoiceUrl = ''
  41. /** The PayPal order paid on the first invoice. */
  42. let order: SinkPayPalOrder | undefined
  43. async function makeSharedInvoice(page: Page, title: string) {
  44. const vehicleUrl = await seededVehicleUrl(page)
  45. const jobUrl = await newWorkOrder(page, vehicleUrl, title)
  46. await addPart(page, { name: `E2E belt ${stamp}`, quantity: 1, unitPrice: 800 })
  47. await saveWorkOrder(page)
  48. return { jobId: jobUrl.split('/').pop() ?? '', invoiceUrl: await shareLink(page) }
  49. }
  50. function shareParts(invoiceUrl: string) {
  51. const [org, token] = new URL(invoiceUrl).pathname.split('/').slice(-2)
  52. return { org, token }
  53. }
  54. test.beforeAll(async ({ browser }) => {
  55. await clearPaymentSink()
  56. await forgetConnections(['paypal'])
  57. organizationId = await ownerOrganizationId()
  58. const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
  59. await connectVendor(page, 'paypal', {
  60. clientId: `e2e-client-${stamp}`,
  61. clientSecret: `e2e-secret-${stamp}`,
  62. })
  63. await expectConnection('paypal', 'active')
  64. ;({ jobId: paidJobId, invoiceUrl: paidInvoiceUrl } = await makeSharedInvoice(
  65. page,
  66. `E2E paid invoice ${stamp}`
  67. ))
  68. ;({ jobId: otherJobId, invoiceUrl: otherInvoiceUrl } = await makeSharedInvoice(
  69. page,
  70. `E2E other invoice ${stamp}`
  71. ))
  72. await page.close()
  73. })
  74. test.afterAll(async () => {
  75. // A connected vendor puts a pay button on every shared invoice.
  76. await forgetConnections(['paypal'])
  77. })
  78. test.describe('a PayPal order', () => {
  79. test('paid on one invoice is booked on that invoice', async ({ page }) => {
  80. await page.goto(paidInvoiceUrl)
  81. await settle(page)
  82. await expect(async () => {
  83. await page.getByRole('button', { name: 'Partial payment', exact: true }).click()
  84. await expect(page.locator('#payAmount')).toBeVisible({ timeout: 2_000 })
  85. }).toPass({ timeout: 30_000 })
  86. await page.locator('#payAmount').fill('100')
  87. await page.getByRole('button', { name: /with PayPal$/ }).click()
  88. await expect(page.getByRole('heading', { name: /checkout/ })).toBeVisible({ timeout: 30_000 })
  89. await page.getByRole('button', { name: 'Pay', exact: true }).click()
  90. await expect(page.getByText('Payment received!')).toBeVisible({ timeout: 30_000 })
  91. order = (await paymentSink()).paypal.find(
  92. (o) => o.custom_id === `${paidJobId}:${organizationId}` && o.status === 'COMPLETED'
  93. )
  94. expect(order, 'PayPal holds a completed order for the first invoice').toBeTruthy()
  95. expect((await paymentsFor(paidJobId)).map((p) => [p.provider, p.amount])).toEqual([
  96. ['paypal', 100],
  97. ])
  98. expect(await paymentsFor(otherJobId), 'and nothing on the other').toEqual([])
  99. })
  100. test('is refused by another invoice’s return page', async ({ request }) => {
  101. // The customer's browser, back from PayPal, with the other invoice's
  102. // link and the paid order's id.
  103. const { org, token } = shareParts(otherInvoiceUrl)
  104. const response = await request.post(`/api/public/share/invoice/${org}/${token}/verify`, {
  105. data: { provider: 'paypal', externalId: order?.id },
  106. })
  107. expect(response.status()).toBe(400)
  108. expect(await response.json()).toEqual({ error: 'Payment does not belong to this invoice' })
  109. expect(await paymentsFor(otherJobId), 'nothing was booked').toEqual([])
  110. })
  111. test('is refused by a notification that names another invoice', async ({ request }) => {
  112. // An order already on the books is answered without another look, so the
  113. // forgery has to be an order the app has not seen: created for the first
  114. // invoice, approved and captured at the vendor, and never brought back
  115. // to the app. That is what a notification that arrives first looks like.
  116. const { org, token } = shareParts(paidInvoiceUrl)
  117. const checkout = await request.post(`/api/public/share/invoice/${org}/${token}/checkout`, {
  118. data: { provider: 'paypal', amount: 50 },
  119. })
  120. expect(checkout.status()).toBe(200)
  121. const fresh = (await paymentSink()).paypal.find(
  122. (o) =>
  123. o.custom_id === `${paidJobId}:${organizationId}` && o.status === 'PAYER_ACTION_REQUIRED'
  124. )
  125. expect(fresh, 'PayPal holds the new order').toBeTruthy()
  126. await fetch(`${sinkUrl}/pay/paypal/${fresh?.id}`, { method: 'POST', redirect: 'manual' })
  127. const captured = await fetch(`${sinkUrl}/v2/checkout/orders/${fresh?.id}/capture`, {
  128. method: 'POST',
  129. headers: { authorization: 'Bearer E2E-ACCESS-TOKEN' },
  130. })
  131. expect(captured.status, 'the order is paid at the vendor').toBe(201)
  132. // PayPal's notification carries the invoice in `custom_id`; here it is
  133. // rewritten to the other invoice while the order stays the paid one.
  134. const notify = (customId: string) =>
  135. request.post('/api/webhooks/paypal', {
  136. data: {
  137. event_type: 'PAYMENT.CAPTURE.COMPLETED',
  138. resource: {
  139. id: `CAP-${fresh?.id}`,
  140. custom_id: customId,
  141. supplementary_data: { related_ids: { order_id: fresh?.id } },
  142. },
  143. },
  144. })
  145. const forged = await notify(`${otherJobId}:${organizationId}`)
  146. expect(forged.status()).toBe(400)
  147. expect(await forged.json()).toEqual({ error: 'Order does not belong to this record' })
  148. expect(await paymentsFor(otherJobId), 'nothing was booked').toEqual([])
  149. // The other invoice's return page is refused the same order.
  150. const other = shareParts(otherInvoiceUrl)
  151. const verify = await request.post(
  152. `/api/public/share/invoice/${other.org}/${other.token}/verify`,
  153. { data: { provider: 'paypal', externalId: fresh?.id } }
  154. )
  155. expect(verify.status()).toBe(400)
  156. expect(await paymentsFor(otherJobId), 'still nothing').toEqual([])
  157. // And the genuine notification books it where it belongs, once.
  158. const genuine = await notify(`${paidJobId}:${organizationId}`)
  159. expect(genuine.status()).toBe(200)
  160. expect((await paymentsFor(paidJobId)).map((p) => [p.provider, p.amount])).toEqual([
  161. ['paypal', 100],
  162. ['paypal', 50],
  163. ])
  164. })
  165. test('leaves the other invoice untouched', async () => {
  166. expect((await paymentsFor(paidJobId)).length).toBe(2)
  167. expect(await paymentsFor(otherJobId)).toEqual([])
  168. })
  169. })