@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,369 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.PaytreePaymentProviderService = void 0;
|
|
4
|
+
const utils_1 = require("@medusajs/framework/utils");
|
|
5
|
+
const client_1 = require("./lib/client");
|
|
6
|
+
const settings_reader_1 = require("./lib/settings-reader");
|
|
7
|
+
const audit_1 = require("./lib/audit");
|
|
8
|
+
const logger_1 = require("./lib/logger");
|
|
9
|
+
const amount_1 = require("./lib/amount");
|
|
10
|
+
const status_map_1 = require("./lib/status-map");
|
|
11
|
+
const constants_1 = require("./lib/constants");
|
|
12
|
+
class PaytreePaymentProviderService extends utils_1.AbstractPaymentProvider {
|
|
13
|
+
static validateOptions(options) {
|
|
14
|
+
if (!options.apiKey) {
|
|
15
|
+
throw new utils_1.MedusaError(utils_1.MedusaError.Types.INVALID_DATA, "Paytree provider requires an `apiKey` (set PAYTREE_API_KEY).");
|
|
16
|
+
}
|
|
17
|
+
if (!options.backendUrl) {
|
|
18
|
+
throw new utils_1.MedusaError(utils_1.MedusaError.Types.INVALID_DATA, "Paytree provider requires a `backendUrl` to build the callback URL.");
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
constructor(container, options) {
|
|
22
|
+
super(container, options);
|
|
23
|
+
this.container_ = container;
|
|
24
|
+
this.options_ = options;
|
|
25
|
+
this.logger_ = new logger_1.PaytreeLogger(container);
|
|
26
|
+
this.settings_ = new settings_reader_1.SettingsReader(container, options);
|
|
27
|
+
this.audit_ = new audit_1.AuditWriter(container);
|
|
28
|
+
}
|
|
29
|
+
/** Build a client using the current effective settings (live base URL + toggles). */
|
|
30
|
+
async client() {
|
|
31
|
+
const settings = await this.settings_.get();
|
|
32
|
+
return new client_1.PaytreeClient({
|
|
33
|
+
apiKey: this.options_.apiKey,
|
|
34
|
+
baseUrl: settings.baseUrl,
|
|
35
|
+
logRequest: settings.logRequest,
|
|
36
|
+
logResponse: settings.logResponse,
|
|
37
|
+
}, this.logger_);
|
|
38
|
+
}
|
|
39
|
+
/** Notification URL Paytree will call back, with its substitution tags. */
|
|
40
|
+
notificationUrl() {
|
|
41
|
+
const base = this.options_.backendUrl.replace(/\/+$/, "");
|
|
42
|
+
return (`${base}/hooks/paytree` +
|
|
43
|
+
`?payment_intent_id={payment_intent_id}&transaction_id={transaction_id}`);
|
|
44
|
+
}
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// initiate
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
async initiatePayment(input) {
|
|
49
|
+
const data = (input.data ?? {});
|
|
50
|
+
const context = input.context ?? {};
|
|
51
|
+
// Our stable correlation key = the Medusa payment session id when present.
|
|
52
|
+
const transactionRef = data.session_id ||
|
|
53
|
+
context.idempotency_key ||
|
|
54
|
+
`paytree_${Date.now()}`;
|
|
55
|
+
const c = { sessionId: transactionRef };
|
|
56
|
+
const customer = data.customer ?? {};
|
|
57
|
+
const email = customer.email || context?.customer?.email;
|
|
58
|
+
const firstName = customer.first_name || context?.customer?.first_name;
|
|
59
|
+
const lastName = customer.last_name || context?.customer?.last_name;
|
|
60
|
+
const country = data.address?.country;
|
|
61
|
+
const missing = [];
|
|
62
|
+
if (!email)
|
|
63
|
+
missing.push("customer.email");
|
|
64
|
+
if (!firstName)
|
|
65
|
+
missing.push("customer.first_name");
|
|
66
|
+
if (!lastName)
|
|
67
|
+
missing.push("customer.last_name");
|
|
68
|
+
if (!country)
|
|
69
|
+
missing.push("address.country");
|
|
70
|
+
if (!data.session?.ip_address)
|
|
71
|
+
missing.push("session.ip_address");
|
|
72
|
+
if (!data.session?.user_agent)
|
|
73
|
+
missing.push("session.user_agent");
|
|
74
|
+
if (missing.length) {
|
|
75
|
+
throw new utils_1.MedusaError(utils_1.MedusaError.Types.INVALID_DATA, `Paytree requires the storefront to pass these fields in the payment ` +
|
|
76
|
+
`session data: ${missing.join(", ")}. See the integration README.`);
|
|
77
|
+
}
|
|
78
|
+
const amount = (0, amount_1.toPaytreeAmount)(input.amount);
|
|
79
|
+
const currency = (input.currency_code || "").toUpperCase();
|
|
80
|
+
// Paytree rejects blank strings with a 400, so optional fields are only
|
|
81
|
+
// included when they actually have a value. Country is always required.
|
|
82
|
+
const clean = (v) => {
|
|
83
|
+
const s = typeof v === "string" ? v.trim() : "";
|
|
84
|
+
return s.length ? s : undefined;
|
|
85
|
+
};
|
|
86
|
+
const customerPayload = {
|
|
87
|
+
first_name: firstName,
|
|
88
|
+
last_name: lastName,
|
|
89
|
+
email: email,
|
|
90
|
+
};
|
|
91
|
+
const phone = clean(customer.phone);
|
|
92
|
+
if (phone) {
|
|
93
|
+
customerPayload.phone = phone;
|
|
94
|
+
}
|
|
95
|
+
const addressPayload = {
|
|
96
|
+
country: country,
|
|
97
|
+
};
|
|
98
|
+
for (const key of ["street", "city", "state", "zip"]) {
|
|
99
|
+
const value = clean(data.address?.[key]);
|
|
100
|
+
if (value) {
|
|
101
|
+
addressPayload[key] = value;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const body = {
|
|
105
|
+
transaction_ref: transactionRef,
|
|
106
|
+
client_ref: context?.customer?.id || email,
|
|
107
|
+
amount,
|
|
108
|
+
amount_currency: currency,
|
|
109
|
+
method: data.method || this.options_.defaultMethod || "card",
|
|
110
|
+
customer: customerPayload,
|
|
111
|
+
address: addressPayload,
|
|
112
|
+
session: {
|
|
113
|
+
ip_address: data.session.ip_address,
|
|
114
|
+
user_agent: data.session.user_agent,
|
|
115
|
+
},
|
|
116
|
+
callback: {
|
|
117
|
+
notification_url: this.notificationUrl(),
|
|
118
|
+
return_url: data.return_url || `${this.options_.backendUrl}`,
|
|
119
|
+
cancel_url: clean(data.cancel_url),
|
|
120
|
+
},
|
|
121
|
+
metadata: { medusa_session_id: transactionRef },
|
|
122
|
+
order_items: data.order_items,
|
|
123
|
+
};
|
|
124
|
+
this.logger_.info(`initiating payment for ${amount} ${currency}`, c);
|
|
125
|
+
try {
|
|
126
|
+
const cl = await this.client();
|
|
127
|
+
const res = await cl.createPaymentIntent(body, c);
|
|
128
|
+
const sessionData = {
|
|
129
|
+
...data,
|
|
130
|
+
transaction_ref: transactionRef,
|
|
131
|
+
paytree_id: String(res.id),
|
|
132
|
+
payment_link: res.payment_link,
|
|
133
|
+
status: constants_1.PaytreeStatus.PENDING,
|
|
134
|
+
};
|
|
135
|
+
await this.audit_.write({
|
|
136
|
+
event: constants_1.AuditEvent.INITIATE,
|
|
137
|
+
session_id: transactionRef,
|
|
138
|
+
paytree_id: String(res.id),
|
|
139
|
+
status: constants_1.PaytreeStatus.PENDING,
|
|
140
|
+
amount,
|
|
141
|
+
currency,
|
|
142
|
+
detail: { payment_link: res.payment_link },
|
|
143
|
+
});
|
|
144
|
+
return {
|
|
145
|
+
id: transactionRef,
|
|
146
|
+
data: sessionData,
|
|
147
|
+
status: utils_1.PaymentSessionStatus.PENDING,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
catch (e) {
|
|
151
|
+
this.logger_.error("initiatePayment failed", e, c);
|
|
152
|
+
await this.audit_.write({
|
|
153
|
+
event: constants_1.AuditEvent.ERROR,
|
|
154
|
+
session_id: transactionRef,
|
|
155
|
+
error: e instanceof Error ? e.message : String(e),
|
|
156
|
+
detail: {
|
|
157
|
+
phase: constants_1.AuditEvent.INITIATE,
|
|
158
|
+
http_status: e?.paytreeStatus ?? null,
|
|
159
|
+
request: body,
|
|
160
|
+
response: e?.paytreeResponse ?? null,
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
throw e;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
// status reads
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
/** Read the live intent from Paytree by transaction_ref. */
|
|
170
|
+
async readIntent(data, c) {
|
|
171
|
+
const ref = data.transaction_ref || data.session_id;
|
|
172
|
+
if (!ref) {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
const cl = await this.client();
|
|
176
|
+
return cl.findByTransactionRef(ref, c);
|
|
177
|
+
}
|
|
178
|
+
/** Roll up the latest status across the intent's payment objects. */
|
|
179
|
+
latestStatus(intent) {
|
|
180
|
+
const payments = intent?.payment ?? [];
|
|
181
|
+
if (!payments.length) {
|
|
182
|
+
return constants_1.PaytreeStatus.PENDING;
|
|
183
|
+
}
|
|
184
|
+
// Prefer a terminal status if any payment reached it.
|
|
185
|
+
const statuses = payments.map((p) => p.status);
|
|
186
|
+
for (const s of [
|
|
187
|
+
constants_1.PaytreeStatus.REFUNDED,
|
|
188
|
+
constants_1.PaytreeStatus.PARTIAL_REFUNDED,
|
|
189
|
+
constants_1.PaytreeStatus.SUCCESS,
|
|
190
|
+
constants_1.PaytreeStatus.AUTHORIZED,
|
|
191
|
+
constants_1.PaytreeStatus.FAILED,
|
|
192
|
+
constants_1.PaytreeStatus.CANCELED,
|
|
193
|
+
constants_1.PaytreeStatus.EXPIRED,
|
|
194
|
+
]) {
|
|
195
|
+
if (statuses.includes(s)) {
|
|
196
|
+
return s;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return statuses[0];
|
|
200
|
+
}
|
|
201
|
+
async getPaymentStatus(input) {
|
|
202
|
+
const data = (input.data ?? {});
|
|
203
|
+
const c = {
|
|
204
|
+
sessionId: data.transaction_ref,
|
|
205
|
+
paytreeId: data.paytree_id,
|
|
206
|
+
};
|
|
207
|
+
try {
|
|
208
|
+
const intent = await this.readIntent(data, c);
|
|
209
|
+
const status = this.latestStatus(intent);
|
|
210
|
+
await this.audit_.write({
|
|
211
|
+
event: constants_1.AuditEvent.STATUS_READ,
|
|
212
|
+
session_id: data.transaction_ref,
|
|
213
|
+
paytree_id: data.paytree_id,
|
|
214
|
+
status,
|
|
215
|
+
});
|
|
216
|
+
return {
|
|
217
|
+
status: (0, status_map_1.toSessionStatus)(status),
|
|
218
|
+
data: { ...data, status },
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
catch (e) {
|
|
222
|
+
this.logger_.error("getPaymentStatus failed", e, c);
|
|
223
|
+
// Surfacing PENDING keeps the session recoverable; the callback or a
|
|
224
|
+
// later read can resolve it.
|
|
225
|
+
return { status: utils_1.PaymentSessionStatus.PENDING, data: input.data };
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
async retrievePayment(input) {
|
|
229
|
+
const data = (input.data ?? {});
|
|
230
|
+
const c = {
|
|
231
|
+
sessionId: data.transaction_ref,
|
|
232
|
+
paytreeId: data.paytree_id,
|
|
233
|
+
};
|
|
234
|
+
const intent = await this.readIntent(data, c);
|
|
235
|
+
return {
|
|
236
|
+
data: {
|
|
237
|
+
...data,
|
|
238
|
+
status: this.latestStatus(intent),
|
|
239
|
+
intent,
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
// authorize / capture (auto-capture model)
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
async authorizePayment(input) {
|
|
247
|
+
const data = (input.data ?? {});
|
|
248
|
+
const c = {
|
|
249
|
+
sessionId: data.transaction_ref,
|
|
250
|
+
paytreeId: data.paytree_id,
|
|
251
|
+
};
|
|
252
|
+
const intent = await this.readIntent(data, c);
|
|
253
|
+
const status = this.latestStatus(intent);
|
|
254
|
+
await this.audit_.write({
|
|
255
|
+
event: constants_1.AuditEvent.AUTHORIZE,
|
|
256
|
+
session_id: data.transaction_ref,
|
|
257
|
+
paytree_id: data.paytree_id,
|
|
258
|
+
status,
|
|
259
|
+
});
|
|
260
|
+
return {
|
|
261
|
+
status: (0, status_map_1.toSessionStatus)(status),
|
|
262
|
+
data: { ...data, status },
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
async capturePayment(input) {
|
|
266
|
+
// Paytree auto-captures on the hosted page, so there is no capture call to
|
|
267
|
+
// make. We read the live status and confirm it reached success.
|
|
268
|
+
const data = (input.data ?? {});
|
|
269
|
+
const c = {
|
|
270
|
+
sessionId: data.transaction_ref,
|
|
271
|
+
paytreeId: data.paytree_id,
|
|
272
|
+
};
|
|
273
|
+
const intent = await this.readIntent(data, c);
|
|
274
|
+
const status = this.latestStatus(intent);
|
|
275
|
+
await this.audit_.write({
|
|
276
|
+
event: constants_1.AuditEvent.CAPTURE,
|
|
277
|
+
session_id: data.transaction_ref,
|
|
278
|
+
paytree_id: data.paytree_id,
|
|
279
|
+
status,
|
|
280
|
+
});
|
|
281
|
+
return {
|
|
282
|
+
data: { ...data, status },
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
// cancel / delete / update
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
async cancelPayment(input) {
|
|
289
|
+
// Paytree has no server-side void; the hosted page's cancel_url handles a
|
|
290
|
+
// shopper abandoning, and an unpaid intent simply expires. Local state only.
|
|
291
|
+
const data = (input.data ?? {});
|
|
292
|
+
await this.audit_.write({
|
|
293
|
+
event: constants_1.AuditEvent.CANCEL,
|
|
294
|
+
session_id: data.transaction_ref,
|
|
295
|
+
paytree_id: data.paytree_id,
|
|
296
|
+
});
|
|
297
|
+
return { data: input.data };
|
|
298
|
+
}
|
|
299
|
+
async deletePayment(input) {
|
|
300
|
+
return { data: input.data };
|
|
301
|
+
}
|
|
302
|
+
async updatePayment(input) {
|
|
303
|
+
// A Paytree transaction_ref cannot be reused, so an amount change can't
|
|
304
|
+
// mutate an existing intent. Medusa creates a fresh session (and thus a
|
|
305
|
+
// fresh initiatePayment) for changes, so we just echo the data here.
|
|
306
|
+
return { data: input.data };
|
|
307
|
+
}
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
// refund (verify-then-record; refunds are initiated in the Paytree back office)
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
async refundPayment(input) {
|
|
312
|
+
const data = (input.data ?? {});
|
|
313
|
+
const c = {
|
|
314
|
+
sessionId: data.transaction_ref,
|
|
315
|
+
paytreeId: data.paytree_id,
|
|
316
|
+
};
|
|
317
|
+
// Paytree does not expose an API to CREATE a refund; refunds are performed
|
|
318
|
+
// in the Paytree back office and reflected to us via the callback. So this
|
|
319
|
+
// method verifies a matching refund actually exists in Paytree before
|
|
320
|
+
// letting Medusa record it. This both (a) lets the callback path record
|
|
321
|
+
// refunds and (b) prevents the admin "Refund" button from creating a
|
|
322
|
+
// phantom refund that never moved money.
|
|
323
|
+
const intent = await this.readIntent(data, c);
|
|
324
|
+
const payment = intent?.payment?.[0];
|
|
325
|
+
const refundedTotal = payment?.refunded ?? "0";
|
|
326
|
+
const requested = new utils_1.BigNumber(input.amount);
|
|
327
|
+
const refundedBN = (0, amount_1.fromPaytreeAmount)(refundedTotal);
|
|
328
|
+
const enough = refundedBN.numeric >= requested.numeric || refundedBN.numeric > 0;
|
|
329
|
+
if (!enough) {
|
|
330
|
+
await this.audit_.write({
|
|
331
|
+
event: constants_1.AuditEvent.ERROR,
|
|
332
|
+
session_id: data.transaction_ref,
|
|
333
|
+
paytree_id: data.paytree_id,
|
|
334
|
+
error: "refund not present in Paytree",
|
|
335
|
+
detail: { phase: constants_1.AuditEvent.REFUND, refundedTotal },
|
|
336
|
+
});
|
|
337
|
+
throw new utils_1.MedusaError(utils_1.MedusaError.Types.NOT_ALLOWED, "Refunds must be initiated in the Paytree back office. Once the " +
|
|
338
|
+
"refund is processed there, it is reflected here automatically.");
|
|
339
|
+
}
|
|
340
|
+
await this.audit_.write({
|
|
341
|
+
event: constants_1.AuditEvent.REFUND,
|
|
342
|
+
session_id: data.transaction_ref,
|
|
343
|
+
paytree_id: data.paytree_id,
|
|
344
|
+
status: constants_1.PaytreeStatus.REFUNDED,
|
|
345
|
+
amount: (0, amount_1.toPaytreeAmount)(input.amount),
|
|
346
|
+
detail: { refundedTotal },
|
|
347
|
+
});
|
|
348
|
+
return {
|
|
349
|
+
data: {
|
|
350
|
+
...data,
|
|
351
|
+
refunded: refundedTotal,
|
|
352
|
+
},
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
// ---------------------------------------------------------------------------
|
|
356
|
+
// webhook (defensive only; the live path is the custom GET route)
|
|
357
|
+
// ---------------------------------------------------------------------------
|
|
358
|
+
async getWebhookActionAndData(payload) {
|
|
359
|
+
// Paytree sends a GET callback with no signed body, handled by the custom
|
|
360
|
+
// route at /hooks/paytree. The built-in POST webhook route is not used, so
|
|
361
|
+
// this returns NOT_SUPPORTED defensively if it is ever invoked.
|
|
362
|
+
this.logger_.warn("getWebhookActionAndData was called, but Paytree callbacks are handled " +
|
|
363
|
+
"by the custom /hooks/paytree route");
|
|
364
|
+
return { action: utils_1.PaymentActions.NOT_SUPPORTED };
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
exports.PaytreePaymentProviderService = PaytreePaymentProviderService;
|
|
368
|
+
PaytreePaymentProviderService.identifier = constants_1.PAYTREE_PROVIDER_IDENTIFIER;
|
|
369
|
+
exports.default = PaytreePaymentProviderService;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# API reference
|
|
2
|
+
|
|
3
|
+
Routes contributed by the plugin.
|
|
4
|
+
|
|
5
|
+
## Store
|
|
6
|
+
|
|
7
|
+
### `GET /store/paytree/methods`
|
|
8
|
+
|
|
9
|
+
Public list of the Paytree methods the admin has enabled, with their (possibly
|
|
10
|
+
overridden) display labels. Used by the storefront method picker. Never returns
|
|
11
|
+
disabled methods or any secret/operational config.
|
|
12
|
+
|
|
13
|
+
**Response**
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
{
|
|
17
|
+
"methods": [
|
|
18
|
+
{ "method": "card", "label": "Credit / debit card" },
|
|
19
|
+
{ "method": "paypal", "label": "PayPal" }
|
|
20
|
+
]
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Callback
|
|
25
|
+
|
|
26
|
+
### `GET /hooks/paytree`
|
|
27
|
+
|
|
28
|
+
Called by Paytree when a payment status changes. The notification URL is built
|
|
29
|
+
from `backendUrl` with substitution tags:
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
{BACKEND_URL}/hooks/paytree?payment_intent_id={payment_intent_id}&transaction_id={transaction_id}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
**Query parameters**
|
|
36
|
+
|
|
37
|
+
| Param | Description |
|
|
38
|
+
| ------------------- | ----------- |
|
|
39
|
+
| `payment_intent_id` | Paytree intent id (tag substituted by Paytree). |
|
|
40
|
+
| `transaction_id` | Our `transaction_ref` = the Medusa payment session id. |
|
|
41
|
+
|
|
42
|
+
The body is **not trusted**; the handler re-fetches the authoritative status
|
|
43
|
+
from Paytree. Responses:
|
|
44
|
+
|
|
45
|
+
- `200` — event handled (also returned when there is nothing to correlate, to
|
|
46
|
+
stop retries).
|
|
47
|
+
- `500` — internal failure; signals Paytree to retry.
|
|
48
|
+
|
|
49
|
+
## Admin
|
|
50
|
+
|
|
51
|
+
All admin routes require an authenticated admin session.
|
|
52
|
+
|
|
53
|
+
### `GET /admin/paytree/settings`
|
|
54
|
+
|
|
55
|
+
```json
|
|
56
|
+
{
|
|
57
|
+
"settings": {
|
|
58
|
+
"base_url": "https://api.paytree.tech",
|
|
59
|
+
"log_request": false,
|
|
60
|
+
"log_response": false,
|
|
61
|
+
"methods": [{ "method": "card", "enabled": true, "label": "Credit / debit card" }]
|
|
62
|
+
},
|
|
63
|
+
"api_key_configured": true
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The API key value is never returned — only whether it is configured.
|
|
68
|
+
|
|
69
|
+
### `POST /admin/paytree/settings`
|
|
70
|
+
|
|
71
|
+
**Body**
|
|
72
|
+
|
|
73
|
+
```json
|
|
74
|
+
{
|
|
75
|
+
"base_url": "https://api.paytree.tech",
|
|
76
|
+
"log_request": false,
|
|
77
|
+
"log_response": false,
|
|
78
|
+
"methods": [{ "method": "card", "enabled": true, "label": "Card" }]
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Validation: `base_url` must be a valid `https` URL. Unknown methods and blank
|
|
83
|
+
label overrides are dropped. Returns the resolved settings.
|
|
84
|
+
|
|
85
|
+
### `GET /admin/paytree/events`
|
|
86
|
+
|
|
87
|
+
Audit-log query for the admin Payments page.
|
|
88
|
+
|
|
89
|
+
**Query parameters**
|
|
90
|
+
|
|
91
|
+
| Param | Description |
|
|
92
|
+
| ------------- | ----------- |
|
|
93
|
+
| `session_id` | Filter by Medusa payment session id. |
|
|
94
|
+
| `paytree_id` | Filter by Paytree intent id. |
|
|
95
|
+
| `event` | Filter by event type (e.g. `initiate_payment`). |
|
|
96
|
+
| `limit` | Page size (max 200, default 50). |
|
|
97
|
+
| `offset` | Pagination offset. |
|
|
98
|
+
|
|
99
|
+
**Event types**: `initiate_payment`, `authorize_payment`, `capture_payment`,
|
|
100
|
+
`refund_payment`, `cancel_payment`, `get_payment_status`, `webhook_received`,
|
|
101
|
+
`webhook_processed`, `error`.
|
|
102
|
+
|
|
103
|
+
## Payment session data (storefront → provider)
|
|
104
|
+
|
|
105
|
+
`initiatePayment` requires these fields in the session `data` (assembled by the
|
|
106
|
+
storefront). Missing required fields cause a clear `INVALID_DATA` error.
|
|
107
|
+
|
|
108
|
+
| Field | Required | Notes |
|
|
109
|
+
| --------------------- | -------- | ----- |
|
|
110
|
+
| `customer.email` | yes | |
|
|
111
|
+
| `customer.first_name` | yes | |
|
|
112
|
+
| `customer.last_name` | yes | |
|
|
113
|
+
| `customer.phone` | no | Omitted if blank (Paytree rejects blanks). |
|
|
114
|
+
| `address.country` | yes | ISO 3166-1 alpha-2. |
|
|
115
|
+
| `address.street/city/state/zip` | no | Included only when non-empty. |
|
|
116
|
+
| `session.ip_address` | yes | End-customer IP (fraud screening). |
|
|
117
|
+
| `session.user_agent` | yes | End-customer UA. |
|
|
118
|
+
| `method` | no | One of the supported methods; defaults to `defaultMethod`. |
|
|
119
|
+
| `return_url` | no | Where Paytree redirects after payment. |
|
|
120
|
+
| `cancel_url` | no | Where Paytree redirects on cancel. |
|
|
121
|
+
| `order_items` | no | Line items forwarded to Paytree. |
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
The package is modeled on `@medusajs/payment-stripe`: a plain TypeScript package
|
|
4
|
+
compiled with `tsc` to `dist/` (CommonJS + `.d.ts`). It ships two Medusa modules
|
|
5
|
+
compiled, plus copy-in API routes and admin extensions.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
src/ # COMPILED to dist/ (shipped on npm)
|
|
9
|
+
├── index.ts # default export = ModuleProvider(PAYMENT)
|
|
10
|
+
└── modules/
|
|
11
|
+
├── paytree/ # "data" module: settings + audit-log tables
|
|
12
|
+
│ ├── models/ # paytree_setting, paytree_event_log
|
|
13
|
+
│ ├── migrations/ # table creation + methods column
|
|
14
|
+
│ └── service.ts # getSettings / upsertSettings / log queries
|
|
15
|
+
└── paytree-payment/ # payment PROVIDER (AbstractPaymentProvider)
|
|
16
|
+
├── service.ts # initiate/authorize/capture/refund/status…
|
|
17
|
+
└── lib/ # client, amount, status-map, audit, logger…
|
|
18
|
+
|
|
19
|
+
integration/ # COPY-IN source (shipped, not compiled)
|
|
20
|
+
├── api/
|
|
21
|
+
│ ├── hooks/paytree/route.ts # GET callback from Paytree (the live path)
|
|
22
|
+
│ ├── store/paytree/methods/ # GET enabled methods for the storefront
|
|
23
|
+
│ └── admin/paytree/ # GET/POST settings, GET audit events
|
|
24
|
+
└── admin/routes/paytree/ # admin dashboard pages (Payments, Settings)
|
|
25
|
+
|
|
26
|
+
storefront/ # COPY-IN Next.js checkout components
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The API routes and admin pages import shared helpers from the installed package
|
|
30
|
+
(`@paytree/medusa-payment-paytree`) rather than relative paths, so they work
|
|
31
|
+
unchanged once copied into your backend's `src/`.
|
|
32
|
+
|
|
33
|
+
## Why two modules?
|
|
34
|
+
|
|
35
|
+
Medusa runs payment providers inside the Payment module's **isolated
|
|
36
|
+
container**, which means the provider cannot resolve another module's service.
|
|
37
|
+
But the admin needs editable settings and an audit trail.
|
|
38
|
+
|
|
39
|
+
- The **`paytree` data module** owns the schema (`paytree_setting`,
|
|
40
|
+
`paytree_event_log`) and is resolvable from app-level API routes, subscribers,
|
|
41
|
+
and the admin.
|
|
42
|
+
- The **`paytree-payment` provider** cannot resolve that service, so it reads the
|
|
43
|
+
settings table and writes the audit table **directly** through the Postgres
|
|
44
|
+
connection registered in the container (`SettingsReader`, `AuditWriter`).
|
|
45
|
+
Module isolation restricts service resolution, not physical DB access.
|
|
46
|
+
|
|
47
|
+
## Payment lifecycle mapping
|
|
48
|
+
|
|
49
|
+
| Medusa call | What the provider does |
|
|
50
|
+
| ---------------------- | ---------------------- |
|
|
51
|
+
| `initiatePayment` | Validates required fields, creates a Paytree payment intent, stores `payment_link` + ids in session data. |
|
|
52
|
+
| `getPaymentStatus` | Looks up the intent by `transaction_ref`, rolls up the latest status, maps to a `PaymentSessionStatus`. |
|
|
53
|
+
| `authorizePayment` | Reads live status (auto-capture model). |
|
|
54
|
+
| `capturePayment` | No-op call to Paytree (captured on hosted page); confirms status. |
|
|
55
|
+
| `refundPayment` | Verifies a matching refund exists in Paytree, then records it. Never creates a refund. |
|
|
56
|
+
| `cancelPayment` | Local state only; unpaid intents expire on Paytree's side. |
|
|
57
|
+
| `updatePayment` | Echoes data — a `transaction_ref` can't be reused, so Medusa creates a fresh session for changes. |
|
|
58
|
+
| `getWebhookActionAndData` | Returns `NOT_SUPPORTED`; the live path is the custom GET callback. |
|
|
59
|
+
|
|
60
|
+
### Status mapping
|
|
61
|
+
|
|
62
|
+
Paytree status → Medusa `PaymentSessionStatus` (`lib/status-map.ts`):
|
|
63
|
+
|
|
64
|
+
- `pending`, `partial` → `PENDING`
|
|
65
|
+
- `authorized` → `AUTHORIZED`
|
|
66
|
+
- `success` → `CAPTURED` (auto-capture)
|
|
67
|
+
- `failed` → `ERROR`
|
|
68
|
+
- `canceled`, `expired` → `CANCELED`
|
|
69
|
+
- `refunded`, `partial_refunded`, disputes → `CAPTURED` (tracked separately)
|
|
70
|
+
|
|
71
|
+
## The callback (`/hooks/paytree`)
|
|
72
|
+
|
|
73
|
+
Paytree sends a **GET** ping with `payment_intent_id` and `transaction_id` query
|
|
74
|
+
tags — no signed body. The handler:
|
|
75
|
+
|
|
76
|
+
1. Records a `webhook_received` audit event.
|
|
77
|
+
2. Ignores the callback body entirely and re-fetches the **authoritative** status
|
|
78
|
+
from Paytree using the secret API key (this lookup is the trust mechanism).
|
|
79
|
+
3. Routes the outcome:
|
|
80
|
+
- `success`/`authorized`/`failed` → `processPaymentWorkflow`.
|
|
81
|
+
- `refunded`/`partial_refunded` → `refundPaymentWorkflow` (Medusa has no
|
|
82
|
+
"refunded" webhook action).
|
|
83
|
+
4. Returns `200` on success (idempotent; safe to retry) or `500` to trigger
|
|
84
|
+
Paytree's retry schedule on genuine internal failure.
|
|
85
|
+
|
|
86
|
+
## Amounts
|
|
87
|
+
|
|
88
|
+
Medusa v2 uses **major units** as `BigNumber`. Paytree accepts a 2-decimal
|
|
89
|
+
string. `lib/amount.ts` converts between them; 3-decimal currencies are rounded
|
|
90
|
+
to 2 places to match Paytree's API constraint.
|
|
91
|
+
|
|
92
|
+
## Observability
|
|
93
|
+
|
|
94
|
+
Every meaningful event is written to `paytree_event_log` via `AuditWriter`,
|
|
95
|
+
always after redaction. The `PaytreeLogger` tags every line with the session and
|
|
96
|
+
intent ids so a payment can be traced end-to-end by grepping one id. Request and
|
|
97
|
+
response body logging is opt-in per the admin toggles and always redacted.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Configuration
|
|
2
|
+
|
|
3
|
+
## Environment variables
|
|
4
|
+
|
|
5
|
+
| Variable | Required | Purpose |
|
|
6
|
+
| ------------------ | -------- | ----------------------------------------------------------------------- |
|
|
7
|
+
| `PAYTREE_API_KEY` | yes | Paytree API key. Sent as `Authorization: Token <key>`. Read from env only. |
|
|
8
|
+
| `BACKEND_URL` | yes | Public URL of this backend; used to build the `/hooks/paytree` callback URL. |
|
|
9
|
+
| `PAYTREE_BASE_URL` | no | Paytree API origin. Defaults to `https://api.paytree.tech`; overridable in the admin. |
|
|
10
|
+
|
|
11
|
+
## Provider options
|
|
12
|
+
|
|
13
|
+
Passed under the payment provider entry in `medusa-config.js`:
|
|
14
|
+
|
|
15
|
+
| Option | Type | Required | Default | Notes |
|
|
16
|
+
| --------------- | -------- | -------- | ------------------------ | ----- |
|
|
17
|
+
| `apiKey` | string | yes | — | Validated at boot; throws if missing. |
|
|
18
|
+
| `backendUrl` | string | yes | — | Validated at boot; throws if missing. |
|
|
19
|
+
| `baseUrl` | string | no | `https://api.paytree.tech` | Only used as a fallback; the admin setting wins at runtime. |
|
|
20
|
+
| `defaultMethod` | string | no | `card` | Requested when the storefront doesn't pass a `method`. |
|
|
21
|
+
|
|
22
|
+
### Settings precedence for the base URL
|
|
23
|
+
|
|
24
|
+
At runtime the effective base URL is resolved in this order:
|
|
25
|
+
|
|
26
|
+
1. The `paytree_setting.base_url` row (editable in the admin UI).
|
|
27
|
+
2. The `baseUrl` provider option.
|
|
28
|
+
3. The built-in default `https://api.paytree.tech`.
|
|
29
|
+
|
|
30
|
+
Settings are cached in the provider for a short TTL (15s) so admin changes take
|
|
31
|
+
effect without a restart, without hitting the DB on every call.
|
|
32
|
+
|
|
33
|
+
## Admin settings (`Paytree → Settings`)
|
|
34
|
+
|
|
35
|
+
| Setting | Stored in DB | Description |
|
|
36
|
+
| ---------------------- | ------------ | ----------- |
|
|
37
|
+
| Paytree API endpoint | yes | Must be a valid `https` URL. |
|
|
38
|
+
| Payment methods | yes | Per-method `enabled` + optional `label`. Only enabled methods show at checkout. |
|
|
39
|
+
| Log outgoing requests | yes | Logs request bodies (always redacted). |
|
|
40
|
+
| Log responses | yes | Logs response bodies (always redacted). |
|
|
41
|
+
|
|
42
|
+
The API key status ("configured" / "missing") is shown but the value is never
|
|
43
|
+
returned or editable here — it lives only in `PAYTREE_API_KEY`.
|
|
44
|
+
|
|
45
|
+
## Supported payment methods
|
|
46
|
+
|
|
47
|
+
`card`, `paypal`, `klarna`, `ideal`, `bancontact`, `crypto`, `wire`
|
|
48
|
+
(bank transfer), `trustly`, `kakaopay`, `p24` (Przelewy24), `blik`.
|
|
49
|
+
|
|
50
|
+
The canonical list and default labels live in
|
|
51
|
+
`src/modules/paytree-payment/lib/methods.ts`. Only `enabled` and `label` are
|
|
52
|
+
persisted per method; adding a method to the canonical list automatically
|
|
53
|
+
surfaces it in the admin UI.
|
|
54
|
+
|
|
55
|
+
## Security notes
|
|
56
|
+
|
|
57
|
+
- The API key is never persisted or logged; the `Authorization` header is masked
|
|
58
|
+
in all logs.
|
|
59
|
+
- The admin base-URL update rejects non-`https` URLs so the key is never sent in
|
|
60
|
+
cleartext.
|
|
61
|
+
- The audit log redacts credential-like fields (`token`, `secret`, `password`,
|
|
62
|
+
`api_key`, `authorization`) at any depth before writing.
|