@paytree/medusa-payment-paytree 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +195 -0
  3. package/dist/index.d.ts +28 -0
  4. package/dist/index.js +55 -0
  5. package/dist/modules/paytree/index.d.ts +36 -0
  6. package/dist/modules/paytree/index.js +13 -0
  7. package/dist/modules/paytree/migrations/Migration20260617000000.d.ts +5 -0
  8. package/dist/modules/paytree/migrations/Migration20260617000000.js +54 -0
  9. package/dist/modules/paytree/migrations/Migration20260618000000.d.ts +5 -0
  10. package/dist/modules/paytree/migrations/Migration20260618000000.js +19 -0
  11. package/dist/modules/paytree/models/paytree-event-log.d.ts +25 -0
  12. package/dist/modules/paytree/models/paytree-event-log.js +29 -0
  13. package/dist/modules/paytree/models/paytree-setting.d.ts +23 -0
  14. package/dist/modules/paytree/models/paytree-setting.js +27 -0
  15. package/dist/modules/paytree/service.d.ts +60 -0
  16. package/dist/modules/paytree/service.js +47 -0
  17. package/dist/modules/paytree-payment/index.d.ts +8 -0
  18. package/dist/modules/paytree-payment/index.js +16 -0
  19. package/dist/modules/paytree-payment/lib/amount.d.ts +23 -0
  20. package/dist/modules/paytree-payment/lib/amount.js +40 -0
  21. package/dist/modules/paytree-payment/lib/audit.d.ts +26 -0
  22. package/dist/modules/paytree-payment/lib/audit.js +51 -0
  23. package/dist/modules/paytree-payment/lib/client.d.ts +28 -0
  24. package/dist/modules/paytree-payment/lib/client.js +81 -0
  25. package/dist/modules/paytree-payment/lib/constants.d.ts +54 -0
  26. package/dist/modules/paytree-payment/lib/constants.js +55 -0
  27. package/dist/modules/paytree-payment/lib/logger.d.ts +27 -0
  28. package/dist/modules/paytree-payment/lib/logger.js +73 -0
  29. package/dist/modules/paytree-payment/lib/methods.d.ts +69 -0
  30. package/dist/modules/paytree-payment/lib/methods.js +68 -0
  31. package/dist/modules/paytree-payment/lib/redact.d.ts +16 -0
  32. package/dist/modules/paytree-payment/lib/redact.js +60 -0
  33. package/dist/modules/paytree-payment/lib/settings-reader.d.ts +24 -0
  34. package/dist/modules/paytree-payment/lib/settings-reader.js +74 -0
  35. package/dist/modules/paytree-payment/lib/status-map.d.ts +26 -0
  36. package/dist/modules/paytree-payment/lib/status-map.js +66 -0
  37. package/dist/modules/paytree-payment/lib/types.d.ts +144 -0
  38. package/dist/modules/paytree-payment/lib/types.js +2 -0
  39. package/dist/modules/paytree-payment/service.d.ts +36 -0
  40. package/dist/modules/paytree-payment/service.js +369 -0
  41. package/docs/api-reference.md +121 -0
  42. package/docs/architecture.md +97 -0
  43. package/docs/configuration.md +62 -0
  44. package/docs/getting-started.md +111 -0
  45. package/docs/storefront-integration.md +85 -0
  46. package/integration/README.md +46 -0
  47. package/integration/admin/routes/paytree/api.ts +16 -0
  48. package/integration/admin/routes/paytree/method-icons.tsx +106 -0
  49. package/integration/admin/routes/paytree/page.tsx +18 -0
  50. package/integration/admin/routes/paytree/payments/page.tsx +240 -0
  51. package/integration/admin/routes/paytree/settings/page.tsx +214 -0
  52. package/integration/api/admin/paytree/events/route.ts +35 -0
  53. package/integration/api/admin/paytree/settings/route.ts +86 -0
  54. package/integration/api/hooks/paytree/route.ts +195 -0
  55. package/integration/api/store/paytree/methods/route.ts +28 -0
  56. package/package.json +57 -0
  57. package/storefront/README.md +25 -0
  58. package/storefront/components/payment-button.tsx +361 -0
  59. package/storefront/components/payment.tsx +356 -0
  60. package/storefront/components/paytree-method-icon.tsx +106 -0
  61. package/storefront/lib/paytree.ts +23 -0
@@ -0,0 +1,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.