@paytree/medusa-payment-paytree 0.1.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 (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +195 -0
  3. package/dist/index.d.ts +28 -0
  4. package/dist/index.js +55 -0
  5. package/dist/modules/paytree/index.d.ts +36 -0
  6. package/dist/modules/paytree/index.js +13 -0
  7. package/dist/modules/paytree/migrations/Migration20260617000000.d.ts +5 -0
  8. package/dist/modules/paytree/migrations/Migration20260617000000.js +54 -0
  9. package/dist/modules/paytree/migrations/Migration20260618000000.d.ts +5 -0
  10. package/dist/modules/paytree/migrations/Migration20260618000000.js +19 -0
  11. package/dist/modules/paytree/models/paytree-event-log.d.ts +25 -0
  12. package/dist/modules/paytree/models/paytree-event-log.js +29 -0
  13. package/dist/modules/paytree/models/paytree-setting.d.ts +23 -0
  14. package/dist/modules/paytree/models/paytree-setting.js +27 -0
  15. package/dist/modules/paytree/service.d.ts +60 -0
  16. package/dist/modules/paytree/service.js +47 -0
  17. package/dist/modules/paytree-payment/index.d.ts +8 -0
  18. package/dist/modules/paytree-payment/index.js +16 -0
  19. package/dist/modules/paytree-payment/lib/amount.d.ts +23 -0
  20. package/dist/modules/paytree-payment/lib/amount.js +40 -0
  21. package/dist/modules/paytree-payment/lib/audit.d.ts +26 -0
  22. package/dist/modules/paytree-payment/lib/audit.js +51 -0
  23. package/dist/modules/paytree-payment/lib/client.d.ts +28 -0
  24. package/dist/modules/paytree-payment/lib/client.js +81 -0
  25. package/dist/modules/paytree-payment/lib/constants.d.ts +54 -0
  26. package/dist/modules/paytree-payment/lib/constants.js +55 -0
  27. package/dist/modules/paytree-payment/lib/logger.d.ts +27 -0
  28. package/dist/modules/paytree-payment/lib/logger.js +73 -0
  29. package/dist/modules/paytree-payment/lib/methods.d.ts +69 -0
  30. package/dist/modules/paytree-payment/lib/methods.js +68 -0
  31. package/dist/modules/paytree-payment/lib/redact.d.ts +16 -0
  32. package/dist/modules/paytree-payment/lib/redact.js +60 -0
  33. package/dist/modules/paytree-payment/lib/settings-reader.d.ts +24 -0
  34. package/dist/modules/paytree-payment/lib/settings-reader.js +74 -0
  35. package/dist/modules/paytree-payment/lib/status-map.d.ts +26 -0
  36. package/dist/modules/paytree-payment/lib/status-map.js +66 -0
  37. package/dist/modules/paytree-payment/lib/types.d.ts +144 -0
  38. package/dist/modules/paytree-payment/lib/types.js +2 -0
  39. package/dist/modules/paytree-payment/service.d.ts +36 -0
  40. package/dist/modules/paytree-payment/service.js +369 -0
  41. package/docs/api-reference.md +121 -0
  42. package/docs/architecture.md +97 -0
  43. package/docs/configuration.md +62 -0
  44. package/docs/getting-started.md +111 -0
  45. package/docs/storefront-integration.md +85 -0
  46. package/integration/README.md +46 -0
  47. package/integration/admin/routes/paytree/api.ts +16 -0
  48. package/integration/admin/routes/paytree/method-icons.tsx +106 -0
  49. package/integration/admin/routes/paytree/page.tsx +18 -0
  50. package/integration/admin/routes/paytree/payments/page.tsx +240 -0
  51. package/integration/admin/routes/paytree/settings/page.tsx +214 -0
  52. package/integration/api/admin/paytree/events/route.ts +35 -0
  53. package/integration/api/admin/paytree/settings/route.ts +86 -0
  54. package/integration/api/hooks/paytree/route.ts +195 -0
  55. package/integration/api/store/paytree/methods/route.ts +28 -0
  56. package/package.json +57 -0
  57. package/storefront/README.md +25 -0
  58. package/storefront/components/payment-button.tsx +361 -0
  59. package/storefront/components/payment.tsx +356 -0
  60. package/storefront/components/paytree-method-icon.tsx +106 -0
  61. package/storefront/lib/paytree.ts +23 -0
@@ -0,0 +1,111 @@
1
+ # Getting started
2
+
3
+ This guide takes you from install to a first successful Paytree payment.
4
+
5
+ ## 1. Prerequisites
6
+
7
+ - A running Medusa `>= 2.13` backend (and, for the full flow, a Next.js storefront).
8
+ - A Paytree account with an API key.
9
+ - The public URL of your backend (for local dev, a tunnel such as ngrok/cloudflared
10
+ so Paytree can reach `/hooks/paytree`).
11
+
12
+ ## 2. Install
13
+
14
+ ```bash
15
+ npm install @paytree/medusa-payment-paytree
16
+ ```
17
+
18
+ ## 3. Environment
19
+
20
+ In `backend/.env`:
21
+
22
+ ```bash
23
+ PAYTREE_API_KEY=sk_live_or_test_key
24
+ PAYTREE_BASE_URL=https://api.paytree.tech
25
+ BACKEND_URL=https://<your-public-backend-url>
26
+ ```
27
+
28
+ ## 4. Register in `medusa-config.js`
29
+
30
+ ```js
31
+ modules: [
32
+ // Data module: settings + audit-log tables (owns the migrations).
33
+ { resolve: "@paytree/medusa-payment-paytree/dist/modules/paytree" },
34
+ // Payment module with the Paytree provider (the package's default export).
35
+ {
36
+ key: Modules.PAYMENT,
37
+ resolve: "@medusajs/payment",
38
+ options: {
39
+ providers: [
40
+ {
41
+ resolve: "@paytree/medusa-payment-paytree",
42
+ id: "paytree",
43
+ options: {
44
+ apiKey: process.env.PAYTREE_API_KEY,
45
+ backendUrl: process.env.BACKEND_URL,
46
+ baseUrl: process.env.PAYTREE_BASE_URL,
47
+ },
48
+ },
49
+ ],
50
+ },
51
+ },
52
+ ],
53
+ ```
54
+
55
+ ## 5. Copy in the API routes and admin UI
56
+
57
+ Medusa loads API routes and admin extensions from *your app's* `src/`, so copy
58
+ them from the package's `integration/` folder into your backend:
59
+
60
+ ```bash
61
+ cp -R node_modules/@paytree/medusa-payment-paytree/integration/api/* backend/src/api/
62
+ cp -R node_modules/@paytree/medusa-payment-paytree/integration/admin/* backend/src/admin/
63
+ ```
64
+
65
+ See [`../integration/README.md`](../integration/README.md) for the file map.
66
+
67
+ ## 6. Migrate the database
68
+
69
+ The data module owns two tables (`paytree_setting`, `paytree_event_log`):
70
+
71
+ ```bash
72
+ npx medusa db:migrate
73
+ ```
74
+
75
+ ## 7. Enable Paytree for a region
76
+
77
+ In the admin: **Settings → Regions → (a region) → Payment providers**, enable
78
+ **Paytree**. This is what makes it appear as an option at checkout.
79
+
80
+ ## 8. Curate methods (optional)
81
+
82
+ Open the admin **Paytree → Settings** page. `card` is enabled by default. Toggle
83
+ the methods you want to offer and optionally override their labels. Only enabled
84
+ methods are returned by `GET /store/paytree/methods` and shown at checkout.
85
+
86
+ ## 9. Storefront
87
+
88
+ Wire up the checkout UI using the reference components in
89
+ [`../storefront`](../storefront) — see
90
+ [storefront-integration.md](./storefront-integration.md).
91
+
92
+ ## 10. Make a test payment
93
+
94
+ 1. Add a product to the cart and reach the payment step.
95
+ 2. Select **Paytree**, pick a method, and continue.
96
+ 3. Click **Pay with Paytree** — you're redirected to the hosted page.
97
+ 4. Complete payment; you're redirected back to the return URL and the order is
98
+ placed.
99
+ 5. Check **Paytree → Payments** in the admin: you should see `initiate_payment`,
100
+ `webhook_received`, and `webhook_processed` events for the session.
101
+
102
+ ## Troubleshooting
103
+
104
+ - **"Paytree requires the storefront to pass these fields…"** — the payment
105
+ session data is missing `customer`, `address.country`, or `session`
106
+ (IP/user-agent). Make sure your storefront assembles these server-side (the
107
+ reference `initiatePaymentSession` does this).
108
+ - **Callback never arrives** — `BACKEND_URL` must be publicly reachable by
109
+ Paytree; for local dev use a tunnel and set `BACKEND_URL` to the tunnel URL.
110
+ - **401/403 from Paytree** — check `PAYTREE_API_KEY`. The base URL must be
111
+ `https` (enforced) so the key is never sent in cleartext.
@@ -0,0 +1,85 @@
1
+ # Storefront integration
2
+
3
+ Paytree is a **hosted-redirect** provider: the storefront collects the method
4
+ choice, the backend creates a payment intent and returns a `payment_link`, and
5
+ the customer is sent to Paytree's hosted page. These reference components target
6
+ the official [Medusa Next.js starter](https://github.com/medusajs/nextjs-starter-medusa)
7
+ layout.
8
+
9
+ The files live under [`../storefront`](../storefront). Copy each to the matching
10
+ path in your storefront and apply the shared-file edits below.
11
+
12
+ ## Files to add
13
+
14
+ | Reference file | Destination in the Next.js starter |
15
+ | -------------- | ---------------------------------- |
16
+ | `storefront/lib/paytree.ts` | `src/lib/data/paytree.ts` |
17
+ | `storefront/components/paytree-method-icon.tsx` | `src/modules/checkout/components/paytree-method-icon/index.tsx` |
18
+ | `storefront/components/payment.tsx` | `src/modules/checkout/components/payment/index.tsx` (replaces the starter's) |
19
+ | `storefront/components/payment-button.tsx` | `src/modules/checkout/components/payment-button/index.tsx` (replaces the starter's) |
20
+
21
+ ## Shared files to edit
22
+
23
+ ### 1. `src/lib/constants.tsx`
24
+
25
+ Register the provider so the picker shows a title/icon and helpers can detect it:
26
+
27
+ ```tsx
28
+ export const paymentInfoMap = {
29
+ // ...existing entries
30
+ pp_paytree_paytree: { title: "Paytree", icon: <CreditCard /> },
31
+ }
32
+
33
+ export const isPaytree = (providerId?: string) =>
34
+ providerId?.startsWith("pp_paytree")
35
+ ```
36
+
37
+ ### 2. `src/lib/data/cart.ts`
38
+
39
+ Paytree needs the customer, address, and request session (IP + user-agent) in
40
+ the payment session `data`. Assemble it **server-side** in
41
+ `initiatePaymentSession` before calling the SDK:
42
+
43
+ ```ts
44
+ export async function initiatePaymentSession(cart, data) {
45
+ let payload = data
46
+ if (data.provider_id.includes("paytree")) {
47
+ payload = { ...data, data: await buildPaytreeSessionData(cart, data.data) }
48
+ }
49
+ return sdk.store.payment.initiatePaymentSession(
50
+ cart, payload, {}, await getAuthHeaders()
51
+ ).then((resp) => { revalidateTag("cart"); return resp })
52
+ }
53
+ ```
54
+
55
+ `buildPaytreeSessionData` reads `next/headers` for `x-forwarded-for` /
56
+ `user-agent`, pulls the address/email from the cart, and sets `return_url` /
57
+ `cancel_url`. See the full implementation in the reference `payment.tsx` notes
58
+ and `storefront/lib/paytree.ts`, or copy from the demo's `cart.ts`.
59
+
60
+ ## How the checkout flows
61
+
62
+ 1. **Method step** (`payment.tsx`) — when Paytree is selected it calls
63
+ `listPaytreeMethods()` (`GET /store/paytree/methods`) and renders a radio list
64
+ using `PaytreeMethodIcon`. Paytree re-initiates the session on every submit so
65
+ the freshest method choice produces a fresh `payment_link`.
66
+ 2. **Review / place order** (`payment-button.tsx`) — `PaytreePaymentButton` reads
67
+ `session.data.payment_link` and redirects the browser to it.
68
+ 3. **Return** — Paytree redirects to your `return_url`. You need a return page at
69
+ the path you configured (the reference `buildPaytreeSessionData` uses
70
+ `/{countryCode}/checkout/paytree-return`). That page should finalize the cart
71
+ (`placeOrder()`) and show confirmation. Meanwhile the backend `/hooks/paytree`
72
+ callback reconciles the authoritative status independently, so the order is
73
+ captured even if the shopper closes the tab before the redirect completes.
74
+
75
+ ## Data loader
76
+
77
+ `storefront/lib/paytree.ts` exports `listPaytreeMethods()`, a server action that
78
+ fetches enabled methods and returns `[]` on failure so checkout degrades
79
+ gracefully if Paytree isn't configured.
80
+
81
+ ## Notes
82
+
83
+ - The reference components keep the starter's import aliases (`@lib/...`,
84
+ `@modules/...`). If your storefront uses different aliases, adjust the imports.
85
+ - The method picker only lists methods enabled in **Paytree → Settings**.
@@ -0,0 +1,46 @@
1
+ # Copy-in integration files
2
+
3
+ Medusa discovers **API routes** and **admin extensions** from your own app's
4
+ `src/` directory (and bundles the admin with Vite), so these can't be loaded
5
+ from an installed module the way the payment provider and data module are.
6
+ Copy them into your backend once, then they behave like first-party code.
7
+
8
+ They import the shared helpers (`PAYTREE_MODULE`, `PaytreeClient`,
9
+ `resolveMethods`, etc.) from the installed package
10
+ `@paytree/medusa-payment-paytree`, so no relative paths need fixing.
11
+
12
+ ## File map
13
+
14
+ | From (this package) | To (your backend) |
15
+ | ------------------- | ----------------- |
16
+ | `integration/api/hooks/paytree/route.ts` | `src/api/hooks/paytree/route.ts` |
17
+ | `integration/api/store/paytree/methods/route.ts` | `src/api/store/paytree/methods/route.ts` |
18
+ | `integration/api/admin/paytree/settings/route.ts` | `src/api/admin/paytree/settings/route.ts` |
19
+ | `integration/api/admin/paytree/events/route.ts` | `src/api/admin/paytree/events/route.ts` |
20
+ | `integration/admin/routes/paytree/**` | `src/admin/routes/paytree/**` |
21
+
22
+ ## Quick copy
23
+
24
+ ```bash
25
+ cp -R node_modules/@paytree/medusa-payment-paytree/integration/api/* backend/src/api/
26
+ cp -R node_modules/@paytree/medusa-payment-paytree/integration/admin/* backend/src/admin/
27
+ ```
28
+
29
+ ## What each piece does
30
+
31
+ - **`api/hooks/paytree`** — the GET callback Paytree calls when a payment status
32
+ changes. It re-fetches the authoritative status and drives the payment/refund
33
+ workflows. This is the live reconciliation path; without it, payments won't
34
+ finalize server-side.
35
+ - **`api/store/paytree/methods`** — public list of admin-enabled methods for the
36
+ storefront picker.
37
+ - **`api/admin/paytree/settings` + `events`** — admin settings read/write and the
38
+ audit-log query.
39
+ - **`admin/routes/paytree`** — the "Paytree" admin dashboard pages.
40
+
41
+ ## Requirements
42
+
43
+ The admin pages use `@medusajs/ui`, `@medusajs/icons`, and `react-router-dom`,
44
+ which a standard Medusa backend already provides. The routes require the data
45
+ module to be registered (see the main README) so `req.scope.resolve("paytree")`
46
+ works.
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Small fetch helper shared by the Paytree admin pages. Calls the admin API
3
+ * under /admin/paytree and surfaces a useful error message on failure.
4
+ */
5
+ export async function api(path: string, init?: RequestInit) {
6
+ const res = await fetch(`/admin/paytree/${path}`, {
7
+ credentials: "include",
8
+ headers: { "Content-Type": "application/json" },
9
+ ...init,
10
+ })
11
+ if (!res.ok) {
12
+ const detail = await res.json().catch(() => ({}))
13
+ throw new Error(detail?.message || `Request failed (${res.status})`)
14
+ }
15
+ return res.json()
16
+ }
@@ -0,0 +1,106 @@
1
+ import React from "react"
2
+
3
+ /**
4
+ * Compact SVG icons for each Paytree method, shown in the admin methods list
5
+ * and intended to mirror what the storefront renders. Generic rails (card,
6
+ * bank transfer, crypto) use line glyphs; brand methods use a colored badge
7
+ * with a short brand mark so the set stays consistent and recognizable.
8
+ */
9
+
10
+ type Badge = { bg: string; fg: string; text: string; size?: number }
11
+
12
+ const BADGES: Record<string, Badge> = {
13
+ paypal: { bg: "#003087", fg: "#ffffff", text: "PP" },
14
+ klarna: { bg: "#FFB3C7", fg: "#0A0A0A", text: "K." },
15
+ ideal: { bg: "#CD0067", fg: "#ffffff", text: "iD" },
16
+ bancontact: { bg: "#005498", fg: "#FFD800", text: "BC" },
17
+ trustly: { bg: "#0EE06E", fg: "#0A0A0A", text: "T" },
18
+ kakaopay: { bg: "#FFCD00", fg: "#3C1E1E", text: "pay", size: 7 },
19
+ p24: { bg: "#D3132B", fg: "#ffffff", text: "P24", size: 7 },
20
+ blik: { bg: "#000000", fg: "#E61E2A", text: "blik", size: 6 },
21
+ }
22
+
23
+ const BadgeIcon = ({ bg, fg, text, size = 9 }: Badge) => (
24
+ <svg width="24" height="24" viewBox="0 0 24 24" aria-hidden="true">
25
+ <rect x="1" y="4" width="22" height="16" rx="3" fill={bg} />
26
+ <text
27
+ x="12"
28
+ y="12.5"
29
+ fill={fg}
30
+ fontSize={size}
31
+ fontWeight="700"
32
+ fontFamily="Inter, system-ui, sans-serif"
33
+ textAnchor="middle"
34
+ dominantBaseline="central"
35
+ >
36
+ {text}
37
+ </text>
38
+ </svg>
39
+ )
40
+
41
+ const CardIcon = () => (
42
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
43
+ <rect
44
+ x="2"
45
+ y="5"
46
+ width="20"
47
+ height="14"
48
+ rx="2.5"
49
+ stroke="currentColor"
50
+ strokeWidth="1.6"
51
+ />
52
+ <path d="M2 9.5h20" stroke="currentColor" strokeWidth="1.6" />
53
+ <path
54
+ d="M5.5 15.5h4"
55
+ stroke="currentColor"
56
+ strokeWidth="1.6"
57
+ strokeLinecap="round"
58
+ />
59
+ </svg>
60
+ )
61
+
62
+ const BankIcon = () => (
63
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
64
+ <path
65
+ d="M12 3 3 7.5h18L12 3Z"
66
+ stroke="currentColor"
67
+ strokeWidth="1.5"
68
+ strokeLinejoin="round"
69
+ />
70
+ <path
71
+ d="M5 10v6M9.3 10v6M14.7 10v6M19 10v6M3 19.5h18"
72
+ stroke="currentColor"
73
+ strokeWidth="1.5"
74
+ strokeLinecap="round"
75
+ />
76
+ </svg>
77
+ )
78
+
79
+ const CryptoIcon = () => (
80
+ <svg width="24" height="24" viewBox="0 0 24 24" aria-hidden="true">
81
+ <circle cx="12" cy="12" r="9.2" fill="#F7931A" />
82
+ <text
83
+ x="12"
84
+ y="12.5"
85
+ fill="#ffffff"
86
+ fontSize="11"
87
+ fontWeight="700"
88
+ fontFamily="Inter, system-ui, sans-serif"
89
+ textAnchor="middle"
90
+ dominantBaseline="central"
91
+ >
92
+
93
+ </text>
94
+ </svg>
95
+ )
96
+
97
+ export const MethodIcon = ({ method }: { method: string }) => {
98
+ if (method === "card") return <CardIcon />
99
+ if (method === "wire") return <BankIcon />
100
+ if (method === "crypto") return <CryptoIcon />
101
+ const badge = BADGES[method]
102
+ if (badge) return <BadgeIcon {...badge} />
103
+ return <CardIcon />
104
+ }
105
+
106
+ export default MethodIcon
@@ -0,0 +1,18 @@
1
+ import { defineRouteConfig } from "@medusajs/admin-sdk"
2
+ import { CreditCard } from "@medusajs/icons"
3
+ import { Navigate } from "react-router-dom"
4
+
5
+ /**
6
+ * The "Paytree" sidebar group. Its children — Payments and Settings — are the
7
+ * real pages; landing on the group itself sends you to Payments.
8
+ */
9
+ const PaytreePage = () => {
10
+ return <Navigate to="/paytree/payments" replace />
11
+ }
12
+
13
+ export const config = defineRouteConfig({
14
+ label: "Paytree",
15
+ icon: CreditCard,
16
+ })
17
+
18
+ export default PaytreePage
@@ -0,0 +1,240 @@
1
+ import { defineRouteConfig } from "@medusajs/admin-sdk"
2
+ import { CurrencyDollar, TriangleRightMini, TriangleDownMini } from "@medusajs/icons"
3
+ import { Badge, Button, Container, Heading, Input, Table, Text, toast } from "@medusajs/ui"
4
+ import { Fragment, useEffect, useState } from "react"
5
+ import { api } from "../api"
6
+
7
+ type EventRow = {
8
+ id: string
9
+ event: string
10
+ session_id: string | null
11
+ paytree_id: string | null
12
+ status: string | null
13
+ amount: string | null
14
+ currency: string | null
15
+ error: string | null
16
+ detail: Record<string, unknown> | null
17
+ created_at: string
18
+ }
19
+
20
+ /** Pretty-print a JSON-ish value; detail may arrive as an object or a string. */
21
+ function formatDetail(value: unknown): string {
22
+ if (value == null) {
23
+ return ""
24
+ }
25
+ if (typeof value === "string") {
26
+ try {
27
+ return JSON.stringify(JSON.parse(value), null, 2)
28
+ } catch {
29
+ return value
30
+ }
31
+ }
32
+ return JSON.stringify(value, null, 2)
33
+ }
34
+
35
+ const STATUS_COLOR: Record<string, "green" | "red" | "orange" | "grey"> = {
36
+ success: "green",
37
+ authorized: "green",
38
+ captured: "green",
39
+ refunded: "orange",
40
+ partial_refunded: "orange",
41
+ failed: "red",
42
+ canceled: "red",
43
+ expired: "red",
44
+ pending: "grey",
45
+ }
46
+
47
+ const PaymentsPage = () => {
48
+ const [events, setEvents] = useState<EventRow[]>([])
49
+ const [count, setCount] = useState(0)
50
+ const [offset, setOffset] = useState(0)
51
+ const [sessionFilter, setSessionFilter] = useState("")
52
+ const [loading, setLoading] = useState(true)
53
+ const [expandedId, setExpandedId] = useState<string | null>(null)
54
+ const limit = 20
55
+
56
+ const toggleExpanded = (id: string) =>
57
+ setExpandedId((current) => (current === id ? null : id))
58
+
59
+ const load = (nextOffset = 0, session = sessionFilter) => {
60
+ setLoading(true)
61
+ const params = new URLSearchParams({
62
+ limit: String(limit),
63
+ offset: String(nextOffset),
64
+ })
65
+ if (session) {
66
+ params.set("session_id", session)
67
+ }
68
+ api(`events?${params.toString()}`)
69
+ .then((d) => {
70
+ setEvents(d.events)
71
+ setCount(d.count)
72
+ setOffset(nextOffset)
73
+ })
74
+ .catch((e) => toast.error(e.message))
75
+ .finally(() => setLoading(false))
76
+ }
77
+
78
+ useEffect(() => {
79
+ load(0)
80
+ // eslint-disable-next-line react-hooks/exhaustive-deps
81
+ }, [])
82
+
83
+ return (
84
+ <Container className="divide-y p-0">
85
+ <div className="flex items-center justify-between px-6 py-4">
86
+ <Heading level="h2">Payment activity</Heading>
87
+ <div className="flex gap-2">
88
+ <Input
89
+ placeholder="Filter by session id"
90
+ value={sessionFilter}
91
+ onChange={(e) => setSessionFilter(e.target.value)}
92
+ />
93
+ <Button variant="secondary" onClick={() => load(0)}>
94
+ Search
95
+ </Button>
96
+ </div>
97
+ </div>
98
+
99
+ <div className="px-6 py-2">
100
+ <Table>
101
+ <Table.Header>
102
+ <Table.Row>
103
+ <Table.HeaderCell className="w-8" />
104
+ <Table.HeaderCell>When</Table.HeaderCell>
105
+ <Table.HeaderCell>Event</Table.HeaderCell>
106
+ <Table.HeaderCell>Status</Table.HeaderCell>
107
+ <Table.HeaderCell>Amount</Table.HeaderCell>
108
+ <Table.HeaderCell>Session</Table.HeaderCell>
109
+ </Table.Row>
110
+ </Table.Header>
111
+ <Table.Body>
112
+ {events.map((row) => {
113
+ const hasDetails = Boolean(
114
+ row.error ||
115
+ (row.detail && Object.keys(row.detail).length > 0)
116
+ )
117
+ const expanded = expandedId === row.id
118
+
119
+ return (
120
+ <Fragment key={row.id}>
121
+ <Table.Row
122
+ className={hasDetails ? "cursor-pointer" : undefined}
123
+ onClick={
124
+ hasDetails ? () => toggleExpanded(row.id) : undefined
125
+ }
126
+ >
127
+ <Table.Cell className="w-8 text-ui-fg-muted">
128
+ {hasDetails ? (
129
+ expanded ? (
130
+ <TriangleDownMini />
131
+ ) : (
132
+ <TriangleRightMini />
133
+ )
134
+ ) : null}
135
+ </Table.Cell>
136
+ <Table.Cell>
137
+ {new Date(row.created_at).toLocaleString()}
138
+ </Table.Cell>
139
+ <Table.Cell>{row.event}</Table.Cell>
140
+ <Table.Cell>
141
+ {row.status ? (
142
+ <Badge
143
+ size="2xsmall"
144
+ color={STATUS_COLOR[row.status] ?? "grey"}
145
+ >
146
+ {row.status}
147
+ </Badge>
148
+ ) : row.error ? (
149
+ <Badge size="2xsmall" color="red">
150
+ error
151
+ </Badge>
152
+ ) : (
153
+ "—"
154
+ )}
155
+ </Table.Cell>
156
+ <Table.Cell>
157
+ {row.amount
158
+ ? `${row.amount} ${row.currency ?? ""}`.trim()
159
+ : "—"}
160
+ </Table.Cell>
161
+ <Table.Cell className="font-mono text-xs">
162
+ {row.session_id ?? "—"}
163
+ </Table.Cell>
164
+ </Table.Row>
165
+
166
+ {expanded && (
167
+ <Table.Row className="bg-ui-bg-subtle hover:bg-ui-bg-subtle">
168
+ <Table.Cell colSpan={6}>
169
+ <div className="flex flex-col gap-3 py-2">
170
+ {row.error && (
171
+ <div className="flex flex-col gap-1">
172
+ <Text
173
+ size="small"
174
+ weight="plus"
175
+ className="text-ui-fg-error"
176
+ >
177
+ Error
178
+ </Text>
179
+ <pre className="whitespace-pre-wrap break-all rounded-md border border-ui-border-base bg-ui-bg-base p-3 text-xs">
180
+ {row.error}
181
+ </pre>
182
+ </div>
183
+ )}
184
+ {row.detail &&
185
+ Object.keys(row.detail).length > 0 && (
186
+ <div className="flex flex-col gap-1">
187
+ <Text
188
+ size="small"
189
+ weight="plus"
190
+ className="text-ui-fg-subtle"
191
+ >
192
+ Request &amp; response
193
+ </Text>
194
+ <pre className="max-h-96 overflow-auto whitespace-pre-wrap break-all rounded-md border border-ui-border-base bg-ui-bg-base p-3 text-xs">
195
+ {formatDetail(row.detail)}
196
+ </pre>
197
+ </div>
198
+ )}
199
+ </div>
200
+ </Table.Cell>
201
+ </Table.Row>
202
+ )}
203
+ </Fragment>
204
+ )
205
+ })}
206
+ </Table.Body>
207
+ </Table>
208
+ </div>
209
+
210
+ <div className="flex items-center justify-between px-6 py-4">
211
+ <Text size="small" className="text-ui-fg-subtle">
212
+ {count} event{count === 1 ? "" : "s"}
213
+ </Text>
214
+ <div className="flex gap-2">
215
+ <Button
216
+ variant="secondary"
217
+ disabled={offset === 0 || loading}
218
+ onClick={() => load(Math.max(0, offset - limit))}
219
+ >
220
+ Previous
221
+ </Button>
222
+ <Button
223
+ variant="secondary"
224
+ disabled={offset + limit >= count || loading}
225
+ onClick={() => load(offset + limit)}
226
+ >
227
+ Next
228
+ </Button>
229
+ </div>
230
+ </div>
231
+ </Container>
232
+ )
233
+ }
234
+
235
+ export const config = defineRouteConfig({
236
+ label: "Payments",
237
+ icon: CurrencyDollar,
238
+ })
239
+
240
+ export default PaymentsPage