@easypayment/medusa-paypal 0.4.7 → 0.4.8

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 (70) hide show
  1. package/.medusa/server/src/admin/index.js +7 -7
  2. package/.medusa/server/src/admin/index.mjs +7 -7
  3. package/.medusa/server/src/api/store/paypal/create-order/route.d.ts.map +1 -1
  4. package/.medusa/server/src/api/store/paypal/create-order/route.js +62 -139
  5. package/.medusa/server/src/api/store/paypal/create-order/route.js.map +1 -1
  6. package/.medusa/server/src/modules/paypal/migrations/20260115120000_create_paypal_connection.js +22 -22
  7. package/.medusa/server/src/modules/paypal/migrations/20260123090000_create_paypal_settings.js +11 -11
  8. package/.medusa/server/src/modules/paypal/migrations/20260201090000_create_paypal_webhook_event.js +18 -18
  9. package/.medusa/server/src/modules/paypal/migrations/20260401090000_create_paypal_metric.js +16 -16
  10. package/.medusa/server/src/modules/paypal/migrations/20260701090000_add_paypal_webhook_event_processing.js +20 -20
  11. package/.medusa/server/src/modules/paypal/migrations/20261101090000_remove_paypal_reconciliation_status.js +14 -14
  12. package/.medusa/server/src/modules/paypal/migrations/20261201090000_remove_paypal_audit_log.js +15 -15
  13. package/README.md +142 -142
  14. package/package.json +75 -75
  15. package/src/admin/index.ts +7 -7
  16. package/src/admin/routes/settings/paypal/_components/Tabs.tsx +52 -52
  17. package/src/admin/routes/settings/paypal/_components/Toast.tsx +51 -51
  18. package/src/admin/routes/settings/paypal/additional-settings/page.tsx +200 -200
  19. package/src/admin/routes/settings/paypal/advanced-card-payments/page.tsx +183 -183
  20. package/src/admin/routes/settings/paypal/apple-pay/page.tsx +5 -5
  21. package/src/admin/routes/settings/paypal/connection/page.tsx +754 -754
  22. package/src/admin/routes/settings/paypal/google-pay/page.tsx +5 -5
  23. package/src/admin/routes/settings/paypal/pay-later-messaging/page.tsx +5 -5
  24. package/src/admin/routes/settings/paypal/paypal-settings/page.tsx +376 -376
  25. package/src/api/admin/payment-collections/[id]/payment-sessions/route.ts +24 -24
  26. package/src/api/admin/paypal/disconnect/route.ts +8 -8
  27. package/src/api/admin/paypal/environment/route.ts +25 -25
  28. package/src/api/admin/paypal/onboard-complete/route.ts +44 -44
  29. package/src/api/admin/paypal/onboarding-link/route.ts +45 -45
  30. package/src/api/admin/paypal/onboarding-status/route.ts +18 -18
  31. package/src/api/admin/paypal/rotate-credentials/route.ts +8 -8
  32. package/src/api/admin/paypal/save-credentials/route.ts +14 -14
  33. package/src/api/admin/paypal/settings/route.ts +14 -14
  34. package/src/api/admin/paypal/status/route.ts +12 -12
  35. package/src/api/store/payment-collections/[id]/payment-sessions/route.ts +65 -65
  36. package/src/api/store/paypal/capture-order/route.ts +276 -276
  37. package/src/api/store/paypal/config/route.ts +102 -102
  38. package/src/api/store/paypal/create-order/route.ts +77 -176
  39. package/src/api/store/paypal/settings/route.ts +19 -19
  40. package/src/api/store/paypal/webhook/route.ts +246 -246
  41. package/src/api/store/paypal-complete/route.ts +75 -75
  42. package/src/jobs/paypal-reconcile.ts +112 -112
  43. package/src/jobs/paypal-webhook-retry.ts +85 -85
  44. package/src/modules/paypal/clients/paypal-seller.client.ts +59 -59
  45. package/src/modules/paypal/index.ts +8 -8
  46. package/src/modules/paypal/migrations/20260115120000_create_paypal_connection.ts +33 -33
  47. package/src/modules/paypal/migrations/20260123090000_create_paypal_settings.ts +22 -22
  48. package/src/modules/paypal/migrations/20260201090000_create_paypal_webhook_event.ts +29 -29
  49. package/src/modules/paypal/migrations/20260401090000_create_paypal_metric.ts +27 -27
  50. package/src/modules/paypal/migrations/20260701090000_add_paypal_webhook_event_processing.ts +31 -31
  51. package/src/modules/paypal/migrations/20261101090000_remove_paypal_reconciliation_status.ts +25 -25
  52. package/src/modules/paypal/migrations/20261201090000_remove_paypal_audit_log.ts +26 -26
  53. package/src/modules/paypal/migrations/20270101090000_set_paypal_environment_default_live.ts +11 -11
  54. package/src/modules/paypal/models/paypal_connection.ts +21 -21
  55. package/src/modules/paypal/models/paypal_metric.ts +9 -9
  56. package/src/modules/paypal/models/paypal_settings.ts +8 -8
  57. package/src/modules/paypal/models/paypal_webhook_event.ts +19 -19
  58. package/src/modules/paypal/payment-provider/README.md +22 -22
  59. package/src/modules/paypal/payment-provider/card-service.ts +760 -760
  60. package/src/modules/paypal/payment-provider/index.ts +19 -19
  61. package/src/modules/paypal/payment-provider/service.ts +1121 -1121
  62. package/src/modules/paypal/payment-provider/webhook-utils.ts +88 -88
  63. package/src/modules/paypal/service.ts +1247 -1247
  64. package/src/modules/paypal/types/config.ts +47 -47
  65. package/src/modules/paypal/utils/amounts.ts +41 -41
  66. package/src/modules/paypal/utils/crypto.ts +51 -51
  67. package/src/modules/paypal/utils/currencies.ts +84 -84
  68. package/src/modules/paypal/utils/paypal-auth.ts +32 -32
  69. package/src/modules/paypal/utils/provider-ids.ts +15 -15
  70. package/src/modules/paypal/webhook-processor.ts +215 -215
