payment-sink.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371
  1. import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
  2. /**
  3. * A payment vendor that moves no money.
  4. *
  5. * Stripe and PayPal are reached over the network with a workshop's own keys,
  6. * and neither can be used from a test run: no account to charge, and a hosted
  7. * checkout page that is theirs to change. So this stands in for both, speaking
  8. * just the calls the app makes, with the same shapes the vendors answer with,
  9. * and a checkout page of its own where a spec clicks "Pay" the way a customer
  10. * would. The app is pointed here by STRIPE_API_BASE_URL and
  11. * PAYPAL_API_BASE_URL, which only the environment can set.
  12. *
  13. * It keeps what it is given in memory and hands it back over /state, so a spec
  14. * can check that the amount a customer was charged is the amount the invoice
  15. * showed. A key or secret containing "wrong" is refused, the way a vendor
  16. * refuses one it does not know. Run on its own with `npx tsx e2e/payment-sink.ts`.
  17. */
  18. const PORT = Number(process.env.E2E_PAYMENT_PORT ?? 8026)
  19. const SELF = `http://127.0.0.1:${PORT}`
  20. interface StripeSession {
  21. id: string
  22. object: 'checkout.session'
  23. mode: 'payment'
  24. status: 'open' | 'complete' | 'expired'
  25. payment_status: 'unpaid' | 'paid'
  26. amount_total: number
  27. currency: string
  28. metadata: Record<string, string>
  29. success_url: string
  30. cancel_url: string
  31. url: string
  32. }
  33. interface PayPalOrder {
  34. id: string
  35. intent: 'CAPTURE'
  36. status: 'PAYER_ACTION_REQUIRED' | 'APPROVED' | 'COMPLETED'
  37. amount: { currency_code: string; value: string }
  38. custom_id: string
  39. invoice_id: string
  40. return_url: string
  41. cancel_url: string
  42. }
  43. const state = {
  44. stripe: [] as StripeSession[],
  45. paypal: [] as PayPalOrder[],
  46. /** Every request the app made, oldest first: "POST /v1/checkout/sessions". */
  47. calls: [] as string[],
  48. }
  49. let counter = 0
  50. /**
  51. * Stamped into every id, because the counter starts again with each run and
  52. * the database does not. A session called `cs_test_e2e_5` in this run is not
  53. * the one of that name an earlier run paid, and the app keys payments on
  54. * these ids exactly as it keys them on Stripe's and PayPal's.
  55. */
  56. const RUN = Date.now().toString(36)
  57. function json(res: ServerResponse, status: number, body: unknown): void {
  58. res.writeHead(status, { 'content-type': 'application/json' })
  59. res.end(JSON.stringify(body))
  60. }
  61. function html(res: ServerResponse, body: string): void {
  62. res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
  63. res.end(
  64. `<!doctype html><html><head><meta charset="utf-8"><title>E2E checkout</title></head><body>${body}</body></html>`
  65. )
  66. }
  67. function redirect(res: ServerResponse, to: string): void {
  68. res.writeHead(303, { location: to })
  69. res.end()
  70. }
  71. async function readBody(req: IncomingMessage): Promise<string> {
  72. const chunks: Buffer[] = []
  73. for await (const chunk of req) chunks.push(chunk as Buffer)
  74. return Buffer.concat(chunks).toString('utf8')
  75. }
  76. /**
  77. * Stripe's form encoding, `metadata[orgId]=…&line_items[0][price_data][unit_amount]=…`,
  78. * as the nested object it describes.
  79. */
  80. function parseStripeForm(body: string): Record<string, unknown> {
  81. const out: Record<string, unknown> = {}
  82. for (const [key, value] of new URLSearchParams(body)) {
  83. const path = key.split(/[[\]]+/).filter(Boolean)
  84. let node = out
  85. path.forEach((segment, i) => {
  86. if (i === path.length - 1) {
  87. node[segment] = value
  88. } else {
  89. node[segment] = (node[segment] as Record<string, unknown>) ?? {}
  90. node = node[segment] as Record<string, unknown>
  91. }
  92. })
  93. }
  94. return out
  95. }
  96. function stripeRefuses(req: IncomingMessage): boolean {
  97. const key = (req.headers.authorization ?? '').replace(/^Bearer\s+/i, '')
  98. return !key || key.includes('wrong')
  99. }
  100. function money(cents: number, currency: string): string {
  101. return `${(cents / 100).toFixed(2)} ${currency.toUpperCase()}`
  102. }
  103. async function handleStripe(req: IncomingMessage, res: ServerResponse, url: URL): Promise<boolean> {
  104. if (!url.pathname.startsWith('/v1/') || url.pathname.startsWith('/v1/oauth2')) return false
  105. if (stripeRefuses(req)) {
  106. json(res, 401, {
  107. error: {
  108. type: 'invalid_request_error',
  109. code: 'api_key_invalid',
  110. message: 'Invalid API Key provided',
  111. },
  112. })
  113. return true
  114. }
  115. if (req.method === 'GET' && url.pathname === '/v1/account') {
  116. json(res, 200, {
  117. id: 'acct_e2e',
  118. object: 'account',
  119. email: 'payments@e2e.test',
  120. settings: { dashboard: { display_name: 'E2E Stripe account' } },
  121. })
  122. return true
  123. }
  124. if (req.method === 'POST' && url.pathname === '/v1/checkout/sessions') {
  125. const form = parseStripeForm(await readBody(req)) as {
  126. line_items?: Record<string, { price_data?: { currency?: string; unit_amount?: string } }>
  127. metadata?: Record<string, string>
  128. success_url?: string
  129. cancel_url?: string
  130. }
  131. const line = form.line_items?.['0']?.price_data
  132. const id = `cs_test_e2e_${RUN}_${++counter}`
  133. const session: StripeSession = {
  134. id,
  135. object: 'checkout.session',
  136. mode: 'payment',
  137. status: 'open',
  138. payment_status: 'unpaid',
  139. amount_total: Number(line?.unit_amount ?? 0),
  140. currency: line?.currency ?? 'usd',
  141. metadata: form.metadata ?? {},
  142. success_url: form.success_url ?? '',
  143. cancel_url: form.cancel_url ?? '',
  144. url: `${SELF}/pay/stripe/${id}`,
  145. }
  146. state.stripe.push(session)
  147. json(res, 200, session)
  148. return true
  149. }
  150. const retrieve = url.pathname.match(/^\/v1\/checkout\/sessions\/([^/]+)$/)
  151. if (req.method === 'GET' && retrieve) {
  152. const session = state.stripe.find((s) => s.id === retrieve[1])
  153. if (!session) {
  154. json(res, 404, {
  155. error: {
  156. type: 'invalid_request_error',
  157. message: `No such checkout.session: '${retrieve[1]}'`,
  158. },
  159. })
  160. } else {
  161. json(res, 200, session)
  162. }
  163. return true
  164. }
  165. json(res, 404, { error: { type: 'invalid_request_error', message: `Unknown ${url.pathname}` } })
  166. return true
  167. }
  168. function paypalOrderBody(order: PayPalOrder) {
  169. const captures =
  170. order.status === 'COMPLETED'
  171. ? [
  172. {
  173. id: `CAP-${order.id}`,
  174. status: 'COMPLETED',
  175. amount: order.amount,
  176. custom_id: order.custom_id,
  177. },
  178. ]
  179. : undefined
  180. return {
  181. id: order.id,
  182. intent: order.intent,
  183. status: order.status,
  184. purchase_units: [
  185. {
  186. reference_id: 'default',
  187. custom_id: order.custom_id,
  188. invoice_id: order.invoice_id,
  189. amount: order.amount,
  190. ...(captures ? { payments: { captures } } : {}),
  191. },
  192. ],
  193. }
  194. }
  195. async function handlePayPal(req: IncomingMessage, res: ServerResponse, url: URL): Promise<boolean> {
  196. if (req.method === 'POST' && url.pathname === '/v1/oauth2/token') {
  197. const basic = (req.headers.authorization ?? '').replace(/^Basic\s+/i, '')
  198. const [clientId, secret] = Buffer.from(basic, 'base64').toString('utf8').split(':')
  199. if (!clientId || !secret || secret.includes('wrong')) {
  200. json(res, 401, { error: 'invalid_client', error_description: 'Client Authentication failed' })
  201. } else {
  202. json(res, 200, { access_token: 'E2E-ACCESS-TOKEN', token_type: 'Bearer', expires_in: 32400 })
  203. }
  204. return true
  205. }
  206. if (!url.pathname.startsWith('/v2/checkout/orders')) return false
  207. if (req.headers.authorization !== 'Bearer E2E-ACCESS-TOKEN') {
  208. json(res, 401, { name: 'AUTHENTICATION_FAILURE', message: 'Authentication failed' })
  209. return true
  210. }
  211. if (req.method === 'POST' && url.pathname === '/v2/checkout/orders') {
  212. const body = JSON.parse((await readBody(req)) || '{}')
  213. const unit = body.purchase_units?.[0] ?? {}
  214. const context = body.payment_source?.paypal?.experience_context ?? {}
  215. const id = `E2EORDER${RUN.toUpperCase()}${++counter}`
  216. const order: PayPalOrder = {
  217. id,
  218. intent: 'CAPTURE',
  219. status: 'PAYER_ACTION_REQUIRED',
  220. amount: unit.amount ?? { currency_code: 'USD', value: '0.00' },
  221. custom_id: unit.custom_id ?? '',
  222. invoice_id: unit.invoice_id ?? '',
  223. return_url: context.return_url ?? '',
  224. cancel_url: context.cancel_url ?? '',
  225. }
  226. state.paypal.push(order)
  227. json(res, 200, {
  228. id,
  229. status: order.status,
  230. links: [
  231. { rel: 'self', href: `${SELF}/v2/checkout/orders/${id}`, method: 'GET' },
  232. { rel: 'payer-action', href: `${SELF}/pay/paypal/${id}`, method: 'GET' },
  233. ],
  234. })
  235. return true
  236. }
  237. const capture = url.pathname.match(/^\/v2\/checkout\/orders\/([^/]+)\/capture$/)
  238. if (req.method === 'POST' && capture) {
  239. const order = state.paypal.find((o) => o.id === capture[1])
  240. if (!order) {
  241. json(res, 404, { name: 'RESOURCE_NOT_FOUND' })
  242. } else if (order.status === 'COMPLETED') {
  243. json(res, 422, {
  244. name: 'UNPROCESSABLE_ENTITY',
  245. details: [{ issue: 'ORDER_ALREADY_CAPTURED' }],
  246. })
  247. } else if (order.status !== 'APPROVED') {
  248. json(res, 422, { name: 'UNPROCESSABLE_ENTITY', details: [{ issue: 'ORDER_NOT_APPROVED' }] })
  249. } else {
  250. order.status = 'COMPLETED'
  251. const body = paypalOrderBody(order)
  252. // A capture answer carries the capture, not the unit's own custom_id.
  253. json(res, 201, {
  254. id: body.id,
  255. status: body.status,
  256. purchase_units: [{ reference_id: 'default', payments: body.purchase_units[0].payments }],
  257. })
  258. }
  259. return true
  260. }
  261. const show = url.pathname.match(/^\/v2\/checkout\/orders\/([^/]+)$/)
  262. if (req.method === 'GET' && show) {
  263. const order = state.paypal.find((o) => o.id === show[1])
  264. if (!order) json(res, 404, { name: 'RESOURCE_NOT_FOUND' })
  265. else json(res, 200, paypalOrderBody(order))
  266. return true
  267. }
  268. json(res, 404, { name: 'RESOURCE_NOT_FOUND', message: `Unknown ${url.pathname}` })
  269. return true
  270. }
  271. /** The page a customer lands on at the vendor, with the one decision a customer makes there. */
  272. async function handleCheckoutPage(
  273. req: IncomingMessage,
  274. res: ServerResponse,
  275. url: URL
  276. ): Promise<boolean> {
  277. const stripe = url.pathname.match(/^\/pay\/stripe\/([^/]+)$/)
  278. if (stripe) {
  279. const session = state.stripe.find((s) => s.id === stripe[1])
  280. if (!session) return json(res, 404, { error: 'no such session' }), true
  281. if (req.method === 'POST') {
  282. session.status = 'complete'
  283. session.payment_status = 'paid'
  284. redirect(res, session.success_url.replace('{CHECKOUT_SESSION_ID}', session.id))
  285. return true
  286. }
  287. html(
  288. res,
  289. `<h1>Stripe checkout</h1><p id="amount">${money(session.amount_total, session.currency)}</p>` +
  290. `<form method="post"><button type="submit">Pay</button></form>` +
  291. `<a href="${session.cancel_url}">Cancel</a>`
  292. )
  293. return true
  294. }
  295. const paypal = url.pathname.match(/^\/pay\/paypal\/([^/]+)$/)
  296. if (paypal) {
  297. const order = state.paypal.find((o) => o.id === paypal[1])
  298. if (!order) return json(res, 404, { error: 'no such order' }), true
  299. if (req.method === 'POST') {
  300. order.status = 'APPROVED'
  301. // PayPal appends its own token and PayerID to whatever return URL it was given.
  302. const joiner = order.return_url.includes('?') ? '&' : '?'
  303. redirect(res, `${order.return_url}${joiner}token=${order.id}&PayerID=E2EPAYER`)
  304. return true
  305. }
  306. html(
  307. res,
  308. `<h1>PayPal checkout</h1><p id="amount">${order.amount.value} ${order.amount.currency_code}</p>` +
  309. `<form method="post"><button type="submit">Pay</button></form>` +
  310. `<a href="${order.cancel_url}">Cancel</a>`
  311. )
  312. return true
  313. }
  314. return false
  315. }
  316. const server = createServer(async (req, res) => {
  317. const url = new URL(req.url ?? '/', SELF)
  318. try {
  319. if (url.pathname === '/health') return json(res, 200, { ok: true })
  320. if (url.pathname === '/state') {
  321. if (req.method === 'DELETE') {
  322. state.stripe.length = 0
  323. state.paypal.length = 0
  324. state.calls.length = 0
  325. return json(res, 200, { cleared: true })
  326. }
  327. return json(res, 200, state)
  328. }
  329. state.calls.push(`${req.method} ${url.pathname}`)
  330. if (await handleCheckoutPage(req, res, url)) return
  331. if (await handlePayPal(req, res, url)) return
  332. if (await handleStripe(req, res, url)) return
  333. json(res, 404, { error: `payment sink has nothing at ${url.pathname}` })
  334. } catch (error) {
  335. json(res, 500, { error: error instanceof Error ? error.message : String(error) })
  336. }
  337. })
  338. server.listen(PORT, '127.0.0.1', () => {
  339. console.log(`[payment-sink] Stripe and PayPal stand-in on ${SELF}`)
  340. })