@adonis-agora/payments 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (346) hide show
  1. package/CHANGELOG.md +2375 -0
  2. package/dist/assets/spa/assets/index-B7rU8eIL.css +2 -0
  3. package/dist/assets/spa/assets/index-BcM2VH5k.js +10 -0
  4. package/dist/assets/spa/assets/index-BcM2VH5k.js.map +1 -0
  5. package/dist/assets/spa/index.html +20 -0
  6. package/dist/commands/main.d.ts +4 -3
  7. package/dist/commands/main.d.ts.map +1 -1
  8. package/dist/commands/main.js +9 -2
  9. package/dist/commands/main.js.map +1 -1
  10. package/dist/commands/make_billable.d.ts.map +1 -1
  11. package/dist/commands/make_billable.js +1 -1
  12. package/dist/commands/make_billable.js.map +1 -1
  13. package/dist/commands/make_webhook_handler.d.ts.map +1 -1
  14. package/dist/commands/make_webhook_handler.js +1 -1
  15. package/dist/commands/make_webhook_handler.js.map +1 -1
  16. package/dist/commands/payments_health.d.ts +31 -0
  17. package/dist/commands/payments_health.d.ts.map +1 -0
  18. package/dist/commands/payments_health.js +113 -0
  19. package/dist/commands/payments_health.js.map +1 -0
  20. package/dist/commands/payments_sync.d.ts +18 -4
  21. package/dist/commands/payments_sync.d.ts.map +1 -1
  22. package/dist/commands/payments_sync.js +136 -40
  23. package/dist/commands/payments_sync.js.map +1 -1
  24. package/dist/commands/payments_webhook.d.ts.map +1 -1
  25. package/dist/commands/payments_webhook.js.map +1 -1
  26. package/dist/configure.d.ts +12 -5
  27. package/dist/configure.d.ts.map +1 -1
  28. package/dist/configure.js +18 -5
  29. package/dist/configure.js.map +1 -1
  30. package/dist/providers/dashboard_provider.d.ts +97 -0
  31. package/dist/providers/dashboard_provider.d.ts.map +1 -0
  32. package/dist/providers/dashboard_provider.js +430 -0
  33. package/dist/providers/dashboard_provider.js.map +1 -0
  34. package/dist/providers/payments_client_provider.d.ts +41 -0
  35. package/dist/providers/payments_client_provider.d.ts.map +1 -0
  36. package/dist/providers/payments_client_provider.js +95 -0
  37. package/dist/providers/payments_client_provider.js.map +1 -0
  38. package/dist/providers/payments_provider.d.ts +35 -0
  39. package/dist/providers/payments_provider.d.ts.map +1 -1
  40. package/dist/providers/payments_provider.js +250 -36
  41. package/dist/providers/payments_provider.js.map +1 -1
  42. package/dist/src/billing/billing_health.d.ts +85 -0
  43. package/dist/src/billing/billing_health.d.ts.map +1 -0
  44. package/dist/src/billing/billing_health.js +155 -0
  45. package/dist/src/billing/billing_health.js.map +1 -0
  46. package/dist/src/billing/billing_overview.d.ts +4 -3
  47. package/dist/src/billing/billing_overview.d.ts.map +1 -1
  48. package/dist/src/billing/billing_overview.js +14 -5
  49. package/dist/src/billing/billing_overview.js.map +1 -1
  50. package/dist/src/billing/billing_store.d.ts +634 -5
  51. package/dist/src/billing/billing_store.d.ts.map +1 -1
  52. package/dist/src/billing/billing_store.js +18 -1
  53. package/dist/src/billing/billing_store.js.map +1 -1
  54. package/dist/src/billing/index.d.ts +4 -3
  55. package/dist/src/billing/index.d.ts.map +1 -1
  56. package/dist/src/billing/index.js +2 -1
  57. package/dist/src/billing/index.js.map +1 -1
  58. package/dist/src/billing/list_query.d.ts +16 -0
  59. package/dist/src/billing/list_query.d.ts.map +1 -0
  60. package/dist/src/billing/list_query.js +28 -0
  61. package/dist/src/billing/list_query.js.map +1 -0
  62. package/dist/src/billing/lucid_billing_store.d.ts +122 -4
  63. package/dist/src/billing/lucid_billing_store.d.ts.map +1 -1
  64. package/dist/src/billing/lucid_billing_store.js +811 -13
  65. package/dist/src/billing/lucid_billing_store.js.map +1 -1
  66. package/dist/src/billing/mixins/index.d.ts +5 -2
  67. package/dist/src/billing/mixins/index.d.ts.map +1 -1
  68. package/dist/src/billing/mixins/index.js +5 -2
  69. package/dist/src/billing/mixins/index.js.map +1 -1
  70. package/dist/src/billing/mixins/with_audit_event.d.ts +48 -0
  71. package/dist/src/billing/mixins/with_audit_event.d.ts.map +1 -0
  72. package/dist/src/billing/mixins/with_audit_event.js +77 -0
  73. package/dist/src/billing/mixins/with_audit_event.js.map +1 -0
  74. package/dist/src/billing/mixins/with_billable.d.ts.map +1 -1
  75. package/dist/src/billing/mixins/with_customer.d.ts +39 -0
  76. package/dist/src/billing/mixins/with_customer.d.ts.map +1 -0
  77. package/dist/src/billing/mixins/with_customer.js +68 -0
  78. package/dist/src/billing/mixins/with_customer.js.map +1 -0
  79. package/dist/src/billing/mixins/with_dispute.d.ts +68 -0
  80. package/dist/src/billing/mixins/with_dispute.d.ts.map +1 -0
  81. package/dist/src/billing/mixins/with_dispute.js +83 -0
  82. package/dist/src/billing/mixins/with_dispute.js.map +1 -0
  83. package/dist/src/billing/mixins/with_payment.d.ts +42 -0
  84. package/dist/src/billing/mixins/with_payment.d.ts.map +1 -1
  85. package/dist/src/billing/mixins/with_payment.js +29 -3
  86. package/dist/src/billing/mixins/with_payment.js.map +1 -1
  87. package/dist/src/billing/mixins/with_subscription.d.ts +7 -0
  88. package/dist/src/billing/mixins/with_subscription.d.ts.map +1 -1
  89. package/dist/src/billing/mixins/with_subscription.js +15 -1
  90. package/dist/src/billing/mixins/with_subscription.js.map +1 -1
  91. package/dist/src/billing/mixins/with_usage_event.d.ts +7 -0
  92. package/dist/src/billing/mixins/with_usage_event.d.ts.map +1 -1
  93. package/dist/src/billing/mixins/with_usage_event.js +15 -1
  94. package/dist/src/billing/mixins/with_usage_event.js.map +1 -1
  95. package/dist/src/billing/mixins/with_webhook_event.d.ts +18 -0
  96. package/dist/src/billing/mixins/with_webhook_event.d.ts.map +1 -1
  97. package/dist/src/billing/mixins/with_webhook_event.js +18 -1
  98. package/dist/src/billing/mixins/with_webhook_event.js.map +1 -1
  99. package/dist/src/billing/resolve_dispatch.d.ts +22 -0
  100. package/dist/src/billing/resolve_dispatch.d.ts.map +1 -0
  101. package/dist/src/billing/resolve_dispatch.js +44 -0
  102. package/dist/src/billing/resolve_dispatch.js.map +1 -0
  103. package/dist/src/billing/resolve_store.d.ts +18 -0
  104. package/dist/src/billing/resolve_store.d.ts.map +1 -0
  105. package/dist/src/billing/resolve_store.js +35 -0
  106. package/dist/src/billing/resolve_store.js.map +1 -0
  107. package/dist/src/billing/schema.d.ts +108 -0
  108. package/dist/src/billing/schema.d.ts.map +1 -0
  109. package/dist/src/billing/schema.js +350 -0
  110. package/dist/src/billing/schema.js.map +1 -0
  111. package/dist/src/billing/webhook_dispatcher.d.ts +95 -16
  112. package/dist/src/billing/webhook_dispatcher.d.ts.map +1 -1
  113. package/dist/src/billing/webhook_dispatcher.js +186 -33
  114. package/dist/src/billing/webhook_dispatcher.js.map +1 -1
  115. package/dist/src/billing/webhook_events.d.ts +67 -1
  116. package/dist/src/billing/webhook_events.d.ts.map +1 -1
  117. package/dist/src/billing/webhook_events.js +15 -0
  118. package/dist/src/billing/webhook_events.js.map +1 -1
  119. package/dist/src/billing/webhook_processor.d.ts.map +1 -1
  120. package/dist/src/billing/webhook_processor.js +325 -5
  121. package/dist/src/billing/webhook_processor.js.map +1 -1
  122. package/dist/src/client/define_config.d.ts +150 -0
  123. package/dist/src/client/define_config.d.ts.map +1 -0
  124. package/dist/src/client/define_config.js +98 -0
  125. package/dist/src/client/define_config.js.map +1 -0
  126. package/dist/src/client/handlers.d.ts +71 -0
  127. package/dist/src/client/handlers.d.ts.map +1 -0
  128. package/dist/src/client/handlers.js +119 -0
  129. package/dist/src/client/handlers.js.map +1 -0
  130. package/dist/src/client/index.d.ts +15 -0
  131. package/dist/src/client/index.d.ts.map +1 -0
  132. package/dist/src/client/index.js +12 -0
  133. package/dist/src/client/index.js.map +1 -0
  134. package/dist/src/client/statuses.d.ts +38 -0
  135. package/dist/src/client/statuses.d.ts.map +1 -0
  136. package/dist/src/client/statuses.js +42 -0
  137. package/dist/src/client/statuses.js.map +1 -0
  138. package/dist/src/dashboard/actions.d.ts +143 -0
  139. package/dist/src/dashboard/actions.d.ts.map +1 -0
  140. package/dist/src/dashboard/actions.js +112 -0
  141. package/dist/src/dashboard/actions.js.map +1 -0
  142. package/dist/src/dashboard/auth.d.ts +136 -0
  143. package/dist/src/dashboard/auth.d.ts.map +1 -0
  144. package/dist/src/dashboard/auth.js +152 -0
  145. package/dist/src/dashboard/auth.js.map +1 -0
  146. package/dist/src/dashboard/define_config.d.ts +80 -0
  147. package/dist/src/dashboard/define_config.d.ts.map +1 -0
  148. package/dist/src/dashboard/define_config.js +83 -0
  149. package/dist/src/dashboard/define_config.js.map +1 -0
  150. package/dist/src/dashboard/handlers.d.ts +281 -0
  151. package/dist/src/dashboard/handlers.d.ts.map +1 -0
  152. package/dist/src/dashboard/handlers.js +960 -0
  153. package/dist/src/dashboard/handlers.js.map +1 -0
  154. package/dist/src/dashboard/index.d.ts +17 -0
  155. package/dist/src/dashboard/index.d.ts.map +1 -0
  156. package/dist/src/dashboard/index.js +13 -0
  157. package/dist/src/dashboard/index.js.map +1 -0
  158. package/dist/src/dashboard/login_page.d.ts +23 -0
  159. package/dist/src/dashboard/login_page.d.ts.map +1 -0
  160. package/dist/src/dashboard/login_page.js +170 -0
  161. package/dist/src/dashboard/login_page.js.map +1 -0
  162. package/dist/src/dashboard/period.d.ts +31 -0
  163. package/dist/src/dashboard/period.d.ts.map +1 -0
  164. package/dist/src/dashboard/period.js +44 -0
  165. package/dist/src/dashboard/period.js.map +1 -0
  166. package/dist/src/dashboard/session_cookie.d.ts +42 -0
  167. package/dist/src/dashboard/session_cookie.d.ts.map +1 -0
  168. package/dist/src/dashboard/session_cookie.js +97 -0
  169. package/dist/src/dashboard/session_cookie.js.map +1 -0
  170. package/dist/src/dashboard/spa.d.ts +24 -0
  171. package/dist/src/dashboard/spa.d.ts.map +1 -0
  172. package/dist/src/dashboard/spa.js +52 -0
  173. package/dist/src/dashboard/spa.js.map +1 -0
  174. package/dist/src/define_config.d.ts +245 -10
  175. package/dist/src/define_config.d.ts.map +1 -1
  176. package/dist/src/define_config.js +97 -1
  177. package/dist/src/define_config.js.map +1 -1
  178. package/dist/src/diagnostics.d.ts +287 -7
  179. package/dist/src/diagnostics.d.ts.map +1 -1
  180. package/dist/src/diagnostics.js +317 -1
  181. package/dist/src/diagnostics.js.map +1 -1
  182. package/dist/src/driver.d.ts +139 -5
  183. package/dist/src/driver.d.ts.map +1 -1
  184. package/dist/src/drivers/abacate.d.ts +13 -3
  185. package/dist/src/drivers/abacate.d.ts.map +1 -1
  186. package/dist/src/drivers/abacate.js +112 -19
  187. package/dist/src/drivers/abacate.js.map +1 -1
  188. package/dist/src/drivers/adyen.d.ts +164 -0
  189. package/dist/src/drivers/adyen.d.ts.map +1 -0
  190. package/dist/src/drivers/adyen.js +678 -0
  191. package/dist/src/drivers/adyen.js.map +1 -0
  192. package/dist/src/drivers/asaas.d.ts +53 -3
  193. package/dist/src/drivers/asaas.d.ts.map +1 -1
  194. package/dist/src/drivers/asaas.js +364 -29
  195. package/dist/src/drivers/asaas.js.map +1 -1
  196. package/dist/src/drivers/dodo.d.ts +135 -0
  197. package/dist/src/drivers/dodo.d.ts.map +1 -0
  198. package/dist/src/drivers/dodo.js +864 -0
  199. package/dist/src/drivers/dodo.js.map +1 -0
  200. package/dist/src/drivers/efi.d.ts +98 -0
  201. package/dist/src/drivers/efi.d.ts.map +1 -0
  202. package/dist/src/drivers/efi.js +628 -0
  203. package/dist/src/drivers/efi.js.map +1 -0
  204. package/dist/src/drivers/infinitepay.d.ts +156 -0
  205. package/dist/src/drivers/infinitepay.d.ts.map +1 -0
  206. package/dist/src/drivers/infinitepay.js +321 -0
  207. package/dist/src/drivers/infinitepay.js.map +1 -0
  208. package/dist/src/drivers/lemonsqueezy.d.ts +125 -0
  209. package/dist/src/drivers/lemonsqueezy.d.ts.map +1 -0
  210. package/dist/src/drivers/lemonsqueezy.js +632 -0
  211. package/dist/src/drivers/lemonsqueezy.js.map +1 -0
  212. package/dist/src/drivers/mercadopago.d.ts +97 -0
  213. package/dist/src/drivers/mercadopago.d.ts.map +1 -0
  214. package/dist/src/drivers/mercadopago.js +844 -0
  215. package/dist/src/drivers/mercadopago.js.map +1 -0
  216. package/dist/src/drivers/mollie.d.ts +125 -0
  217. package/dist/src/drivers/mollie.d.ts.map +1 -0
  218. package/dist/src/drivers/mollie.js +812 -0
  219. package/dist/src/drivers/mollie.js.map +1 -0
  220. package/dist/src/drivers/paddle.d.ts +148 -0
  221. package/dist/src/drivers/paddle.d.ts.map +1 -0
  222. package/dist/src/drivers/paddle.js +738 -0
  223. package/dist/src/drivers/paddle.js.map +1 -0
  224. package/dist/src/drivers/pagarme.d.ts +97 -0
  225. package/dist/src/drivers/pagarme.d.ts.map +1 -0
  226. package/dist/src/drivers/pagarme.js +829 -0
  227. package/dist/src/drivers/pagarme.js.map +1 -0
  228. package/dist/src/drivers/pagbank.d.ts +57 -0
  229. package/dist/src/drivers/pagbank.d.ts.map +1 -0
  230. package/dist/src/drivers/pagbank.js +499 -0
  231. package/dist/src/drivers/pagbank.js.map +1 -0
  232. package/dist/src/drivers/paypal.d.ts +120 -0
  233. package/dist/src/drivers/paypal.d.ts.map +1 -0
  234. package/dist/src/drivers/paypal.js +891 -0
  235. package/dist/src/drivers/paypal.js.map +1 -0
  236. package/dist/src/drivers/polar.d.ts +118 -0
  237. package/dist/src/drivers/polar.d.ts.map +1 -0
  238. package/dist/src/drivers/polar.js +604 -0
  239. package/dist/src/drivers/polar.js.map +1 -0
  240. package/dist/src/drivers/razorpay.d.ts +103 -0
  241. package/dist/src/drivers/razorpay.d.ts.map +1 -0
  242. package/dist/src/drivers/razorpay.js +867 -0
  243. package/dist/src/drivers/razorpay.js.map +1 -0
  244. package/dist/src/drivers/shared.d.ts +33 -0
  245. package/dist/src/drivers/shared.d.ts.map +1 -0
  246. package/dist/src/drivers/shared.js +37 -0
  247. package/dist/src/drivers/shared.js.map +1 -0
  248. package/dist/src/drivers/square.d.ts +133 -0
  249. package/dist/src/drivers/square.d.ts.map +1 -0
  250. package/dist/src/drivers/square.js +905 -0
  251. package/dist/src/drivers/square.js.map +1 -0
  252. package/dist/src/drivers/stripe.d.ts +41 -4
  253. package/dist/src/drivers/stripe.d.ts.map +1 -1
  254. package/dist/src/drivers/stripe.js +823 -26
  255. package/dist/src/drivers/stripe.js.map +1 -1
  256. package/dist/src/drivers/woovi.d.ts +11 -4
  257. package/dist/src/drivers/woovi.d.ts.map +1 -1
  258. package/dist/src/drivers/woovi.js +162 -29
  259. package/dist/src/drivers/woovi.js.map +1 -1
  260. package/dist/src/ensure_customer.d.ts +34 -1
  261. package/dist/src/ensure_customer.d.ts.map +1 -1
  262. package/dist/src/ensure_customer.js +29 -4
  263. package/dist/src/ensure_customer.js.map +1 -1
  264. package/dist/src/hooks/webhook_handlers.d.ts.map +1 -1
  265. package/dist/src/hooks/webhook_handlers.js.map +1 -1
  266. package/dist/src/http.d.ts +37 -3
  267. package/dist/src/http.d.ts.map +1 -1
  268. package/dist/src/http.js +69 -5
  269. package/dist/src/http.js.map +1 -1
  270. package/dist/src/index.d.ts +54 -26
  271. package/dist/src/index.d.ts.map +1 -1
  272. package/dist/src/index.js +29 -12
  273. package/dist/src/index.js.map +1 -1
  274. package/dist/src/invoice/drivers/asaas.d.ts.map +1 -1
  275. package/dist/src/invoice/drivers/asaas.js.map +1 -1
  276. package/dist/src/invoice/drivers/enotas.d.ts.map +1 -1
  277. package/dist/src/invoice/drivers/enotas.js.map +1 -1
  278. package/dist/src/invoice/drivers/focus.d.ts.map +1 -1
  279. package/dist/src/invoice/drivers/focus.js.map +1 -1
  280. package/dist/src/invoice/drivers/plugnotas.d.ts.map +1 -1
  281. package/dist/src/invoice/drivers/plugnotas.js.map +1 -1
  282. package/dist/src/invoice/drivers/tecnospeed.d.ts.map +1 -1
  283. package/dist/src/invoice/drivers/tecnospeed.js.map +1 -1
  284. package/dist/src/invoice/emit_invoice.d.ts.map +1 -1
  285. package/dist/src/invoice/emit_invoice.js +31 -11
  286. package/dist/src/invoice/emit_invoice.js.map +1 -1
  287. package/dist/src/invoice/invoice_manager.d.ts.map +1 -1
  288. package/dist/src/money.d.ts +23 -5
  289. package/dist/src/money.d.ts.map +1 -1
  290. package/dist/src/money.js +72 -7
  291. package/dist/src/money.js.map +1 -1
  292. package/dist/src/payments_manager.d.ts +8 -1
  293. package/dist/src/payments_manager.d.ts.map +1 -1
  294. package/dist/src/payments_manager.js +168 -6
  295. package/dist/src/payments_manager.js.map +1 -1
  296. package/dist/src/services/main.d.ts +38 -0
  297. package/dist/src/services/main.d.ts.map +1 -1
  298. package/dist/src/services/main.js +130 -0
  299. package/dist/src/services/main.js.map +1 -1
  300. package/dist/src/telescope/index.d.ts +2 -0
  301. package/dist/src/telescope/index.d.ts.map +1 -1
  302. package/dist/src/telescope/index.js +1 -0
  303. package/dist/src/telescope/index.js.map +1 -1
  304. package/dist/src/telescope/payments_watcher.d.ts +10 -4
  305. package/dist/src/telescope/payments_watcher.d.ts.map +1 -1
  306. package/dist/src/telescope/payments_watcher.js +66 -14
  307. package/dist/src/telescope/payments_watcher.js.map +1 -1
  308. package/dist/src/telescope/register.d.ts +62 -0
  309. package/dist/src/telescope/register.d.ts.map +1 -0
  310. package/dist/src/telescope/register.js +48 -0
  311. package/dist/src/telescope/register.js.map +1 -0
  312. package/dist/src/testing/fake_payments.d.ts +62 -0
  313. package/dist/src/testing/fake_payments.d.ts.map +1 -0
  314. package/dist/src/testing/fake_payments.js +82 -0
  315. package/dist/src/testing/fake_payments.js.map +1 -0
  316. package/dist/src/testing/fake_payments_driver.d.ts +1 -1
  317. package/dist/src/testing/fake_payments_driver.d.ts.map +1 -1
  318. package/dist/src/testing/fake_payments_driver.js.map +1 -1
  319. package/dist/src/testing/in_memory_billing_store.d.ts +151 -2
  320. package/dist/src/testing/in_memory_billing_store.d.ts.map +1 -1
  321. package/dist/src/testing/in_memory_billing_store.js +517 -4
  322. package/dist/src/testing/in_memory_billing_store.js.map +1 -1
  323. package/dist/src/testing/index.d.ts +3 -2
  324. package/dist/src/testing/index.d.ts.map +1 -1
  325. package/dist/src/testing/index.js +1 -0
  326. package/dist/src/testing/index.js.map +1 -1
  327. package/dist/src/testing/mutable_clock.d.ts.map +1 -1
  328. package/dist/src/types.d.ts +161 -5
  329. package/dist/src/types.d.ts.map +1 -1
  330. package/dist/src/types.js +30 -1
  331. package/dist/src/types.js.map +1 -1
  332. package/dist/src/webhook_handlers.d.ts +96 -2
  333. package/dist/src/webhook_handlers.d.ts.map +1 -1
  334. package/dist/src/webhook_handlers.js +91 -10
  335. package/dist/src/webhook_handlers.js.map +1 -1
  336. package/dist/src/webhook_security.d.ts +94 -3
  337. package/dist/src/webhook_security.d.ts.map +1 -1
  338. package/dist/src/webhook_security.js +173 -9
  339. package/dist/src/webhook_security.js.map +1 -1
  340. package/dist/stubs/config/payments.stub +13 -0
  341. package/dist/stubs/config/payments_client.stub +42 -0
  342. package/dist/stubs/config/payments_dashboard.stub +38 -0
  343. package/dist/stubs/database/migrations/create_billing_tables.stub +35 -77
  344. package/dist/stubs/main.js.map +1 -1
  345. package/dist/stubs/make/webhook_handler/main.stub +29 -11
  346. package/package.json +67 -32