@@ -1,52 +1,52 @@
1
- import React from "react"
2
- import { Link, useLocation } from "react-router-dom"
3
-
4
- type Tab = {
5
- label: string
6
- to: string
7
- }
8
-
9
- const BASE = "/settings/paypal"
10
-
11
- const TABS: Tab[] = [
12
- { label: "PayPal Connection", to: `${BASE}/connection` },
13
- { label: "PayPal Settings", to: `${BASE}/paypal-settings` },
14
- { label: "Advanced Card Payments", to: `${BASE}/advanced-card-payments` },
15
- /* { label: "Google Pay", to: `${BASE}/google-pay` },
16
- { label: "Apple Pay", to: `${BASE}/apple-pay` },
17
- { label: "Pay Later Messaging", to: `${BASE}/pay-later-messaging` }, */
18
- { label: "Additional Settings", to: `${BASE}/additional-settings` },
19
- ]
20
-
21
- function isActive(pathname: string, to: string) {
22
- // exact match OR nested route under same tab
23
- return pathname === to || pathname.startsWith(to + "/")
24
- }
25
-
26
- export default function PayPalTabs() {
27
- const { pathname } = useLocation()
28
-
29
- return (
30
- <div className="border-b border-ui-border-base">
31
- <div className="flex flex-wrap gap-6 text-sm">
32
- {TABS.map((t) => {
33
- const active = isActive(pathname, t.to)
34
-
35
- return (
36
- <Link
37
- key={t.to}
38
- to={t.to}
39
- className={
40
- active
41
- ? "border-b-2 border-ui-fg-base pb-2 font-medium text-ui-fg-base"
42
- : "pb-2 text-ui-fg-subtle hover:text-ui-fg-base"
43
- }
44
- >
45
- {t.label}
46
- </Link>
47
- )
48
- })}
49
- </div>
50
- </div>
51
- )
52
- }
1
+ import React from "react"
2
+ import { Link, useLocation } from "react-router-dom"
3
+
4
+ type Tab = {
5
+ label: string
6
+ to: string
7
+ }
8
+
9
+ const BASE = "/settings/paypal"
10
+
11
+ const TABS: Tab[] = [
12
+ { label: "PayPal Connection", to: `${BASE}/connection` },
13
+ { label: "PayPal Settings", to: `${BASE}/paypal-settings` },
14
+ { label: "Advanced Card Payments", to: `${BASE}/advanced-card-payments` },
15
+ /* { label: "Google Pay", to: `${BASE}/google-pay` },
16
+ { label: "Apple Pay", to: `${BASE}/apple-pay` },
17
+ { label: "Pay Later Messaging", to: `${BASE}/pay-later-messaging` }, */
18
+ { label: "Additional Settings", to: `${BASE}/additional-settings` },
19
+ ]
20
+
21
+ function isActive(pathname: string, to: string) {
22
+ // exact match OR nested route under same tab
23
+ return pathname === to || pathname.startsWith(to + "/")
24
+ }
25
+
26
+ export default function PayPalTabs() {
27
+ const { pathname } = useLocation()
28
+
29
+ return (
30
+ <div className="border-b border-ui-border-base">
31
+ <div className="flex flex-wrap gap-6 text-sm">
32
+ {TABS.map((t) => {
33
+ const active = isActive(pathname, t.to)
34
+
35
+ return (
36
+ <Link
37
+ key={t.to}
38
+ to={t.to}
39
+ className={
40
+ active
41
+ ? "border-b-2 border-ui-fg-base pb-2 font-medium text-ui-fg-base"
42
+ : "pb-2 text-ui-fg-subtle hover:text-ui-fg-base"
43
+ }
44
+ >
45
+ {t.label}
46
+ </Link>
47
+ )
48
+ })}
49
+ </div>
50
+ </div>
51
+ )
52
+ }
@@ -1,51 +1,51 @@
1
- import React, { useEffect } from "react"
2
-
3
- export type ToastKind = "success" | "error"
4
-
5
- export type ToastState = {
6
- kind: ToastKind
7
- message: string
8
- } | null
9
-
10
- type Props = {
11
- toast: ToastState
12
- onClose: () => void
13
- }
14
-
15
- export default function Toast({ toast, onClose }: Props) {
16
- useEffect(() => {
17
- if (!toast) return
18
- const t = setTimeout(() => onClose(), 2500)
19
- return () => clearTimeout(t)
20
- }, [toast, onClose])
21
-
22
- if (!toast) return null
23
-
24
- const isSuccess = toast.kind === "success"
25
-
26
- return (
27
- <div className="fixed right-6 top-6 z-[9999]">
28
- <div
29
- className={[
30
- "min-w-[280px] max-w-[420px] rounded-lg border px-4 py-3 shadow-md",
31
- isSuccess ? "border-emerald-500/30 bg-emerald-500/10" : "border-rose-500/30 bg-rose-500/10",
32
- ].join(" ")}
33
- role="status"
34
- aria-live="polite"
35
- >
36
- <div className="flex items-start gap-3">
37
- <div className={["mt-0.5 h-2.5 w-2.5 rounded-full", isSuccess ? "bg-emerald-500" : "bg-rose-500"].join(" ")} />
38
- <div className="flex-1 text-sm text-ui-fg-base">{toast.message}</div>
39
- <button
40
- type="button"
41
- onClick={onClose}
42
- className="text-ui-fg-subtle hover:text-ui-fg-base"
43
- aria-label="Close"
44
- >
45
- ×
46
- </button>
47
- </div>
48
- </div>
49
- </div>
50
- )
51
- }
1
+ import React, { useEffect } from "react"
2
+
3
+ export type ToastKind = "success" | "error"
4
+
5
+ export type ToastState = {
6
+ kind: ToastKind
7
+ message: string
8
+ } | null
9
+
10
+ type Props = {
11
+ toast: ToastState
12
+ onClose: () => void
13
+ }
14
+
15
+ export default function Toast({ toast, onClose }: Props) {
16
+ useEffect(() => {
17
+ if (!toast) return
18
+ const t = setTimeout(() => onClose(), 2500)
19
+ return () => clearTimeout(t)
20
+ }, [toast, onClose])
21
+
22
+ if (!toast) return null
23
+
24
+ const isSuccess = toast.kind === "success"
25
+
26
+ return (
27
+ <div className="fixed right-6 top-6 z-[9999]">
28
+ <div
29
+ className={[
30
+ "min-w-[280px] max-w-[420px] rounded-lg border px-4 py-3 shadow-md",
31
+ isSuccess ? "border-emerald-500/30 bg-emerald-500/10" : "border-rose-500/30 bg-rose-500/10",
32
+ ].join(" ")}
33
+ role="status"
34
+ aria-live="polite"
35
+ >
36
+ <div className="flex items-start gap-3">
37
+ <div className={["mt-0.5 h-2.5 w-2.5 rounded-full", isSuccess ? "bg-emerald-500" : "bg-rose-500"].join(" ")} />
38
+ <div className="flex-1 text-sm text-ui-fg-base">{toast.message}</div>
39
+ <button
40
+ type="button"
41
+ onClick={onClose}
42
+ className="text-ui-fg-subtle hover:text-ui-fg-base"
43
+ aria-label="Close"
44
+ >
45
+ ×
46
+ </button>
47
+ </div>
48
+ </div>
49
+ </div>
50
+ )
51
+ }
@@ -1,200 +1,200 @@
1
- import React, { useEffect, useRef, useState } from "react"
2
- import PayPalTabs from "../_components/Tabs"
3
-
4
- type AdminFetchOptions = {
5
- method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH"
6
- body?: Record<string, unknown>
7
- query?: Record<string, string>
8
- }
9
-
10
- async function adminFetch<T = unknown>(path: string, opts: AdminFetchOptions = {}): Promise<T> {
11
- const { method = "GET", body, query } = opts
12
- let url = path
13
- if (query && Object.keys(query).length > 0) {
14
- const params = new URLSearchParams(query)
15
- url = `${path}?${params.toString()}`
16
- }
17
- const headers: Record<string, string> = { Accept: "application/json" }
18
- if (body !== undefined) headers["Content-Type"] = "application/json"
19
- if (typeof window !== "undefined") {
20
- const token = (window as any).__medusa__?.token
21
- if (token) headers["Authorization"] = `Bearer ${token}`
22
- }
23
- const res = await fetch(url, {
24
- method, headers, credentials: "include",
25
- body: body !== undefined ? JSON.stringify(body) : undefined,
26
- })
27
- const text = await res.text().catch(() => "")
28
- if (!res.ok) {
29
- if (res.status === 401) throw new Error("Unauthorized (401) - session may have expired. Please reload and log in again.")
30
- if (res.status === 403) throw new Error("Forbidden (403) - you do not have permission to perform this action.")
31
- throw new Error(text || `Request failed with status ${res.status}`)
32
- }
33
- if (!text) return {} as T
34
- try { return JSON.parse(text) as T } catch { return {} as T }
35
- }
36
-
37
- type PaymentAction = "capture" | "authorize"
38
- type LandingPage = "no_preference" | "login" | "billing"
39
-
40
- type AdditionalSettingsForm = {
41
- paymentAction: PaymentAction
42
- brandName: string
43
- landingPage: LandingPage
44
- requireInstantPayment: boolean
45
- sendItemDetails: boolean
46
- invoicePrefix: string
47
- creditCardStatementName: string
48
- }
49
-
50
- const DEFAULT_FORM: AdditionalSettingsForm = {
51
- paymentAction: "capture",
52
- brandName: "PayPal",
53
- landingPage: "no_preference",
54
- requireInstantPayment: false,
55
- sendItemDetails: true,
56
- invoicePrefix: "WC-",
57
- creditCardStatementName: "PayPal",
58
- }
59
-
60
- function mergeWithDefaults(saved?: Partial<AdditionalSettingsForm> | null) {
61
- if (!saved) return { ...DEFAULT_FORM }
62
- const entries = Object.entries(saved).filter(([, value]) => value !== undefined)
63
- return { ...DEFAULT_FORM, ...(Object.fromEntries(entries) as Partial<AdditionalSettingsForm>) }
64
- }
65
-
66
- function SectionCard({ title, description, right, children }: { title: string; description?: React.ReactNode; right?: React.ReactNode; children: React.ReactNode }) {
67
- return (
68
- <div className="rounded-xl border border-ui-border-base bg-ui-bg-base shadow-sm">
69
- <div className="flex items-start justify-between gap-4 border-b border-ui-border-base p-4">
70
- <div>
71
- <div className="text-base font-semibold text-ui-fg-base">{title}</div>
72
- {description ? <div className="mt-1 text-sm text-ui-fg-subtle">{description}</div> : null}
73
- </div>
74
- {right}
75
- </div>
76
- <div className="p-4">{children}</div>
77
- </div>
78
- )
79
- }
80
-
81
- function FieldRow({ label, hint, children }: { label: string; hint?: React.ReactNode; children: React.ReactNode }) {
82
- return (
83
- <div className="grid grid-cols-12 items-start gap-4 py-3">
84
- <div className="col-span-12 md:col-span-4">
85
- <div className="text-sm font-medium text-ui-fg-base">{label}</div>
86
- {hint ? <div className="mt-1 text-xs text-ui-fg-subtle">{hint}</div> : null}
87
- </div>
88
- <div className="col-span-12 md:col-span-8">{children}</div>
89
- </div>
90
- )
91
- }
92
-
93
- export default function AdditionalSettingsTab() {
94
- const [form, setForm] = useState<AdditionalSettingsForm>(() => ({ ...DEFAULT_FORM }))
95
- const [loading, setLoading] = useState(false)
96
- const [saving, setSaving] = useState(false)
97
- const [toast, setToast] = useState<{ type: "success" | "error"; message: string } | null>(null)
98
- const didInit = useRef(false)
99
-
100
- useEffect(() => {
101
- if (didInit.current) return
102
- didInit.current = true
103
- ;(async () => {
104
- try {
105
- setLoading(true)
106
- const json = await adminFetch<any>("/admin/paypal/settings")
107
- const payload = json?.data ?? json
108
- const saved = payload?.additional_settings
109
- if (saved && typeof saved === "object") setForm(mergeWithDefaults(saved))
110
- } catch {
111
- // use defaults
112
- } finally {
113
- setLoading(false)
114
- }
115
- })()
116
- }, [])
117
-
118
- async function onSave() {
119
- try {
120
- setSaving(true)
121
- setToast(null)
122
- const json = await adminFetch<any>("/admin/paypal/settings", { method: "POST", body: { additional_settings: form as unknown as Record<string, unknown> } })
123
- const payload = json?.data ?? json
124
- const saved = payload?.additional_settings
125
- if (saved && typeof saved === "object") setForm(mergeWithDefaults(saved))
126
- setToast({ type: "success", message: "Settings saved" })
127
- window.setTimeout(() => setToast(null), 2500)
128
- } catch (e: unknown) {
129
- setToast({ type: "error", message: e instanceof Error ? e.message : "Failed to save settings" })
130
- window.setTimeout(() => setToast(null), 3500)
131
- } finally {
132
- setSaving(false)
133
- }
134
- }
135
-
136
- return (
137
- <div className="p-6">
138
- <div className="flex flex-col gap-6">
139
- <div className="flex items-start justify-between gap-4">
140
- <div><h1 className="text-xl font-semibold text-ui-fg-base">PayPal Gateway By Easy Payment</h1></div>
141
- </div>
142
- <PayPalTabs />
143
- {toast ? (
144
- <div className="fixed right-6 top-6 z-50 rounded-md border border-ui-border-base bg-ui-bg-base px-4 py-3 text-sm shadow-lg" role="status" aria-live="polite">
145
- <span className={toast.type === "success" ? "text-ui-fg-base" : "text-ui-fg-error"}>{toast.message}</span>
146
- </div>
147
- ) : null}
148
- <SectionCard
149
- title="Additional Settings"
150
- description="These settings control checkout behavior and PayPal experience."
151
- right={(
152
- <div className="flex items-center gap-3">
153
- <button type="button" onClick={onSave} disabled={saving || loading} className="rounded-md bg-ui-button-neutral px-4 py-2 text-sm font-medium text-ui-fg-on-color shadow-sm hover:opacity-90 disabled:opacity-60">
154
- {saving ? "Saving..." : "Save settings"}
155
- </button>
156
- {loading ? <span className="text-sm text-ui-fg-subtle">Loading...</span> : null}
157
- </div>
158
- )}
159
- >
160
- <div className="divide-y divide-ui-border-base">
161
- <FieldRow label="Payment action">
162
- <select value={form.paymentAction} onChange={(e) => setForm((p) => ({ ...p, paymentAction: e.target.value as PaymentAction }))} className="w-full rounded-md border border-ui-border-base bg-ui-bg-base px-3 py-2 text-sm text-ui-fg-base outline-none focus:ring-2 focus:ring-ui-border-interactive">
163
- <option value="capture">Capture</option>
164
- <option value="authorize">Authorize</option>
165
- </select>
166
- </FieldRow>
167
- <FieldRow label="Brand Name">
168
- <input value={form.brandName} onChange={(e) => setForm((p) => ({ ...p, brandName: e.target.value }))} className="w-full rounded-md border border-ui-border-base bg-ui-bg-base px-3 py-2 text-sm text-ui-fg-base outline-none focus:ring-2 focus:ring-ui-border-interactive" placeholder="PayPal" />
169
- </FieldRow>
170
- <FieldRow label="Landing Page">
171
- <select value={form.landingPage} onChange={(e) => setForm((p) => ({ ...p, landingPage: e.target.value as LandingPage }))} className="w-full rounded-md border border-ui-border-base bg-ui-bg-base px-3 py-2 text-sm text-ui-fg-base outline-none focus:ring-2 focus:ring-ui-border-interactive">
172
- <option value="no_preference">No Preference</option>
173
- <option value="login">Login</option>
174
- <option value="billing">Billing</option>
175
- </select>
176
- </FieldRow>
177
- <FieldRow label="Instant Payments">
178
- <label className="inline-flex items-center gap-2">
179
- <input type="checkbox" checked={form.requireInstantPayment} onChange={(e) => setForm((p) => ({ ...p, requireInstantPayment: e.target.checked }))} className="h-4 w-4 rounded border-ui-border-base" />
180
- <span className="text-sm text-ui-fg-base">Require Instant Payment</span>
181
- </label>
182
- </FieldRow>
183
- <FieldRow label="Send Item Details" hint="Include all line item details in the payment request to PayPal so that they can be seen from the PayPal transaction details page.">
184
- <label className="inline-flex items-center gap-2">
185
- <input type="checkbox" checked={form.sendItemDetails} onChange={(e) => setForm((p) => ({ ...p, sendItemDetails: e.target.checked }))} className="h-4 w-4 rounded border-ui-border-base" />
186
- <span className="text-sm text-ui-fg-base">Send line item details to PayPal</span>
187
- </label>
188
- </FieldRow>
189
- <FieldRow label="Invoice prefix">
190
- <input value={form.invoicePrefix} onChange={(e) => setForm((p) => ({ ...p, invoicePrefix: e.target.value }))} className="w-full rounded-md border border-ui-border-base bg-ui-bg-base px-3 py-2 text-sm text-ui-fg-base outline-none focus:ring-2 focus:ring-ui-border-interactive" placeholder="WC-" />
191
- </FieldRow>
192
- <FieldRow label="Credit Card Statement Name">
193
- <input value={form.creditCardStatementName} onChange={(e) => setForm((p) => ({ ...p, creditCardStatementName: e.target.value }))} className="w-full rounded-md border border-ui-border-base bg-ui-bg-base px-3 py-2 text-sm text-ui-fg-base outline-none focus:ring-2 focus:ring-ui-border-interactive" placeholder="PayPal" />
194
- </FieldRow>
195
- </div>
196
- </SectionCard>
197
- </div>
198
- </div>
199
- )
200
- }
1
+ import React, { useEffect, useRef, useState } from "react"
2
+ import PayPalTabs from "../_components/Tabs"
3
+
4
+ type AdminFetchOptions = {
5
+ method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH"
6
+ body?: Record<string, unknown>
7
+ query?: Record<string, string>
8
+ }
9
+
10
+ async function adminFetch<T = unknown>(path: string, opts: AdminFetchOptions = {}): Promise<T> {
11
+ const { method = "GET", body, query } = opts
12
+ let url = path
13
+ if (query && Object.keys(query).length > 0) {
14
+ const params = new URLSearchParams(query)
15
+ url = `${path}?${params.toString()}`
16
+ }
17
+ const headers: Record<string, string> = { Accept: "application/json" }
18
+ if (body !== undefined) headers["Content-Type"] = "application/json"
19
+ if (typeof window !== "undefined") {
20
+ const token = (window as any).__medusa__?.token
21
+ if (token) headers["Authorization"] = `Bearer ${token}`
22
+ }
23
+ const res = await fetch(url, {
24
+ method, headers, credentials: "include",
25
+ body: body !== undefined ? JSON.stringify(body) : undefined,
26
+ })
27
+ const text = await res.text().catch(() => "")
28
+ if (!res.ok) {
29
+ if (res.status === 401) throw new Error("Unauthorized (401) - session may have expired. Please reload and log in again.")
30
+ if (res.status === 403) throw new Error("Forbidden (403) - you do not have permission to perform this action.")
31
+ throw new Error(text || `Request failed with status ${res.status}`)
32
+ }
33
+ if (!text) return {} as T
34
+ try { return JSON.parse(text) as T } catch { return {} as T }
35
+ }
36
+
37
+ type PaymentAction = "capture" | "authorize"
38
+ type LandingPage = "no_preference" | "login" | "billing"
39
+
40
+ type AdditionalSettingsForm = {
41
+ paymentAction: PaymentAction
42
+ brandName: string
43
+ landingPage: LandingPage
44
+ requireInstantPayment: boolean
45
+ sendItemDetails: boolean
46
+ invoicePrefix: string
47
+ creditCardStatementName: string
48
+ }
49
+
50
+ const DEFAULT_FORM: AdditionalSettingsForm = {
51
+ paymentAction: "capture",
52
+ brandName: "PayPal",
53
+ landingPage: "no_preference",
54
+ requireInstantPayment: false,
55
+ sendItemDetails: true,
56
+ invoicePrefix: "WC-",
57
+ creditCardStatementName: "PayPal",
58
+ }
59
+
60
+ function mergeWithDefaults(saved?: Partial<AdditionalSettingsForm> | null) {
61
+ if (!saved) return { ...DEFAULT_FORM }
62
+ const entries = Object.entries(saved).filter(([, value]) => value !== undefined)
63
+ return { ...DEFAULT_FORM, ...(Object.fromEntries(entries) as Partial<AdditionalSettingsForm>) }
64
+ }
65
+
66
+ function SectionCard({ title, description, right, children }: { title: string; description?: React.ReactNode; right?: React.ReactNode; children: React.ReactNode }) {
67
+ return (
68
+ <div className="rounded-xl border border-ui-border-base bg-ui-bg-base shadow-sm">
69
+ <div className="flex items-start justify-between gap-4 border-b border-ui-border-base p-4">
70
+ <div>
71
+ <div className="text-base font-semibold text-ui-fg-base">{title}</div>
72
+ {description ? <div className="mt-1 text-sm text-ui-fg-subtle">{description}</div> : null}
73
+ </div>
74
+ {right}
75
+ </div>
76
+ <div className="p-4">{children}</div>
77
+ </div>
78
+ )
79
+ }
80
+
81
+ function FieldRow({ label, hint, children }: { label: string; hint?: React.ReactNode; children: React.ReactNode }) {
82
+ return (
83
+ <div className="grid grid-cols-12 items-start gap-4 py-3">
84
+ <div className="col-span-12 md:col-span-4">
85
+ <div className="text-sm font-medium text-ui-fg-base">{label}</div>
86
+ {hint ? <div className="mt-1 text-xs text-ui-fg-subtle">{hint}</div> : null}
87
+ </div>
88
+ <div className="col-span-12 md:col-span-8">{children}</div>
89
+ </div>
90
+ )
91
+ }
92
+
93
+ export default function AdditionalSettingsTab() {
94
+ const [form, setForm] = useState<AdditionalSettingsForm>(() => ({ ...DEFAULT_FORM }))
95
+ const [loading, setLoading] = useState(false)
96
+ const [saving, setSaving] = useState(false)
97
+ const [toast, setToast] = useState<{ type: "success" | "error"; message: string } | null>(null)
98
+ const didInit = useRef(false)
99
+
100
+ useEffect(() => {
101
+ if (didInit.current) return
102
+ didInit.current = true
103
+ ;(async () => {
104
+ try {
105
+ setLoading(true)
106
+ const json = await adminFetch<any>("/admin/paypal/settings")
107
+ const payload = json?.data ?? json
108
+ const saved = payload?.additional_settings
109
+ if (saved && typeof saved === "object") setForm(mergeWithDefaults(saved))
110
+ } catch {
111
+ // use defaults
112
+ } finally {
113
+ setLoading(false)
114
+ }
115
+ })()
116
+ }, [])
117
+
118
+ async function onSave() {
119
+ try {
120
+ setSaving(true)
121
+ setToast(null)
122
+ const json = await adminFetch<any>("/admin/paypal/settings", { method: "POST", body: { additional_settings: form as unknown as Record<string, unknown> } })
123
+ const payload = json?.data ?? json
124
+ const saved = payload?.additional_settings
125
+ if (saved && typeof saved === "object") setForm(mergeWithDefaults(saved))
126
+ setToast({ type: "success", message: "Settings saved" })
127
+ window.setTimeout(() => setToast(null), 2500)
128
+ } catch (e: unknown) {
129
+ setToast({ type: "error", message: e instanceof Error ? e.message : "Failed to save settings" })
130
+ window.setTimeout(() => setToast(null), 3500)
131
+ } finally {
132
+ setSaving(false)
133
+ }
134
+ }
135
+
136
+ return (
137
+ <div className="p-6">
138
+ <div className="flex flex-col gap-6">
139
+ <div className="flex items-start justify-between gap-4">
140
+ <div><h1 className="text-xl font-semibold text-ui-fg-base">PayPal Gateway By Easy Payment</h1></div>
141
+ </div>
142
+ <PayPalTabs />
143
+ {toast ? (
144
+ <div className="fixed right-6 top-6 z-50 rounded-md border border-ui-border-base bg-ui-bg-base px-4 py-3 text-sm shadow-lg" role="status" aria-live="polite">
145
+ <span className={toast.type === "success" ? "text-ui-fg-base" : "text-ui-fg-error"}>{toast.message}</span>
146
+ </div>
147
+ ) : null}
148
+ <SectionCard
149
+ title="Additional Settings"
150
+ description="These settings control checkout behavior and PayPal experience."
151
+ right={(
152
+ <div className="flex items-center gap-3">
153
+ <button type="button" onClick={onSave} disabled={saving || loading} className="rounded-md bg-ui-button-neutral px-4 py-2 text-sm font-medium text-ui-fg-on-color shadow-sm hover:opacity-90 disabled:opacity-60">
154
+ {saving ? "Saving..." : "Save settings"}
155
+ </button>
156
+ {loading ? <span className="text-sm text-ui-fg-subtle">Loading...</span> : null}
157
+ </div>
158
+ )}
159
+ >
160
+ <div className="divide-y divide-ui-border-base">
161
+ <FieldRow label="Payment action">
162
+ <select value={form.paymentAction} onChange={(e) => setForm((p) => ({ ...p, paymentAction: e.target.value as PaymentAction }))} className="w-full rounded-md border border-ui-border-base bg-ui-bg-base px-3 py-2 text-sm text-ui-fg-base outline-none focus:ring-2 focus:ring-ui-border-interactive">
163
+ <option value="capture">Capture</option>
164
+ <option value="authorize">Authorize</option>
165
+ </select>
166
+ </FieldRow>
167
+ <FieldRow label="Brand Name">
168
+ <input value={form.brandName} onChange={(e) => setForm((p) => ({ ...p, brandName: e.target.value }))} className="w-full rounded-md border border-ui-border-base bg-ui-bg-base px-3 py-2 text-sm text-ui-fg-base outline-none focus:ring-2 focus:ring-ui-border-interactive" placeholder="PayPal" />
169
+ </FieldRow>
170
+ <FieldRow label="Landing Page">
171
+ <select value={form.landingPage} onChange={(e) => setForm((p) => ({ ...p, landingPage: e.target.value as LandingPage }))} className="w-full rounded-md border border-ui-border-base bg-ui-bg-base px-3 py-2 text-sm text-ui-fg-base outline-none focus:ring-2 focus:ring-ui-border-interactive">
172
+ <option value="no_preference">No Preference</option>
173
+ <option value="login">Login</option>
174
+ <option value="billing">Billing</option>
175
+ </select>
176
+ </FieldRow>
177
+ <FieldRow label="Instant Payments">
178
+ <label className="inline-flex items-center gap-2">
179
+ <input type="checkbox" checked={form.requireInstantPayment} onChange={(e) => setForm((p) => ({ ...p, requireInstantPayment: e.target.checked }))} className="h-4 w-4 rounded border-ui-border-base" />
180
+ <span className="text-sm text-ui-fg-base">Require Instant Payment</span>
181
+ </label>
182
+ </FieldRow>
183
+ <FieldRow label="Send Item Details" hint="Include all line item details in the payment request to PayPal so that they can be seen from the PayPal transaction details page.">
184
+ <label className="inline-flex items-center gap-2">
185
+ <input type="checkbox" checked={form.sendItemDetails} onChange={(e) => setForm((p) => ({ ...p, sendItemDetails: e.target.checked }))} className="h-4 w-4 rounded border-ui-border-base" />
186
+ <span className="text-sm text-ui-fg-base">Send line item details to PayPal</span>
187
+ </label>
188
+ </FieldRow>
189
+ <FieldRow label="Invoice prefix">
190
+ <input value={form.invoicePrefix} onChange={(e) => setForm((p) => ({ ...p, invoicePrefix: e.target.value }))} className="w-full rounded-md border border-ui-border-base bg-ui-bg-base px-3 py-2 text-sm text-ui-fg-base outline-none focus:ring-2 focus:ring-ui-border-interactive" placeholder="WC-" />
191
+ </FieldRow>
192
+ <FieldRow label="Credit Card Statement Name">
193
+ <input value={form.creditCardStatementName} onChange={(e) => setForm((p) => ({ ...p, creditCardStatementName: e.target.value }))} className="w-full rounded-md border border-ui-border-base bg-ui-bg-base px-3 py-2 text-sm text-ui-fg-base outline-none focus:ring-2 focus:ring-ui-border-interactive" placeholder="PayPal" />
194
+ </FieldRow>
195
+ </div>
196
+ </SectionCard>
197
+ </div>
198
+ </div>
199
+ )
200
+ }