@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.
- package/LICENSE +21 -0
- package/README.md +195 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.js +55 -0
- package/dist/modules/paytree/index.d.ts +36 -0
- package/dist/modules/paytree/index.js +13 -0
- package/dist/modules/paytree/migrations/Migration20260617000000.d.ts +5 -0
- package/dist/modules/paytree/migrations/Migration20260617000000.js +54 -0
- package/dist/modules/paytree/migrations/Migration20260618000000.d.ts +5 -0
- package/dist/modules/paytree/migrations/Migration20260618000000.js +19 -0
- package/dist/modules/paytree/models/paytree-event-log.d.ts +25 -0
- package/dist/modules/paytree/models/paytree-event-log.js +29 -0
- package/dist/modules/paytree/models/paytree-setting.d.ts +23 -0
- package/dist/modules/paytree/models/paytree-setting.js +27 -0
- package/dist/modules/paytree/service.d.ts +60 -0
- package/dist/modules/paytree/service.js +47 -0
- package/dist/modules/paytree-payment/index.d.ts +8 -0
- package/dist/modules/paytree-payment/index.js +16 -0
- package/dist/modules/paytree-payment/lib/amount.d.ts +23 -0
- package/dist/modules/paytree-payment/lib/amount.js +40 -0
- package/dist/modules/paytree-payment/lib/audit.d.ts +26 -0
- package/dist/modules/paytree-payment/lib/audit.js +51 -0
- package/dist/modules/paytree-payment/lib/client.d.ts +28 -0
- package/dist/modules/paytree-payment/lib/client.js +81 -0
- package/dist/modules/paytree-payment/lib/constants.d.ts +54 -0
- package/dist/modules/paytree-payment/lib/constants.js +55 -0
- package/dist/modules/paytree-payment/lib/logger.d.ts +27 -0
- package/dist/modules/paytree-payment/lib/logger.js +73 -0
- package/dist/modules/paytree-payment/lib/methods.d.ts +69 -0
- package/dist/modules/paytree-payment/lib/methods.js +68 -0
- package/dist/modules/paytree-payment/lib/redact.d.ts +16 -0
- package/dist/modules/paytree-payment/lib/redact.js +60 -0
- package/dist/modules/paytree-payment/lib/settings-reader.d.ts +24 -0
- package/dist/modules/paytree-payment/lib/settings-reader.js +74 -0
- package/dist/modules/paytree-payment/lib/status-map.d.ts +26 -0
- package/dist/modules/paytree-payment/lib/status-map.js +66 -0
- package/dist/modules/paytree-payment/lib/types.d.ts +144 -0
- package/dist/modules/paytree-payment/lib/types.js +2 -0
- package/dist/modules/paytree-payment/service.d.ts +36 -0
- package/dist/modules/paytree-payment/service.js +369 -0
- package/docs/api-reference.md +121 -0
- package/docs/architecture.md +97 -0
- package/docs/configuration.md +62 -0
- package/docs/getting-started.md +111 -0
- package/docs/storefront-integration.md +85 -0
- package/integration/README.md +46 -0
- package/integration/admin/routes/paytree/api.ts +16 -0
- package/integration/admin/routes/paytree/method-icons.tsx +106 -0
- package/integration/admin/routes/paytree/page.tsx +18 -0
- package/integration/admin/routes/paytree/payments/page.tsx +240 -0
- package/integration/admin/routes/paytree/settings/page.tsx +214 -0
- package/integration/api/admin/paytree/events/route.ts +35 -0
- package/integration/api/admin/paytree/settings/route.ts +86 -0
- package/integration/api/hooks/paytree/route.ts +195 -0
- package/integration/api/store/paytree/methods/route.ts +28 -0
- package/package.json +57 -0
- package/storefront/README.md +25 -0
- package/storefront/components/payment-button.tsx +361 -0
- package/storefront/components/payment.tsx +356 -0
- package/storefront/components/paytree-method-icon.tsx +106 -0
- package/storefront/lib/paytree.ts +23 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { defineRouteConfig } from "@medusajs/admin-sdk"
|
|
2
|
+
import { CogSixTooth } from "@medusajs/icons"
|
|
3
|
+
import {
|
|
4
|
+
Badge,
|
|
5
|
+
Button,
|
|
6
|
+
Container,
|
|
7
|
+
Heading,
|
|
8
|
+
Input,
|
|
9
|
+
Label,
|
|
10
|
+
Switch,
|
|
11
|
+
Text,
|
|
12
|
+
toast,
|
|
13
|
+
} from "@medusajs/ui"
|
|
14
|
+
import { useEffect, useState } from "react"
|
|
15
|
+
import { MethodIcon } from "../method-icons"
|
|
16
|
+
import { api } from "../api"
|
|
17
|
+
|
|
18
|
+
type MethodConfig = {
|
|
19
|
+
method: string
|
|
20
|
+
enabled: boolean
|
|
21
|
+
label: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type Settings = {
|
|
25
|
+
base_url: string
|
|
26
|
+
log_request: boolean
|
|
27
|
+
log_response: boolean
|
|
28
|
+
methods: MethodConfig[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const SettingsPage = () => {
|
|
32
|
+
const [settings, setSettings] = useState<Settings>({
|
|
33
|
+
base_url: "",
|
|
34
|
+
log_request: false,
|
|
35
|
+
log_response: false,
|
|
36
|
+
methods: [],
|
|
37
|
+
})
|
|
38
|
+
const [keyConfigured, setKeyConfigured] = useState(false)
|
|
39
|
+
const [loading, setLoading] = useState(true)
|
|
40
|
+
const [saving, setSaving] = useState(false)
|
|
41
|
+
|
|
42
|
+
const updateMethod = (method: string, patch: Partial<MethodConfig>) =>
|
|
43
|
+
setSettings((s) => ({
|
|
44
|
+
...s,
|
|
45
|
+
methods: s.methods.map((m) =>
|
|
46
|
+
m.method === method ? { ...m, ...patch } : m
|
|
47
|
+
),
|
|
48
|
+
}))
|
|
49
|
+
|
|
50
|
+
const enabledCount = settings.methods.filter((m) => m.enabled).length
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
api("settings")
|
|
54
|
+
.then((d) => {
|
|
55
|
+
setSettings(d.settings)
|
|
56
|
+
setKeyConfigured(d.api_key_configured)
|
|
57
|
+
})
|
|
58
|
+
.catch((e) => toast.error(e.message))
|
|
59
|
+
.finally(() => setLoading(false))
|
|
60
|
+
}, [])
|
|
61
|
+
|
|
62
|
+
const save = async () => {
|
|
63
|
+
setSaving(true)
|
|
64
|
+
try {
|
|
65
|
+
const d = await api("settings", {
|
|
66
|
+
method: "POST",
|
|
67
|
+
body: JSON.stringify(settings),
|
|
68
|
+
})
|
|
69
|
+
setSettings(d.settings)
|
|
70
|
+
toast.success("Settings saved")
|
|
71
|
+
} catch (e: any) {
|
|
72
|
+
toast.error(e.message)
|
|
73
|
+
} finally {
|
|
74
|
+
setSaving(false)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div className="flex flex-col gap-y-3">
|
|
80
|
+
<Container className="divide-y p-0">
|
|
81
|
+
<div className="flex items-center justify-between px-6 py-4">
|
|
82
|
+
<Heading level="h2">Paytree settings</Heading>
|
|
83
|
+
<Badge color={keyConfigured ? "green" : "red"} size="2xsmall">
|
|
84
|
+
{keyConfigured ? "API key configured" : "API key missing"}
|
|
85
|
+
</Badge>
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
<div className="flex flex-col gap-6 px-6 py-6">
|
|
89
|
+
<div className="flex flex-col gap-2">
|
|
90
|
+
<Label htmlFor="base_url">Paytree API endpoint</Label>
|
|
91
|
+
<Input
|
|
92
|
+
id="base_url"
|
|
93
|
+
placeholder="https://api.paytree.tech"
|
|
94
|
+
value={settings.base_url}
|
|
95
|
+
disabled={loading}
|
|
96
|
+
onChange={(e) =>
|
|
97
|
+
setSettings({ ...settings, base_url: e.target.value })
|
|
98
|
+
}
|
|
99
|
+
/>
|
|
100
|
+
<Text size="small" className="text-ui-fg-subtle">
|
|
101
|
+
Requests are sent here with your API key. Any https endpoint is
|
|
102
|
+
accepted. The API key itself is set with the PAYTREE_API_KEY
|
|
103
|
+
environment variable, not here.
|
|
104
|
+
</Text>
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
<div className="flex flex-col gap-3">
|
|
108
|
+
<div className="flex items-center justify-between">
|
|
109
|
+
<div className="flex flex-col">
|
|
110
|
+
<Label>Payment methods</Label>
|
|
111
|
+
<Text size="small" className="text-ui-fg-subtle">
|
|
112
|
+
Choose which methods customers can select at checkout, and
|
|
113
|
+
rename any of them. Disabled methods are never shown.
|
|
114
|
+
</Text>
|
|
115
|
+
</div>
|
|
116
|
+
<Badge size="2xsmall" color={enabledCount > 0 ? "green" : "orange"}>
|
|
117
|
+
{enabledCount} enabled
|
|
118
|
+
</Badge>
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
<div className="flex flex-col divide-y rounded-lg border border-ui-border-base">
|
|
122
|
+
{settings.methods.map((m) => (
|
|
123
|
+
<div
|
|
124
|
+
key={m.method}
|
|
125
|
+
className="flex items-center gap-3 px-3 py-2.5"
|
|
126
|
+
>
|
|
127
|
+
<div className="flex h-8 w-8 shrink-0 items-center justify-center text-ui-fg-subtle">
|
|
128
|
+
<MethodIcon method={m.method} />
|
|
129
|
+
</div>
|
|
130
|
+
<Input
|
|
131
|
+
aria-label={`${m.method} label`}
|
|
132
|
+
className="flex-1"
|
|
133
|
+
value={m.label}
|
|
134
|
+
disabled={loading || !m.enabled}
|
|
135
|
+
onChange={(e) =>
|
|
136
|
+
updateMethod(m.method, { label: e.target.value })
|
|
137
|
+
}
|
|
138
|
+
/>
|
|
139
|
+
<Switch
|
|
140
|
+
checked={m.enabled}
|
|
141
|
+
disabled={loading}
|
|
142
|
+
onCheckedChange={(v) => updateMethod(m.method, { enabled: v })}
|
|
143
|
+
/>
|
|
144
|
+
</div>
|
|
145
|
+
))}
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
</Container>
|
|
150
|
+
|
|
151
|
+
<Container className="divide-y p-0">
|
|
152
|
+
<div className="px-6 py-4">
|
|
153
|
+
<Heading level="h2">Logging</Heading>
|
|
154
|
+
</div>
|
|
155
|
+
|
|
156
|
+
<div className="flex flex-col gap-3 px-6 py-6">
|
|
157
|
+
<Text size="small" className="text-ui-fg-subtle">
|
|
158
|
+
Diagnostic logging for Paytree traffic. Sensitive data is always
|
|
159
|
+
masked.
|
|
160
|
+
</Text>
|
|
161
|
+
|
|
162
|
+
<div className="flex flex-col divide-y rounded-lg border border-ui-border-base">
|
|
163
|
+
<div className="flex items-center justify-between gap-4 px-4 py-3">
|
|
164
|
+
<div className="flex flex-col">
|
|
165
|
+
<Label htmlFor="log_request">Log outgoing requests</Label>
|
|
166
|
+
<Text size="small" className="text-ui-fg-subtle">
|
|
167
|
+
Records request bodies sent to Paytree. Card data, the API key,
|
|
168
|
+
and personal details are always masked.
|
|
169
|
+
</Text>
|
|
170
|
+
</div>
|
|
171
|
+
<Switch
|
|
172
|
+
id="log_request"
|
|
173
|
+
checked={settings.log_request}
|
|
174
|
+
onCheckedChange={(v) =>
|
|
175
|
+
setSettings({ ...settings, log_request: v })
|
|
176
|
+
}
|
|
177
|
+
/>
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
<div className="flex items-center justify-between gap-4 px-4 py-3">
|
|
181
|
+
<div className="flex flex-col">
|
|
182
|
+
<Label htmlFor="log_response">Log responses</Label>
|
|
183
|
+
<Text size="small" className="text-ui-fg-subtle">
|
|
184
|
+
Records responses received from Paytree, with sensitive fields
|
|
185
|
+
masked.
|
|
186
|
+
</Text>
|
|
187
|
+
</div>
|
|
188
|
+
<Switch
|
|
189
|
+
id="log_response"
|
|
190
|
+
checked={settings.log_response}
|
|
191
|
+
onCheckedChange={(v) =>
|
|
192
|
+
setSettings({ ...settings, log_response: v })
|
|
193
|
+
}
|
|
194
|
+
/>
|
|
195
|
+
</div>
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
</Container>
|
|
199
|
+
|
|
200
|
+
<div className="flex justify-end">
|
|
201
|
+
<Button onClick={save} isLoading={saving} disabled={loading}>
|
|
202
|
+
Save changes
|
|
203
|
+
</Button>
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export const config = defineRouteConfig({
|
|
210
|
+
label: "Settings",
|
|
211
|
+
icon: CogSixTooth,
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
export default SettingsPage
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http";
|
|
2
|
+
import { PAYTREE_MODULE } from "@paytree/medusa-payment-paytree";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* GET audit-log events for the admin UI. Supports filtering by session id,
|
|
6
|
+
* Paytree intent id, and event type, plus simple pagination.
|
|
7
|
+
*/
|
|
8
|
+
export async function GET(
|
|
9
|
+
req: MedusaRequest,
|
|
10
|
+
res: MedusaResponse,
|
|
11
|
+
): Promise<void> {
|
|
12
|
+
const paytree: any = req.scope.resolve(PAYTREE_MODULE);
|
|
13
|
+
|
|
14
|
+
const limit = Math.min(Number(req.query.limit ?? 50), 200);
|
|
15
|
+
const offset = Number(req.query.offset ?? 0);
|
|
16
|
+
|
|
17
|
+
const filters: Record<string, unknown> = {};
|
|
18
|
+
if (req.query.session_id) {
|
|
19
|
+
filters.session_id = String(req.query.session_id);
|
|
20
|
+
}
|
|
21
|
+
if (req.query.paytree_id) {
|
|
22
|
+
filters.paytree_id = String(req.query.paytree_id);
|
|
23
|
+
}
|
|
24
|
+
if (req.query.event) {
|
|
25
|
+
filters.event = String(req.query.event);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const [events, count] = await paytree.listAndCountPaytreeEventLogs(filters, {
|
|
29
|
+
take: limit,
|
|
30
|
+
skip: offset,
|
|
31
|
+
order: { created_at: "DESC" },
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
res.json({ events, count, limit, offset });
|
|
35
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http";
|
|
2
|
+
import { MedusaError } from "@medusajs/framework/utils";
|
|
3
|
+
import {
|
|
4
|
+
PAYTREE_MODULE,
|
|
5
|
+
DEFAULT_BASE_URL,
|
|
6
|
+
normalizeMethods,
|
|
7
|
+
resolveMethods,
|
|
8
|
+
type StoredMethodConfig,
|
|
9
|
+
} from "@paytree/medusa-payment-paytree";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* GET current settings. The API key is never returned — it lives only in the
|
|
13
|
+
* PAYTREE_API_KEY environment variable. We return whether it is configured so
|
|
14
|
+
* the UI can show a status, without exposing the value.
|
|
15
|
+
*/
|
|
16
|
+
export async function GET(
|
|
17
|
+
req: MedusaRequest,
|
|
18
|
+
res: MedusaResponse,
|
|
19
|
+
): Promise<void> {
|
|
20
|
+
const paytree: any = req.scope.resolve(PAYTREE_MODULE);
|
|
21
|
+
const settings = await paytree.getSettings({ base_url: DEFAULT_BASE_URL });
|
|
22
|
+
res.json({
|
|
23
|
+
settings: {
|
|
24
|
+
base_url: settings.base_url,
|
|
25
|
+
log_request: settings.log_request,
|
|
26
|
+
log_response: settings.log_response,
|
|
27
|
+
methods: resolveMethods(settings.methods as StoredMethodConfig[] | null),
|
|
28
|
+
},
|
|
29
|
+
api_key_configured: Boolean(process.env.PAYTREE_API_KEY),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface UpdateBody {
|
|
34
|
+
base_url?: string;
|
|
35
|
+
log_request?: boolean;
|
|
36
|
+
log_response?: boolean;
|
|
37
|
+
methods?: StoredMethodConfig[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Update settings. The base URL accepts any endpoint (no host restriction);
|
|
42
|
+
* we only require a well-formed https URL so the API key is never sent over
|
|
43
|
+
* cleartext http.
|
|
44
|
+
*/
|
|
45
|
+
export async function POST(
|
|
46
|
+
req: MedusaRequest,
|
|
47
|
+
res: MedusaResponse,
|
|
48
|
+
): Promise<void> {
|
|
49
|
+
const body = (req.body ?? {}) as UpdateBody;
|
|
50
|
+
|
|
51
|
+
const baseUrl = (body.base_url ?? "").trim();
|
|
52
|
+
let parsed: URL;
|
|
53
|
+
try {
|
|
54
|
+
parsed = new URL(baseUrl);
|
|
55
|
+
} catch {
|
|
56
|
+
throw new MedusaError(
|
|
57
|
+
MedusaError.Types.INVALID_DATA,
|
|
58
|
+
"Enter a valid URL, e.g. https://api.paytree.tech",
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
if (parsed.protocol !== "https:") {
|
|
62
|
+
throw new MedusaError(
|
|
63
|
+
MedusaError.Types.INVALID_DATA,
|
|
64
|
+
"The base URL must use https so the API key is never sent in cleartext.",
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const methods = normalizeMethods(body.methods);
|
|
69
|
+
|
|
70
|
+
const paytree: any = req.scope.resolve(PAYTREE_MODULE);
|
|
71
|
+
const saved = await paytree.upsertSettings({
|
|
72
|
+
base_url: baseUrl.replace(/\/+$/, ""),
|
|
73
|
+
log_request: Boolean(body.log_request),
|
|
74
|
+
log_response: Boolean(body.log_response),
|
|
75
|
+
methods,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
res.json({
|
|
79
|
+
settings: {
|
|
80
|
+
base_url: saved.base_url,
|
|
81
|
+
log_request: saved.log_request,
|
|
82
|
+
log_response: saved.log_response,
|
|
83
|
+
methods: resolveMethods(saved.methods as StoredMethodConfig[] | null),
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http";
|
|
2
|
+
import { ContainerRegistrationKeys, Modules } from "@medusajs/framework/utils";
|
|
3
|
+
import {
|
|
4
|
+
processPaymentWorkflow,
|
|
5
|
+
refundPaymentWorkflow,
|
|
6
|
+
} from "@medusajs/medusa/core-flows";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
PAYTREE_MODULE,
|
|
10
|
+
PaytreeClient,
|
|
11
|
+
PaytreeLogger,
|
|
12
|
+
callbackOutcome,
|
|
13
|
+
toSessionStatus,
|
|
14
|
+
newlyRefundedAmount,
|
|
15
|
+
fromPaytreeAmount,
|
|
16
|
+
AuditEvent,
|
|
17
|
+
DEFAULT_BASE_URL,
|
|
18
|
+
} from "@paytree/medusa-payment-paytree";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Paytree calls this GET endpoint when a payment status changes, substituting
|
|
22
|
+
* the {payment_intent_id} and {transaction_id} tags in the notification URL.
|
|
23
|
+
*
|
|
24
|
+
* The callback body is NOT trusted. We take only the ids and fetch the
|
|
25
|
+
* authoritative status from Paytree using our secret API key — that lookup is
|
|
26
|
+
* the trust mechanism (no signature is involved). The handler is idempotent,
|
|
27
|
+
* so repeated or spoofed pings cause at most a harmless re-read.
|
|
28
|
+
*
|
|
29
|
+
* Always returns 200 on a handled event. A non-200 triggers Paytree's retry
|
|
30
|
+
* schedule, which we reserve for genuine internal failures.
|
|
31
|
+
*/
|
|
32
|
+
export async function GET(
|
|
33
|
+
req: MedusaRequest,
|
|
34
|
+
res: MedusaResponse,
|
|
35
|
+
): Promise<void> {
|
|
36
|
+
const logger = new PaytreeLogger(req.scope);
|
|
37
|
+
const transactionId = String(req.query.transaction_id ?? "");
|
|
38
|
+
const paytreeId = String(req.query.payment_intent_id ?? "");
|
|
39
|
+
const c = { sessionId: transactionId, paytreeId };
|
|
40
|
+
|
|
41
|
+
const paytree: any = req.scope.resolve(PAYTREE_MODULE);
|
|
42
|
+
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY);
|
|
43
|
+
|
|
44
|
+
logger.info("callback received", c);
|
|
45
|
+
await safeAudit(paytree, {
|
|
46
|
+
event: AuditEvent.WEBHOOK_RECEIVED,
|
|
47
|
+
session_id: transactionId,
|
|
48
|
+
paytree_id: paytreeId,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
if (!transactionId && !paytreeId) {
|
|
52
|
+
// Nothing to correlate; acknowledge so Paytree doesn't retry forever.
|
|
53
|
+
res.sendStatus(200);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const settings = await paytree.getSettings({ base_url: DEFAULT_BASE_URL });
|
|
59
|
+
const apiKey = process.env.PAYTREE_API_KEY || "";
|
|
60
|
+
const client = new PaytreeClient(
|
|
61
|
+
{
|
|
62
|
+
apiKey,
|
|
63
|
+
baseUrl: settings.base_url || DEFAULT_BASE_URL,
|
|
64
|
+
logRequest: Boolean(settings.log_request),
|
|
65
|
+
logResponse: Boolean(settings.log_response),
|
|
66
|
+
},
|
|
67
|
+
logger,
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
// Authoritative lookup by our own ref (avoids the create-id vs UUID issue).
|
|
71
|
+
const intent = transactionId
|
|
72
|
+
? await client.findByTransactionRef(transactionId, c)
|
|
73
|
+
: await client.getPaymentIntent(paytreeId, c);
|
|
74
|
+
|
|
75
|
+
if (!intent) {
|
|
76
|
+
logger.warn("callback intent not found in Paytree", c);
|
|
77
|
+
res.sendStatus(200);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const payment = intent.payment?.[0];
|
|
82
|
+
const status = payment?.status ?? "pending";
|
|
83
|
+
const sessionId = intent.transaction_ref || transactionId;
|
|
84
|
+
const outcome = callbackOutcome(status);
|
|
85
|
+
|
|
86
|
+
if (outcome.kind === "action") {
|
|
87
|
+
await processPaymentWorkflow(req.scope).run({
|
|
88
|
+
input: {
|
|
89
|
+
action: outcome.action,
|
|
90
|
+
data: {
|
|
91
|
+
session_id: sessionId,
|
|
92
|
+
amount: fromPaytreeAmount(payment?.amount ?? intent.amount),
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
} else if (outcome.kind === "refund") {
|
|
97
|
+
await reflectRefund(
|
|
98
|
+
req.scope,
|
|
99
|
+
query,
|
|
100
|
+
sessionId,
|
|
101
|
+
payment?.refunded ?? "0",
|
|
102
|
+
c,
|
|
103
|
+
logger,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
await safeAudit(paytree, {
|
|
108
|
+
event: AuditEvent.WEBHOOK_PROCESSED,
|
|
109
|
+
session_id: sessionId,
|
|
110
|
+
paytree_id: paytreeId,
|
|
111
|
+
status,
|
|
112
|
+
detail: { medusa_status: toSessionStatus(status), outcome: outcome.kind },
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
res.sendStatus(200);
|
|
116
|
+
} catch (e) {
|
|
117
|
+
logger.error("callback processing failed", e, c);
|
|
118
|
+
await safeAudit(paytree, {
|
|
119
|
+
event: AuditEvent.ERROR,
|
|
120
|
+
session_id: transactionId,
|
|
121
|
+
paytree_id: paytreeId,
|
|
122
|
+
error: e instanceof Error ? e.message : String(e),
|
|
123
|
+
detail: { phase: AuditEvent.WEBHOOK_PROCESSED },
|
|
124
|
+
});
|
|
125
|
+
// Signal failure so Paytree retries the callback.
|
|
126
|
+
res.sendStatus(500);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Record an externally-initiated Paytree refund in Medusa. Medusa has no
|
|
132
|
+
* "refunded" webhook action, so we drive the refund workflow directly. The
|
|
133
|
+
* workflow calls the provider's refundPayment, which verifies the refund
|
|
134
|
+
* against Paytree before recording — so this is safe to call idempotently.
|
|
135
|
+
*/
|
|
136
|
+
async function reflectRefund(
|
|
137
|
+
scope: any,
|
|
138
|
+
query: any,
|
|
139
|
+
sessionId: string,
|
|
140
|
+
paytreeRefundedTotal: string,
|
|
141
|
+
c: { sessionId?: string; paytreeId?: string },
|
|
142
|
+
logger: PaytreeLogger,
|
|
143
|
+
): Promise<void> {
|
|
144
|
+
// Find the Medusa payment + how much is already refunded there.
|
|
145
|
+
const { data: sessions } = await query.graph({
|
|
146
|
+
entity: "payment_session",
|
|
147
|
+
fields: [
|
|
148
|
+
"id",
|
|
149
|
+
"payment_collection.payments.id",
|
|
150
|
+
"payment_collection.payments.amount",
|
|
151
|
+
"payment_collection.payments.refunds.amount",
|
|
152
|
+
],
|
|
153
|
+
filters: { id: sessionId },
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const payment = sessions?.[0]?.payment_collection?.payments?.[0];
|
|
157
|
+
if (!payment) {
|
|
158
|
+
logger.warn("refund callback: no Medusa payment found for session", c);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const alreadyRefunded = (payment.refunds ?? []).reduce(
|
|
163
|
+
(sum: number, r: any) => sum + Number(r.amount ?? 0),
|
|
164
|
+
0,
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
const delta = newlyRefundedAmount(paytreeRefundedTotal, alreadyRefunded);
|
|
168
|
+
if (!delta) {
|
|
169
|
+
logger.info("refund callback: nothing new to record", c);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
await refundPaymentWorkflow(scope).run({
|
|
174
|
+
input: {
|
|
175
|
+
payment_id: payment.id,
|
|
176
|
+
amount: fromPaytreeAmount(delta),
|
|
177
|
+
note: "Reflected from Paytree back-office refund",
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
logger.info(`recorded refund of ${delta}`, c);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function safeAudit(paytree: any, record: any): Promise<void> {
|
|
184
|
+
try {
|
|
185
|
+
await paytree.createPaytreeEventLogs([
|
|
186
|
+
{
|
|
187
|
+
id: `ptlog_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
188
|
+
...record,
|
|
189
|
+
detail: record.detail ?? null,
|
|
190
|
+
},
|
|
191
|
+
]);
|
|
192
|
+
} catch {
|
|
193
|
+
// Audit write must never break the callback.
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http";
|
|
2
|
+
import {
|
|
3
|
+
PAYTREE_MODULE,
|
|
4
|
+
DEFAULT_BASE_URL,
|
|
5
|
+
resolveMethods,
|
|
6
|
+
type StoredMethodConfig,
|
|
7
|
+
} from "@paytree/medusa-payment-paytree";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Public list of the Paytree payment methods the admin has enabled, with their
|
|
11
|
+
* (possibly overridden) display labels. The storefront renders this as the
|
|
12
|
+
* method picker shown after Paytree is selected at checkout. Only enabled
|
|
13
|
+
* methods are returned, and never any secret/operational config.
|
|
14
|
+
*/
|
|
15
|
+
export async function GET(
|
|
16
|
+
req: MedusaRequest,
|
|
17
|
+
res: MedusaResponse,
|
|
18
|
+
): Promise<void> {
|
|
19
|
+
const paytree: any = req.scope.resolve(PAYTREE_MODULE);
|
|
20
|
+
const settings = await paytree.getSettings({ base_url: DEFAULT_BASE_URL });
|
|
21
|
+
const methods = resolveMethods(
|
|
22
|
+
settings.methods as StoredMethodConfig[] | null,
|
|
23
|
+
)
|
|
24
|
+
.filter((m) => m.enabled)
|
|
25
|
+
.map((m) => ({ method: m.method, label: m.label }));
|
|
26
|
+
|
|
27
|
+
res.json({ methods });
|
|
28
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@paytree/medusa-payment-paytree",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Paytree hosted-payment provider for Medusa 2.0.",
|
|
5
|
+
"author": "Paytree",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"main": "dist/index.js",
|
|
8
|
+
"types": "dist/index.d.ts",
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"!dist/**/__tests__",
|
|
12
|
+
"integration",
|
|
13
|
+
"storefront",
|
|
14
|
+
"docs",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"keywords": [
|
|
19
|
+
"medusa",
|
|
20
|
+
"medusa-plugin",
|
|
21
|
+
"medusa-plugin-payment",
|
|
22
|
+
"payment",
|
|
23
|
+
"payment-provider",
|
|
24
|
+
"paytree",
|
|
25
|
+
"ecommerce"
|
|
26
|
+
],
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "https://github.com/paytree/medusa-payment-paytree"
|
|
30
|
+
},
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=20"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "rimraf dist && tsc --build tsconfig.json",
|
|
36
|
+
"watch": "tsc --watch",
|
|
37
|
+
"test": "jest --passWithNoTests src",
|
|
38
|
+
"prepublishOnly": "npm run build"
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"@medusajs/framework": "2.13.6"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@medusajs/framework": "2.13.6",
|
|
45
|
+
"@mikro-orm/migrations": "6.6.12",
|
|
46
|
+
"@swc/core": "1.5.7",
|
|
47
|
+
"@swc/jest": "^0.2.39",
|
|
48
|
+
"@types/jest": "^29.5.14",
|
|
49
|
+
"@types/node": "^20.19.25",
|
|
50
|
+
"jest": "^29.7.0",
|
|
51
|
+
"rimraf": "^5.0.10",
|
|
52
|
+
"typescript": "^5.9.3"
|
|
53
|
+
},
|
|
54
|
+
"publishConfig": {
|
|
55
|
+
"access": "public"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Paytree storefront reference components
|
|
2
|
+
|
|
3
|
+
Reference Next.js components for the Paytree checkout experience, targeting the
|
|
4
|
+
[Medusa Next.js starter](https://github.com/medusajs/nextjs-starter-medusa)
|
|
5
|
+
layout. These are **copy-paste sources**, not a compiled/importable module — the
|
|
6
|
+
storefront owns its own build, styling, and import aliases.
|
|
7
|
+
|
|
8
|
+
## Contents
|
|
9
|
+
|
|
10
|
+
| File | Purpose | Copy to |
|
|
11
|
+
| ---- | ------- | ------- |
|
|
12
|
+
| `lib/paytree.ts` | `listPaytreeMethods()` server action (`GET /store/paytree/methods`). | `src/lib/data/paytree.ts` |
|
|
13
|
+
| `components/paytree-method-icon.tsx` | SVG icons for each method in the picker. | `src/modules/checkout/components/paytree-method-icon/index.tsx` |
|
|
14
|
+
| `components/payment.tsx` | Payment step with the Paytree method picker. | `src/modules/checkout/components/payment/index.tsx` |
|
|
15
|
+
| `components/payment-button.tsx` | Place-order button that redirects to the hosted page. | `src/modules/checkout/components/payment-button/index.tsx` |
|
|
16
|
+
|
|
17
|
+
## Peer requirements
|
|
18
|
+
|
|
19
|
+
These components assume the starter's dependencies are present:
|
|
20
|
+
`@medusajs/ui`, `@medusajs/icons`, `@medusajs/types`, `@headlessui/react`,
|
|
21
|
+
`next`, `react`, and the starter's `@lib`/`@modules` path aliases.
|
|
22
|
+
|
|
23
|
+
See [`../docs/storefront-integration.md`](../docs/storefront-integration.md) for
|
|
24
|
+
the full wiring guide, including the shared-file edits to `constants.tsx` and
|
|
25
|
+
`cart.ts`.
|