@@ -0,0 +1,891 @@
1
+ import { publishPaymentDiagnostics, publishRefundDiagnostics, publishSubscriptionDiagnostics, } from '../diagnostics.js';
2
+ import { headerValue, httpRequest, isNotFound } from '../http.js';
3
+ import { emitInvoiceIfRequested } from '../invoice/emit_invoice.js';
4
+ import { formatDecimal, fromDecimal } from '../money.js';
5
+ import { requireCredential, requireCurrency } from './shared.js';
6
+ /**
7
+ * The two currencies where PayPal disagrees with ISO 4217. PayPal's currency-codes table
8
+ * marks HUF, JPY and TWD as "does not support decimals"; ISO gives HUF and TWD two minor
9
+ * units, so `formatDecimal` would send `1990.00` and PayPal would reject it. JPY needs no
10
+ * entry here — ISO already says zero.
11
+ */
12
+ const PAYPAL_UNDIVIDED_CURRENCIES = new Set(['huf', 'twd']);
13
+ /** Refresh the OAuth token this many ms before PayPal's own expiry. */
14
+ const TOKEN_SAFETY_MARGIN_MS = 60_000;
15
+ /** What PayPal's idempotency guidance says a `PayPal-Request-Id` fits in — a UUID. */
16
+ const REQUEST_ID_MAX_LENGTH = 38;
17
+ /**
18
+ * PayPal driver — Orders v2 for money in, Payments v2 for refunds, Subscriptions v1 for
19
+ * recurring billing. Multi-currency, OAuth2 client credentials, plain REST (no SDK).
20
+ *
21
+ * Two things about PayPal shape this driver more than anything else:
22
+ *
23
+ * 1. It is a **wallet**. A payment normally needs the payer to approve it on paypal.com,
24
+ * so {@link createCheckout} is the real entry point; {@link charge} only works against
25
+ * a payment method the payer already vaulted, and refuses otherwise.
26
+ * 2. Webhook verification is an **API call**, not a local HMAC — see {@link parseWebhook}.
27
+ */
28
+ export class PayPalDriver {
29
+ provider = 'paypal';
30
+ /**
31
+ * `wallet` and `undefined`, and the split is which call is being routed.
32
+ *
33
+ * {@link PayPalDriver.createCheckout} hands the payer to PayPal, who picks the funding
34
+ * source on their own page — a card, a bank, a local method — so `undefined` ("let the
35
+ * customer choose") is the only promise that call can keep. {@link PayPalDriver.charge}
36
+ * is different: it charges a **vaulted PayPal account** (`payment_source.paypal.vault_id`)
37
+ * and nothing else, which is a stored-balance wallet by definition. `credit_card` is
38
+ * still absent — the driver never sends card data, so routing a card charge here would
39
+ * reach a driver that cannot make one.
40
+ */
41
+ supportedMethods = ['wallet', 'undefined'];
42
+ capabilities = { refunds: true, invoices: false, subscriptions: true };
43
+ #baseUrl;
44
+ #basicAuth;
45
+ #currency;
46
+ #webhookId;
47
+ #invoiceCtx;
48
+ /** Cached client-credentials token. `expiresAt` comes from PayPal's own `expires_in`. */
49
+ #token;
50
+ constructor(ctx, config) {
51
+ this.#invoiceCtx = ctx;
52
+ const clientId = requireCredential({
53
+ driver: 'paypal',
54
+ option: 'clientId',
55
+ env: 'PAYPAL_CLIENT_ID',
56
+ value: config.clientId,
57
+ });
58
+ const clientSecret = requireCredential({
59
+ driver: 'paypal',
60
+ option: 'clientSecret',
61
+ env: 'PAYPAL_CLIENT_SECRET',
62
+ value: config.clientSecret,
63
+ });
64
+ this.#basicAuth = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
65
+ this.#currency = requireCurrency('paypal', config.currency);
66
+ const sandbox = config.sandbox ?? process.env.NODE_ENV !== 'production';
67
+ this.#baseUrl = sandbox ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com';
68
+ this.#webhookId = config.webhookId ?? process.env.PAYPAL_WEBHOOK_ID;
69
+ }
70
+ // ── Customers ────────────────────────────────────────────────────────────────────────
71
+ async createCustomer(_input) {
72
+ throw new Error('[payments] PayPal has no customer resource to create. A customer only comes into ' +
73
+ 'existence as a side effect of vaulting a payment method (`POST /v3/vault/setup-tokens`), ' +
74
+ 'and its id is one you choose. Keep customers on your own records and pass the vault ' +
75
+ 'token id as `paymentMethodId` on the charge.');
76
+ }
77
+ async findCustomer(_customerId) {
78
+ throw new Error('[payments] PayPal cannot look a customer up. The Vault only lists the payment tokens ' +
79
+ 'belonging to a customer id you assigned (`GET /v3/vault/payment-tokens?customer_id=`); ' +
80
+ 'there is no customer to read back.');
81
+ }
82
+ async updateCustomer(_customerId, _input) {
83
+ throw new Error('[payments] PayPal has no customer resource to update. Keep customer details on your ' +
84
+ 'own records.');
85
+ }
86
+ // ── Payments ─────────────────────────────────────────────────────────────────────────
87
+ /**
88
+ * Charge a vaulted payment method server-to-server. PayPal calls this the "single shot"
89
+ * flow: an order with `payment_source.paypal.vault_id` is created and captured without
90
+ * the payer being present. Everything else needs approval on paypal.com, which no
91
+ * server-side call can stand in for.
92
+ */
93
+ async charge(input) {
94
+ const vaultId = input.paymentMethodId ??
95
+ input.card?.token ??
96
+ (typeof input.metadata?.vaultId === 'string' ? input.metadata.vaultId : undefined);
97
+ if (vaultId === undefined) {
98
+ throw new Error('[payments] PayPal cannot charge without the payer approving the payment. Use ' +
99
+ '`createCheckout()` and redirect them, or pass an already-vaulted payment method ' +
100
+ 'token as `paymentMethodId` for a server-side charge.');
101
+ }
102
+ if (input.method !== undefined && input.method !== 'wallet' && input.method !== 'undefined') {
103
+ throw new Error(`[payments] PayPal charges the vaulted PayPal account behind the token and nothing else, so "${input.method}" is a promise this call cannot keep — the funding source is whatever the payer has attached to that account. Drop \`method\`, or route the charge as \`wallet\`.`);
104
+ }
105
+ // PayPal documents `PayPal-Request-Id` as mandatory for single-step create-order calls.
106
+ // Generating one here would defeat it: a retry would mint a new key and charge twice.
107
+ if (input.idempotencyKey === undefined) {
108
+ throw new Error('[payments] PayPal requires an idempotency key on a vaulted server-side charge — ' +
109
+ 'pass `idempotencyKey`. It becomes the `PayPal-Request-Id` header, which is the ' +
110
+ 'only thing PayPal deduplicates a repeated charge on.');
111
+ }
112
+ const currency = input.currency ?? this.#currency;
113
+ const body = {
114
+ intent: 'CAPTURE',
115
+ purchase_units: [
116
+ {
117
+ amount: {
118
+ currency_code: currency.toUpperCase(),
119
+ value: this.#toValue(input.amount, currency),
120
+ },
121
+ ...(input.externalReference !== undefined ? { custom_id: input.externalReference } : {}),
122
+ ...(input.description !== undefined ? { description: input.description } : {}),
123
+ },
124
+ ],
125
+ payment_source: { paypal: { vault_id: vaultId } },
126
+ };
127
+ let order = await this.#request('/v2/checkout/orders', {
128
+ method: 'POST',
129
+ body,
130
+ headers: { ...this.#requestId(input.idempotencyKey), Prefer: 'return=representation' },
131
+ });
132
+ // A vaulted CAPTURE order usually comes back COMPLETED; when PayPal only authorized it,
133
+ // capture explicitly rather than reporting a charge that never took the money.
134
+ if (order.status !== 'COMPLETED') {
135
+ order = await this.#request(`/v2/checkout/orders/${order.id}/capture`, {
136
+ method: 'POST',
137
+ headers: { ...this.#requestId(input.idempotencyKey), Prefer: 'return=representation' },
138
+ });
139
+ }
140
+ const capture = order.purchase_units?.[0]?.payments?.captures?.[0];
141
+ if (capture === undefined) {
142
+ throw new Error(`[payments] PayPal order ${order.id} came back "${order.status}" with no capture. ` +
143
+ `The money was not taken — inspect order ${order.id} before retrying.`);
144
+ }
145
+ const payment = this.#mapCapture(capture, currency);
146
+ // The order was created with `payment_source.paypal`, so the money came out of a PayPal
147
+ // account. `order.payment_source` says which one when PayPal echoes it back.
148
+ payment.method = this.#mapPaymentSource(order.payment_source) ?? 'wallet';
149
+ await emitInvoiceIfRequested(this.#invoiceCtx, input, payment, this);
150
+ publishPaymentDiagnostics(payment);
151
+ return payment;
152
+ }
153
+ /** Find a payment by its **capture** id — the id `charge()` and the webhooks return. */
154
+ async findPayment(gatewayId) {
155
+ try {
156
+ const capture = await this.#request(`/v2/payments/captures/${gatewayId}`);
157
+ return this.#mapCapture(capture, this.#currency);
158
+ }
159
+ catch (error) {
160
+ if (isNotFound(error))
161
+ return null;
162
+ throw error;
163
+ }
164
+ }
165
+ async refund(paymentGatewayId, amount, options) {
166
+ const currency = this.#currency;
167
+ // An empty body means "refund everything left"; an `amount` makes it partial.
168
+ const body = amount !== undefined
169
+ ? {
170
+ amount: {
171
+ currency_code: currency.toUpperCase(),
172
+ value: this.#toValue(amount, currency),
173
+ },
174
+ }
175
+ : {};
176
+ const data = await this.#request(`/v2/payments/captures/${paymentGatewayId}/refund`, {
177
+ method: 'POST',
178
+ body,
179
+ headers: {
180
+ Prefer: 'return=representation',
181
+ // The refund endpoint documents `PayPal-Request-Id`, the same header the charge
182
+ // uses. Without it a retried refund job refunds the capture twice.
183
+ ...this.#requestId(options?.idempotencyKey),
184
+ },
185
+ });
186
+ const refund = {
187
+ id: data.id,
188
+ gatewayId: data.id,
189
+ provider: this.provider,
190
+ amount: this.#mapMoney(data.amount, currency),
191
+ status: data.status === 'COMPLETED'
192
+ ? 'succeeded'
193
+ : data.status === 'FAILED' || data.status === 'CANCELLED'
194
+ ? 'failed'
195
+ : 'pending',
196
+ createdAt: data.create_time ?? new Date().toISOString(),
197
+ };
198
+ publishRefundDiagnostics(refund);
199
+ return refund;
200
+ }
201
+ // ── Checkout ─────────────────────────────────────────────────────────────────────────
202
+ /**
203
+ * The real entry point for PayPal. Returns the URL to send the payer to; the money only
204
+ * moves once they approve it there and you capture the order (or, for `planId`, once
205
+ * they approve the subscription).
206
+ */
207
+ async createCheckout(input) {
208
+ const currency = input.currency ?? this.#currency;
209
+ // `custom_id` and not `reference_id`: the capture PayPal sends on the webhook carries
210
+ // `custom_id`, while `reference_id` stays behind on the purchase unit.
211
+ const externalReference = input.externalReference;
212
+ if (input.planId !== undefined) {
213
+ const subscription = await this.#createSubscription({
214
+ planId: input.planId,
215
+ returnUrl: input.successUrl,
216
+ ...(input.cancelUrl !== undefined ? { cancelUrl: input.cancelUrl } : {}),
217
+ ...(externalReference !== undefined ? { customId: externalReference } : {}),
218
+ ...(input.idempotencyKey !== undefined ? { requestId: input.idempotencyKey } : {}),
219
+ });
220
+ return {
221
+ id: subscription.id,
222
+ gatewayId: subscription.id,
223
+ provider: this.provider,
224
+ url: this.#approvalUrl(subscription.links),
225
+ status: subscription.status === 'ACTIVE' ? 'complete' : 'open',
226
+ subscriptionId: subscription.id,
227
+ ...(input.customerId !== undefined ? { customerId: input.customerId } : {}),
228
+ };
229
+ }
230
+ const body = {
231
+ intent: 'CAPTURE',
232
+ purchase_units: [
233
+ {
234
+ amount: {
235
+ currency_code: currency.toUpperCase(),
236
+ value: this.#toValue(input.amount, currency),
237
+ },
238
+ ...(externalReference !== undefined ? { custom_id: externalReference } : {}),
239
+ ...(input.description !== undefined ? { description: input.description } : {}),
240
+ },
241
+ ],
242
+ payment_source: {
243
+ paypal: {
244
+ experience_context: {
245
+ return_url: input.successUrl,
246
+ ...(input.cancelUrl !== undefined ? { cancel_url: input.cancelUrl } : {}),
247
+ user_action: 'PAY_NOW',
248
+ },
249
+ },
250
+ },
251
+ };
252
+ const order = await this.#request('/v2/checkout/orders', {
253
+ method: 'POST',
254
+ body,
255
+ headers: {
256
+ Prefer: 'return=representation',
257
+ ...this.#requestId(input.idempotencyKey),
258
+ },
259
+ });
260
+ return {
261
+ id: order.id,
262
+ gatewayId: order.id,
263
+ provider: this.provider,
264
+ url: this.#approvalUrl(order.links),
265
+ status: order.status === 'COMPLETED' ? 'complete' : 'open',
266
+ amount: { amount: input.amount, currency },
267
+ ...(input.customerId !== undefined ? { customerId: input.customerId } : {}),
268
+ };
269
+ }
270
+ // ── Subscriptions ────────────────────────────────────────────────────────────────────
271
+ /**
272
+ * Create a subscription against an existing PayPal **plan** (`P-…`). It comes back
273
+ * `APPROVAL_PENDING`: the payer still has to approve it, and the link to send them to
274
+ * is in `subscription.payload.links` (rel `approve`). `createCheckout({ planId })`
275
+ * returns that URL directly and is usually what you want.
276
+ */
277
+ async createSubscription(input) {
278
+ // Amount, cycle and trial all live on the PayPal plan, not on the subscription.
279
+ // Accepting them here would report a price the gateway never charges.
280
+ const planLevel = [
281
+ input.amount !== undefined ? 'amount' : undefined,
282
+ input.cycle !== undefined ? 'cycle' : undefined,
283
+ input.trialDays !== undefined ? 'trialDays' : undefined,
284
+ ].filter((field) => field !== undefined);
285
+ if (planLevel.length > 0) {
286
+ throw new Error(`[payments] PayPal prices subscriptions on the plan, so ${planLevel.join(', ')} cannot ` +
287
+ `be applied here. Create a plan with that price, cycle and trial (\`POST /v1/billing/plans\`) and pass its \`P-…\` id as \`planId\` (yours was ${input.planId}).`);
288
+ }
289
+ const data = await this.#createSubscription({
290
+ planId: input.planId,
291
+ ...(input.externalReference !== undefined ? { customId: input.externalReference } : {}),
292
+ ...(input.startDate !== undefined ? { startTime: input.startDate } : {}),
293
+ // `POST /v1/billing/subscriptions` takes `PayPal-Request-Id` and holds the key for 72
294
+ // hours; without it a retry starts a second subscription billing the same person.
295
+ ...(input.idempotencyKey !== undefined ? { requestId: input.idempotencyKey } : {}),
296
+ });
297
+ const subscription = this.#mapSubscription(data);
298
+ publishSubscriptionDiagnostics(subscription, 'subscription.created');
299
+ return subscription;
300
+ }
301
+ async cancelSubscription(subscriptionGatewayId, options) {
302
+ if (options?.atPeriodEnd === true) {
303
+ throw new Error('[payments] PayPal cancels a subscription immediately — there is no cancel-at-period-end ' +
304
+ 'flag. Cancel it when the period ends, or suspend it (`POST /v1/billing/subscriptions/' +
305
+ '{id}/suspend`) and keep the grace period on your own records.');
306
+ }
307
+ await this.#requestNoContent(`/v1/billing/subscriptions/${subscriptionGatewayId}/cancel`, {
308
+ reason: 'Canceled by the merchant.',
309
+ });
310
+ // The cancel call answers 204 with no body; read the subscription back rather than
311
+ // inventing the post-cancel state.
312
+ const data = await this.#request(`/v1/billing/subscriptions/${subscriptionGatewayId}`);
313
+ const subscription = this.#mapSubscription(data);
314
+ publishSubscriptionDiagnostics(subscription, 'subscription.canceled');
315
+ return subscription;
316
+ }
317
+ /**
318
+ * Change the recurring price. PayPal applies it as a JSON Patch against the plan's
319
+ * REGULAR billing cycle — the sequence is read off the subscription first, because
320
+ * patching sequence 1 blindly rewrites the *trial* price on a plan that has one.
321
+ */
322
+ async updateSubscription(subscriptionGatewayId, input) {
323
+ if (input.idempotencyKey !== undefined) {
324
+ throw new Error('[payments] PayPal does not deduplicate a subscription update: `PATCH /v1/billing/subscriptions/{id}` documents no `PayPal-Request-Id`, unlike the create call. The patch is a price replacement, so a repeat is harmless in itself — but accepting a key the API never sees would promise a guarantee nothing enforces, so it is refused rather than dropped.');
325
+ }
326
+ if (input.description !== undefined) {
327
+ throw new Error('[payments] PayPal subscriptions have no description — it belongs to the catalog ' +
328
+ 'product behind the plan. Update the product, or keep the description on your ' +
329
+ 'own record.');
330
+ }
331
+ if (input.amount === undefined) {
332
+ throw new Error('[payments] PayPal subscription update needs an `amount`; there is nothing else on ' +
333
+ 'the shared input it can change.');
334
+ }
335
+ const current = await this.#request(`/v1/billing/subscriptions/${subscriptionGatewayId}?fields=plan`);
336
+ const regular = current.plan?.billing_cycles?.find((cycle) => cycle.tenure_type === 'REGULAR' && cycle.sequence !== undefined);
337
+ if (regular === undefined) {
338
+ throw new Error(`[payments] PayPal subscription ${subscriptionGatewayId} exposes no REGULAR billing ` +
339
+ `cycle to reprice. Change the price on the plan behind ${subscriptionGatewayId}, or move the subscriber to a new plan (\`POST /v1/billing/subscriptions/{id}/revise\`).`);
340
+ }
341
+ const currency = regular.pricing_scheme?.fixed_price?.currency_code?.toLowerCase() ?? this.#currency;
342
+ const patch = [
343
+ {
344
+ op: 'replace',
345
+ path: `/plan/billing_cycles/@sequence==${regular.sequence}/pricing_scheme/fixed_price`,
346
+ value: {
347
+ currency_code: currency.toUpperCase(),
348
+ value: this.#toValue(input.amount, currency),
349
+ },
350
+ },
351
+ ];
352
+ await this.#requestNoContent(`/v1/billing/subscriptions/${subscriptionGatewayId}`, patch, {
353
+ method: 'PATCH',
354
+ });
355
+ const data = await this.#request(`/v1/billing/subscriptions/${subscriptionGatewayId}?fields=plan`);
356
+ return this.#mapSubscription(data);
357
+ }
358
+ async findSubscription(gatewayId) {
359
+ try {
360
+ const data = await this.#request(`/v1/billing/subscriptions/${gatewayId}?fields=plan`);
361
+ return this.#mapSubscription(data);
362
+ }
363
+ catch (error) {
364
+ if (isNotFound(error))
365
+ return null;
366
+ throw error;
367
+ }
368
+ }
369
+ // ── Invoices ─────────────────────────────────────────────────────────────────────────
370
+ async listInvoices(_customerId) {
371
+ throw new Error('[payments] PayPal cannot list invoices for a customer: `GET /v2/invoicing/invoices` ' +
372
+ 'takes only paging, and `POST /v2/invoicing/search-invoices` filters by recipient ' +
373
+ 'email, not by a customer id. Search by email yourself, or use an invoice provider ' +
374
+ 'with `invoice: true`.');
375
+ }
376
+ // ── Webhooks ─────────────────────────────────────────────────────────────────────────
377
+ /**
378
+ * Verify the webhook with PayPal and normalize it.
379
+ *
380
+ * PayPal has no local signature to check: verification is a POST to
381
+ * `/v1/notifications/verify-webhook-signature`, so **every webhook costs a round trip**
382
+ * (plus the OAuth call when the cached token has expired). That is PayPal's scheme —
383
+ * there is no offline HMAC to substitute for it.
384
+ *
385
+ * Without a `webhookId` there is nothing to verify against at all, so the event is
386
+ * parsed unverified — the same "unconfigured means unenforced" rule the other drivers
387
+ * follow, and what makes local development work without a dashboard webhook.
388
+ */
389
+ /**
390
+ * Whether a delivery to `POST /payments/webhook/:provider` can be authenticated.
391
+ *
392
+ * Without the webhook id `parseWebhook` normalizes the body without ever calling PayPal's
393
+ * verify-signature endpoint.
394
+ */
395
+ get webhookVerification() {
396
+ return this.#webhookId !== undefined ? 'configured' : 'unconfigured';
397
+ }
398
+ async parseWebhook(rawBody, headers) {
399
+ if (this.#webhookId === undefined)
400
+ return this.#normalize(rawBody);
401
+ const body = {
402
+ auth_algo: headerValue(headers, 'paypal-auth-algo') ?? '',
403
+ cert_url: headerValue(headers, 'paypal-cert-url') ?? '',
404
+ transmission_id: headerValue(headers, 'paypal-transmission-id') ?? '',
405
+ transmission_sig: headerValue(headers, 'paypal-transmission-sig') ?? '',
406
+ transmission_time: headerValue(headers, 'paypal-transmission-time') ?? '',
407
+ webhook_id: this.#webhookId,
408
+ // PayPal takes the event as JSON, not as the raw string; it re-serializes it its own
409
+ // way, so a body that does not parse can never verify.
410
+ webhook_event: JSON.parse(rawBody),
411
+ };
412
+ const result = await this.#request('/v1/notifications/verify-webhook-signature', { method: 'POST', body });
413
+ if (result.verification_status !== 'SUCCESS') {
414
+ throw new Error('[payments] Invalid PayPal webhook signature.');
415
+ }
416
+ return this.#normalize(rawBody);
417
+ }
418
+ // ── Mappers ──────────────────────────────────────────────────────────────────────────
419
+ #normalize(rawBody) {
420
+ const payload = JSON.parse(rawBody);
421
+ const eventType = payload.event_type ?? 'unknown';
422
+ const resource = payload.resource ?? {};
423
+ // The dispute events decide their type from the resource (the lifecycle stage), and
424
+ // the payload then has to know which type it is producing to carry the outcome.
425
+ const type = this.#mapWebhookType(eventType, resource);
426
+ return {
427
+ id: payload.id ?? `${eventType}-${Date.now()}`,
428
+ provider: this.provider,
429
+ type,
430
+ createdAt: payload.create_time ?? new Date().toISOString(),
431
+ data: this.#mapWebhookData(eventType, resource, type),
432
+ raw: payload,
433
+ };
434
+ }
435
+ /**
436
+ * PayPal's `payment_source` → the canonical {@link PaymentMethodType}.
437
+ *
438
+ * Exactly one key of the object is populated, and it names the funding source: `paypal`
439
+ * and `venmo` are stored-balance wallets, `apple_pay`/`google_pay` device ones, `card`
440
+ * is a card, and the European local methods push from the payer's own bank. Anything
441
+ * unrecognized is left unset rather than guessed at.
442
+ */
443
+ #mapPaymentSource(source) {
444
+ if (source === undefined)
445
+ return undefined;
446
+ const key = Object.keys(source)[0];
447
+ switch (key) {
448
+ case 'paypal':
449
+ case 'venmo':
450
+ case 'apple_pay':
451
+ case 'google_pay':
452
+ return 'wallet';
453
+ case 'card':
454
+ return 'card';
455
+ case 'ideal':
456
+ case 'bancontact':
457
+ case 'eps':
458
+ case 'giropay':
459
+ case 'p24':
460
+ case 'sofort':
461
+ case 'blik':
462
+ case 'trustly':
463
+ case 'multibanco':
464
+ case 'mybank':
465
+ return 'bank_transfer';
466
+ default:
467
+ return undefined;
468
+ }
469
+ }
470
+ #mapWebhookType(eventType, resource) {
471
+ switch (eventType) {
472
+ case 'PAYMENT.CAPTURE.COMPLETED':
473
+ case 'PAYMENT.SALE.COMPLETED':
474
+ return 'payment.succeeded';
475
+ case 'PAYMENT.CAPTURE.DECLINED':
476
+ case 'PAYMENT.SALE.DENIED':
477
+ return 'payment.failed';
478
+ // ── The dispute family ───────────────────────────────────────────────────────────
479
+ // PayPal's dispute is NOT a card-scheme chargeback: it has a lifecycle of its own,
480
+ // and `CUSTOMER.DISPUTE.CREATED` fires at two very different points in it. PayPal's
481
+ // own sandbox guide has you assert `dispute_life_cycle_stage` is `INQUIRY` for one
482
+ // test and `CHARGEBACK` for the next, on the same event.
483
+ //
484
+ // `INQUIRY` is PayPal's own words "a customer and merchant interact in an attempt to
485
+ // resolve a dispute without escalation to PayPal" — a 20-day window in the Resolution
486
+ // Center that a refund closes. Nothing is adjudicated and nothing is debited; calling
487
+ // it `payment.disputed` moves a paid row over money still in the account, which is
488
+ // the same bug Stripe's inquiry and Adyen's notification-of-chargeback had.
489
+ // `CHARGEBACK` and the two appeal stages are the claim: PayPal is now investigating
490
+ // and will debit the account if it decides for the buyer.
491
+ //
492
+ // With no stage on the resource this stays `payment.disputed` — the mapping it has
493
+ // always had — rather than downgrading a dispute the driver could not read.
494
+ case 'CUSTOMER.DISPUTE.CREATED':
495
+ // The deprecated spelling PayPal's own reference says `CUSTOMER.DISPUTE.CREATED`
496
+ // supersedes; an account still subscribed to it should not silently miss the dispute.
497
+ case 'RISK.DISPUTE.CREATED':
498
+ return this.#disputeStage(resource) === 'INQUIRY'
499
+ ? 'payment.dispute_warning'
500
+ : 'payment.disputed';
501
+ // PayPal sends **no dedicated "escalated to a claim" event**: an inquiry becoming a
502
+ // chargeback arrives as a plain `CUSTOMER.DISPUTE.UPDATED` carrying the new stage. It
503
+ // is therefore the only place a row that opened as a warning can learn the money is
504
+ // now at stake, so an UPDATED past the inquiry stage is the chargeback. An UPDATED
505
+ // still in `INQUIRY` (a message, evidence, an offer) moves nothing, and one on an
506
+ // already `RESOLVED` dispute is bookkeeping after the fact.
507
+ case 'CUSTOMER.DISPUTE.UPDATED': {
508
+ const dispute = resource;
509
+ const stage = this.#disputeStage(resource);
510
+ return stage === undefined || stage === 'INQUIRY' || dispute.status === 'RESOLVED'
511
+ ? 'payment.updated'
512
+ : 'payment.disputed';
513
+ }
514
+ // The outcome lives in `dispute_outcome.outcome_code`, and only three of the seven
515
+ // codes say who kept the money — see {@link PayPalDriver.#disputeOutcome}. Without a
516
+ // readable one this stays an update rather than inventing a result.
517
+ case 'CUSTOMER.DISPUTE.RESOLVED':
518
+ return this.#disputeOutcome(resource) === undefined
519
+ ? 'payment.updated'
520
+ : 'payment.dispute_closed';
521
+ case 'PAYMENT.CAPTURE.REFUNDED':
522
+ case 'PAYMENT.CAPTURE.REVERSED':
523
+ case 'PAYMENT.SALE.REFUNDED':
524
+ case 'PAYMENT.SALE.REVERSED':
525
+ return 'payment.refunded';
526
+ case 'CHECKOUT.ORDER.APPROVED':
527
+ case 'CHECKOUT.ORDER.COMPLETED':
528
+ case 'PAYMENT.CAPTURE.PENDING':
529
+ case 'PAYMENT.SALE.PENDING':
530
+ // An authorization is money HELD, not money moved: PayPal reserves it for about 29
531
+ // days and voids it if nobody captures. There is deliberately no `payment.authorized`
532
+ // event for it to become, so it is an update — the `status: 'authorized'` on the
533
+ // payload is what a handler reads to tell it apart from the others here.
534
+ case 'PAYMENT.AUTHORIZATION.CREATED':
535
+ case 'PAYMENT.AUTHORIZATION.VOIDED':
536
+ return 'payment.updated';
537
+ case 'BILLING.SUBSCRIPTION.CREATED':
538
+ return 'subscription.created';
539
+ case 'BILLING.SUBSCRIPTION.ACTIVATED':
540
+ case 'BILLING.SUBSCRIPTION.UPDATED':
541
+ case 'BILLING.SUBSCRIPTION.SUSPENDED':
542
+ case 'BILLING.SUBSCRIPTION.PAYMENT.FAILED':
543
+ return 'subscription.updated';
544
+ case 'BILLING.SUBSCRIPTION.CANCELLED':
545
+ case 'BILLING.SUBSCRIPTION.EXPIRED':
546
+ return 'subscription.canceled';
547
+ default:
548
+ return eventType.toLowerCase();
549
+ }
550
+ }
551
+ /**
552
+ * The dispute's lifecycle stage — `INQUIRY`, `CHARGEBACK`, `PRE_ARBITRATION` or
553
+ * `ARBITRATION` — or `undefined` when the resource is not a dispute or does not carry
554
+ * one.
555
+ *
556
+ * A caveat worth knowing: PayPal documents the stages and the fund holds separately, and
557
+ * never in the same sentence. The dispute guide says PayPal "holds the disputed payment
558
+ * until resolution" for internal disputes, while its own sandbox matrix enumerates both
559
+ * "Dispute NO HOLD" and "Dispute WITH HOLD" scenarios, and only ever says "PayPal debits
560
+ * the merchant's account" on a case resolved in the buyer's favour. So the reference
561
+ * does **not** state that a `CHARGEBACK`-stage dispute has already taken the money — it
562
+ * states that the claim is being adjudicated and that losing it debits the account. The
563
+ * split here follows the stage PayPal itself uses to separate "you and the buyer are
564
+ * talking" from "PayPal is deciding", which is the line an operator can act on.
565
+ */
566
+ #disputeStage(resource) {
567
+ const stage = resource.dispute_life_cycle_stage;
568
+ return typeof stage === 'string' ? stage : undefined;
569
+ }
570
+ /**
571
+ * `dispute_outcome.outcome_code` → the canonical outcome, or `undefined`.
572
+ *
573
+ * PayPal's enum has seven values and only three of them name who ends up with the money:
574
+ * `RESOLVED_SELLER_FAVOUR`, `RESOLVED_BUYER_FAVOUR` and `CANCELED_BY_BUYER` ("the
575
+ * customer canceled the dispute"). The rest deliberately return `undefined`:
576
+ *
577
+ * - `RESOLVED_WITH_PAYOUT` is "PayPal provided the merchant **or customer** with
578
+ * protection and the case is resolved" — it does not say which, and guessing decides
579
+ * whether the row goes back to `paid`.
580
+ * - `ACCEPTED` and `DENIED` are marked DEPRECATED in PayPal's current schema and their
581
+ * descriptions ("PayPal accepted the dispute" / "PayPal denied the dispute") name the
582
+ * dispute rather than the party, unlike every other code in the same enum.
583
+ * - `NONE` is "a dispute was created for the same transaction ID, and the previous
584
+ * dispute was closed **without any decision**", which is the definition of no outcome.
585
+ *
586
+ * Each of those stays `payment.updated` carrying the full resource on `event.raw`.
587
+ */
588
+ #disputeOutcome(resource) {
589
+ switch (resource.dispute_outcome?.outcome_code) {
590
+ case 'RESOLVED_SELLER_FAVOUR':
591
+ return 'won';
592
+ case 'RESOLVED_BUYER_FAVOUR':
593
+ return 'lost';
594
+ case 'CANCELED_BY_BUYER':
595
+ return 'canceled';
596
+ default:
597
+ return undefined;
598
+ }
599
+ }
600
+ #mapWebhookData(eventType, resource, type) {
601
+ if (eventType.startsWith('BILLING.SUBSCRIPTION.')) {
602
+ const subscription = resource;
603
+ return {
604
+ gatewayId: subscription.id,
605
+ customerId: subscription.subscriber?.payer_id ?? subscription.subscriber?.email_address ?? '',
606
+ status: this.#mapSubscriptionStatus(subscription.status),
607
+ ...(subscription.plan_id !== undefined ? { planId: subscription.plan_id } : {}),
608
+ ...(subscription.custom_id !== undefined
609
+ ? { externalReference: subscription.custom_id }
610
+ : {}),
611
+ };
612
+ }
613
+ if (eventType.startsWith('PAYMENT.SALE.')) {
614
+ // Sales carry money as `amount.total` + `amount.currency`, and the app's own
615
+ // reference as `custom` — neither spelled the way captures spell them.
616
+ const sale = resource;
617
+ const currency = (sale.amount?.currency ?? this.#currency).toLowerCase();
618
+ return {
619
+ gatewayId: sale.id,
620
+ amount: this.#fromValue(sale.amount?.total, currency),
621
+ currency,
622
+ ...(sale.billing_agreement_id !== undefined
623
+ ? { subscriptionId: sale.billing_agreement_id }
624
+ : {}),
625
+ ...(sale.custom !== undefined ? { externalReference: sale.custom } : {}),
626
+ };
627
+ }
628
+ if (eventType.endsWith('DISPUTE.CREATED') || eventType.startsWith('CUSTOMER.DISPUTE.')) {
629
+ // The dispute names the money as `dispute_amount` and the payment as the SELLER's
630
+ // side of the disputed transaction — `seller_transaction_id` is the capture id this
631
+ // driver keys payments on, and `buyer_transaction_id` is the buyer's view of the same
632
+ // money, which would find no row here. A dispute can cover several transactions; the
633
+ // first is the one the row is written against and `event.raw` carries the rest.
634
+ const dispute = resource;
635
+ const currency = (dispute.dispute_amount?.currency_code ?? this.#currency).toLowerCase();
636
+ const transaction = dispute.disputed_transactions?.[0];
637
+ const outcome = type === 'payment.dispute_closed' ? this.#disputeOutcome(resource) : undefined;
638
+ return {
639
+ gatewayId: transaction?.seller_transaction_id ?? '',
640
+ amount: this.#fromValue(dispute.dispute_amount?.value, currency),
641
+ currency,
642
+ ...(dispute.dispute_id !== undefined ? { disputeId: dispute.dispute_id } : {}),
643
+ ...(dispute.reason !== undefined ? { reason: dispute.reason } : {}),
644
+ // The whole value of an inquiry: PayPal closes an unanswered dispute in the
645
+ // customer's favour when this passes, so it is the date the alert is worth acting
646
+ // on. Already RFC 3339, which is the ISO 8601 `actionableUntil` is declared as.
647
+ ...(dispute.seller_response_due_date !== undefined
648
+ ? { actionableUntil: dispute.seller_response_due_date }
649
+ : {}),
650
+ ...(dispute.dispute_life_cycle_stage !== undefined
651
+ ? { disputeStage: dispute.dispute_life_cycle_stage }
652
+ : {}),
653
+ ...(outcome !== undefined ? { outcome } : {}),
654
+ ...(transaction?.custom !== undefined ? { externalReference: transaction.custom } : {}),
655
+ };
656
+ }
657
+ if (eventType.startsWith('PAYMENT.AUTHORIZATION.')) {
658
+ // An Authorization is shaped like a Capture — `id`, `status`, `amount`, `custom_id` —
659
+ // so the same reader works; what differs is that the money is only held.
660
+ const authorization = resource;
661
+ const currency = (authorization.amount?.currency_code ?? this.#currency).toLowerCase();
662
+ return {
663
+ gatewayId: authorization.id,
664
+ amount: this.#fromValue(authorization.amount?.value, currency),
665
+ currency,
666
+ status: 'authorized',
667
+ ...(authorization.custom_id !== undefined
668
+ ? { externalReference: authorization.custom_id }
669
+ : {}),
670
+ };
671
+ }
672
+ if (eventType.startsWith('CHECKOUT.ORDER.')) {
673
+ const order = resource;
674
+ const unit = order.purchase_units?.[0];
675
+ const currency = (unit?.amount?.currency_code ?? this.#currency).toLowerCase();
676
+ return {
677
+ gatewayId: order.id,
678
+ amount: this.#fromValue(unit?.amount?.value, currency),
679
+ currency,
680
+ ...(unit?.custom_id !== undefined ? { externalReference: unit.custom_id } : {}),
681
+ };
682
+ }
683
+ const capture = resource;
684
+ const currency = (capture.amount?.currency_code ?? this.#currency).toLowerCase();
685
+ return {
686
+ gatewayId: capture.id,
687
+ amount: this.#fromValue(capture.amount?.value, currency),
688
+ currency,
689
+ ...(capture.custom_id !== undefined ? { externalReference: capture.custom_id } : {}),
690
+ };
691
+ }
692
+ #mapCapture(data, fallbackCurrency) {
693
+ const statusMap = {
694
+ COMPLETED: 'paid',
695
+ // The canonical set has no partial-refund state and the money did settle, so this
696
+ // stays `paid`; the refund is a Refund of its own.
697
+ PARTIALLY_REFUNDED: 'paid',
698
+ PENDING: 'pending',
699
+ DECLINED: 'failed',
700
+ FAILED: 'failed',
701
+ REFUNDED: 'refunded',
702
+ };
703
+ const result = {
704
+ id: data.id,
705
+ gatewayId: data.id,
706
+ provider: this.provider,
707
+ amount: this.#mapMoney(data.amount, fallbackCurrency),
708
+ status: statusMap[data.status ?? ''] ?? 'pending',
709
+ payload: data,
710
+ createdAt: data.create_time ?? new Date().toISOString(),
711
+ };
712
+ if (data.status === 'COMPLETED') {
713
+ result.paidAt = data.update_time ?? data.create_time ?? new Date().toISOString();
714
+ }
715
+ return result;
716
+ }
717
+ #mapSubscription(data) {
718
+ const regular = data.plan?.billing_cycles?.find((cycle) => cycle.tenure_type === 'REGULAR');
719
+ const price = regular?.pricing_scheme?.fixed_price;
720
+ return {
721
+ id: data.id,
722
+ gatewayId: data.id,
723
+ provider: this.provider,
724
+ customerId: data.subscriber?.payer_id ?? data.subscriber?.email_address ?? '',
725
+ status: this.#mapSubscriptionStatus(data.status),
726
+ planId: data.plan_id ?? '',
727
+ ...(price !== undefined
728
+ ? { amount: this.#mapMoney(price, price.currency_code.toLowerCase()) }
729
+ : {}),
730
+ ...(data.start_time !== undefined ? { currentPeriodStart: data.start_time } : {}),
731
+ ...(data.billing_info?.next_billing_time !== undefined
732
+ ? { currentPeriodEnd: data.billing_info.next_billing_time }
733
+ : {}),
734
+ payload: data,
735
+ createdAt: data.create_time ?? data.start_time ?? new Date().toISOString(),
736
+ };
737
+ }
738
+ #mapSubscriptionStatus(status) {
739
+ switch (status) {
740
+ case 'ACTIVE':
741
+ return 'active';
742
+ // Suspended is PayPal's pause: it exists, it bills nothing today, and `/activate`
743
+ // starts it again. `past_due` said the subscriber owed money, which a merchant-
744
+ // initiated suspension does not — and this driver's own `cancelSubscription` points
745
+ // at suspend as the way to hold a subscription open. Either way it entitles nobody.
746
+ case 'SUSPENDED':
747
+ return 'paused';
748
+ case 'CANCELLED':
749
+ return 'canceled';
750
+ case 'EXPIRED':
751
+ return 'ended';
752
+ // APPROVAL_PENDING and APPROVED both mean "the payer has not finished" — PayPal
753
+ // has not charged anything yet.
754
+ default:
755
+ return 'incomplete';
756
+ }
757
+ }
758
+ #mapMoney(money, fallbackCurrency) {
759
+ const currency = (money?.currency_code ?? fallbackCurrency).toLowerCase();
760
+ return { amount: this.#fromValue(money?.value, currency), currency };
761
+ }
762
+ /** The link the payer must be sent to; PayPal names it `payer-action` or `approve`. */
763
+ #approvalUrl(links) {
764
+ const link = links?.find((candidate) => candidate.rel === 'payer-action') ??
765
+ links?.find((candidate) => candidate.rel === 'approve');
766
+ return link?.href ?? '';
767
+ }
768
+ async #createSubscription(options) {
769
+ const body = {
770
+ plan_id: options.planId,
771
+ ...(options.customId !== undefined ? { custom_id: options.customId } : {}),
772
+ ...(options.startTime !== undefined ? { start_time: options.startTime } : {}),
773
+ ...(options.returnUrl !== undefined
774
+ ? {
775
+ application_context: {
776
+ return_url: options.returnUrl,
777
+ ...(options.cancelUrl !== undefined ? { cancel_url: options.cancelUrl } : {}),
778
+ user_action: 'SUBSCRIBE_NOW',
779
+ },
780
+ }
781
+ : {}),
782
+ };
783
+ return this.#request('/v1/billing/subscriptions', {
784
+ method: 'POST',
785
+ body,
786
+ headers: {
787
+ Prefer: 'return=representation',
788
+ ...this.#requestId(options.requestId),
789
+ },
790
+ });
791
+ }
792
+ /**
793
+ * PayPal's idempotency header, with the limit its own guidance names.
794
+ *
795
+ * The per-endpoint schemas allow up to 10 000 characters, but PayPal's idempotency page
796
+ * recommends a UUID "because it meets the 38 single-byte character limit" — so 38 is the
797
+ * number that survives every endpoint, and a longer key is refused here rather than
798
+ * accepted by one call and rejected by the next.
799
+ */
800
+ #requestId(key) {
801
+ if (key === undefined)
802
+ return {};
803
+ if (key.length > REQUEST_ID_MAX_LENGTH) {
804
+ throw new Error(`[payments] PayPal's \`PayPal-Request-Id\` is documented at ${REQUEST_ID_MAX_LENGTH} single-byte characters (a UUID); got ${key.length}.`);
805
+ }
806
+ return { 'PayPal-Request-Id': key };
807
+ }
808
+ // ── Money ────────────────────────────────────────────────────────────────────────────
809
+ /** Minor units → the decimal string PayPal's `amount.value` wants for that currency. */
810
+ #toValue(amount, currency) {
811
+ if (PAYPAL_UNDIVIDED_CURRENCIES.has(currency.toLowerCase())) {
812
+ if (amount % 100 !== 0) {
813
+ throw new Error(`[payments] PayPal does not accept decimals in ${currency.toUpperCase()}, and ` +
814
+ `${amount} is not a whole ${currency.toUpperCase()}. Charge a round amount.`);
815
+ }
816
+ return String(amount / 100);
817
+ }
818
+ return formatDecimal(amount, currency);
819
+ }
820
+ /** PayPal's decimal string → minor units. */
821
+ #fromValue(value, currency) {
822
+ const parsed = Number(value ?? 0);
823
+ if (Number.isNaN(parsed))
824
+ return 0;
825
+ return fromDecimal(parsed, currency);
826
+ }
827
+ // ── Transport ────────────────────────────────────────────────────────────────────────
828
+ async #request(path, options = {}) {
829
+ return httpRequest(path, {
830
+ baseUrl: this.#baseUrl,
831
+ bearerToken: await this.#accessToken(),
832
+ ...(options.method !== undefined ? { method: options.method } : {}),
833
+ ...(options.body !== undefined ? { body: options.body } : {}),
834
+ ...(options.headers !== undefined ? { headers: options.headers } : {}),
835
+ });
836
+ }
837
+ /**
838
+ * The two calls PayPal answers with `204 No Content` (cancel, JSON Patch). `httpRequest`
839
+ * always parses a JSON body, which throws on an empty one — and the JSON Patch body is
840
+ * an array, not an object. Errors are shaped the same way so `isNotFound` still works.
841
+ */
842
+ async #requestNoContent(path, body, options = {}) {
843
+ const response = await fetch(`${this.#baseUrl}${path}`, {
844
+ method: options.method ?? 'POST',
845
+ headers: {
846
+ 'Content-Type': 'application/json',
847
+ Authorization: `Bearer ${await this.#accessToken()}`,
848
+ },
849
+ body: JSON.stringify(body),
850
+ });
851
+ if (!response.ok)
852
+ throw await this.#error(response);
853
+ }
854
+ /** Error normalization matching `httpRequest`, for the two calls that bypass it. */
855
+ async #error(response) {
856
+ const text = await response.text();
857
+ return Object.assign(new Error(`[payments] HTTP request failed (${response.status}): ${text}`), { status: response.status });
858
+ }
859
+ /**
860
+ * The client-credentials token, cached until PayPal's own `expires_in` says it is spent
861
+ * (minus a safety margin). Caching it for any fixed period of our own choosing is how a
862
+ * deploy works fine for an hour and then 401s on every charge.
863
+ */
864
+ async #accessToken() {
865
+ const cached = this.#token;
866
+ if (cached !== undefined && Date.now() < cached.expiresAt)
867
+ return cached.value;
868
+ const response = await fetch(`${this.#baseUrl}/v1/oauth2/token`, {
869
+ method: 'POST',
870
+ headers: {
871
+ Authorization: `Basic ${this.#basicAuth}`,
872
+ 'Content-Type': 'application/x-www-form-urlencoded',
873
+ },
874
+ body: 'grant_type=client_credentials',
875
+ });
876
+ if (!response.ok)
877
+ throw await this.#error(response);
878
+ const data = (await response.json());
879
+ if (data.access_token === undefined) {
880
+ throw new Error('[payments] PayPal returned no access token for the client credentials.');
881
+ }
882
+ // No `expires_in` means we know nothing about the lifetime, so don't cache it.
883
+ const ttlMs = typeof data.expires_in === 'number' ? data.expires_in * 1000 : 0;
884
+ this.#token = {
885
+ value: data.access_token,
886
+ expiresAt: Date.now() + Math.max(0, ttlMs - TOKEN_SAFETY_MARGIN_MS),
887
+ };
888
+ return data.access_token;
889
+ }
890
+ }
891
+ //# sourceMappingURL=paypal.js.map