@storecraft/payments-paypal 1.0.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/README.md ADDED
@@ -0,0 +1,43 @@
1
+ # Paypal payment gateway for **StoreCraft**
2
+
3
+ [paypal standard](https://developer.paypal.com/docs/checkout/standard/) integration
4
+
5
+ ## Features
6
+ - Create checkouts with `AUTHORIZE` or `CAPTURE` intents
7
+ - `capture`, `void`, `refund` actions
8
+ - Get a readable and explainable `status`
9
+ - Supports both `prod` and `test` endpoints
10
+
11
+ ```bash
12
+ npm i @storecraft/payments-paypal-standard
13
+ ```
14
+
15
+ ## Howto
16
+
17
+ ```js
18
+ const config = {
19
+ env: 'prod',
20
+ client_id: '<get-from-your-paypal-dashboard>',
21
+ secret: '<get-from-your-paypal-dashboard>',
22
+ currency_code: 'USD',
23
+ intent_on_checkout: 'AUTHORIZE'
24
+ }
25
+
26
+ new PaypalStandard(config);
27
+ ```
28
+
29
+ ## Developer info and test
30
+
31
+ Integration examples
32
+ - https://developer.paypal.com/studio/checkout/standard/integrate
33
+
34
+ Credit Card Generator
35
+ - https://developer.paypal.com/tools/sandbox/card-testing/#link-creditcardgenerator
36
+
37
+ ## todo:
38
+ - Add tests
39
+ - Think about adding more dynamic config
40
+
41
+ ```text
42
+ Author: Tomer Shalev (tomer.shalev@gmail.com)
43
+ ```
@@ -0,0 +1,136 @@
1
+ /**
2
+ *
3
+ * @description Official PayPal UI integration with `storecraft`.
4
+ *
5
+ * Test with dummy data with this generator
6
+ * https://developer.paypal.com/tools/sandbox/card-testing/#link-creditcardgenerator.
7
+ *
8
+ * Or use the following dummy details:
9
+ * - Card number: 4032033785933750
10
+ * - Expiry date: 12/2027
11
+ * - CVC code: 897
12
+ *
13
+ * @param {import("./types.public.js").Config} config
14
+ * @param {Partial<import("@storecraft/core/v-api").OrderData>} order_data
15
+ */
16
+ export default function html_buy_ui(config, order_data) {
17
+ const orderData = order_data?.payment_gateway?.on_checkout_create;
18
+
19
+ return `
20
+ <!DOCTYPE html>
21
+ <html style="height: 100%; width: 100%">
22
+ <head>
23
+ <meta charset="UTF-8" />
24
+ <meta name="viewport" content="width=device-width; height=device-height, initial-scale=1.0" />
25
+ <title>PayPal JS SDK Standard Integration</title>
26
+ </head>
27
+ <body style='display: flex; flex-direction: column; justify-content: start; height: 100%; align-items: center;'>
28
+ <div id="paypal-button-container" style='width: 100%' ></div>
29
+ <p id="result-message"></p>
30
+ <!-- Initialize the JS-SDK -->
31
+ <script src="https://www.paypal.com/sdk/js?currency=${config.default_currency_code}&client-id=${config.client_id}&intent=${config.intent_on_checkout.toLowerCase()}"></script>
32
+
33
+ <!-- code -->
34
+ <script>
35
+ const resultMessage = (msg) => {
36
+ document.getElementById('result-message').innerHTML = msg
37
+ console.log(msg);
38
+
39
+ }
40
+
41
+ window.paypal
42
+ .Buttons(
43
+ {
44
+ style: {
45
+ shape: "rect",
46
+ layout: "vertical",
47
+ color: "gold",
48
+ label: "paypal",
49
+ disableMaxWidth: true
50
+ },
51
+
52
+ message: {
53
+ amount: ${order_data.pricing.total},
54
+ },
55
+
56
+ async createOrder() {
57
+ try {
58
+ if ('${orderData.id}') {
59
+ return '${orderData.id}';
60
+ }
61
+ const errorDetail = ${orderData?.details?.[0]};
62
+ const errorMessage = errorDetail
63
+ ? (errorDetail.issue + errorDetail.description + "(" + '${orderData.debug_id}' + ")")
64
+ : JSON.stringify(orderData);
65
+
66
+ throw new Error(errorMessage);
67
+ } catch (error) {
68
+ console.error(error);
69
+ resultMessage("Could not initiate PayPal Checkout...<br><br> " + error);
70
+ }
71
+ },
72
+
73
+ async onApprove(data, actions) {
74
+ try {
75
+ const response = await fetch("/api/checkout/${order_data.id}/complete", {
76
+ method: "POST",
77
+ headers: {
78
+ "Content-Type": "application/json",
79
+ },
80
+ });
81
+
82
+ const orderData_main = await response.json();
83
+ const orderData = orderData_main.payment_gateway.on_checkout_complete;
84
+
85
+ // Three cases to handle:
86
+ // (1) Recoverable INSTRUMENT_DECLINED -> call actions.restart()
87
+ // (2) Other non-recoverable errors -> Show a failure message
88
+ // (3) Successful transaction -> Show confirmation or thank you message
89
+
90
+ const errorDetail = orderData?.details?.[0];
91
+
92
+ if (errorDetail?.issue === "INSTRUMENT_DECLINED") {
93
+ // (1) Recoverable INSTRUMENT_DECLINED -> call actions.restart()
94
+ // recoverable state, per
95
+ // https://developer.paypal.com/docs/checkout/standard/customize/handle-funding-failures/
96
+ return actions.restart();
97
+ } else if (errorDetail) {
98
+ // (2) Other non-recoverable errors -> Show a failure message
99
+ throw new Error(errorDetail.description + "(" + orderData.debug_id + ")");
100
+ }
101
+ // else if (!orderData.purchase_units) {
102
+ // throw new Error(JSON.stringify(orderData));
103
+ // }
104
+ else {
105
+ // (3) Successful transaction -> Show confirmation or thank you message
106
+ // Or go to another URL: actions.redirect('thank_you.html');
107
+ const transaction =
108
+ orderData?.purchase_units?.[0]?.payments?.captures?.[0] ||
109
+ orderData?.purchase_units?.[0]?.payments?.authorizations?.[0];
110
+ resultMessage(
111
+ "Transaction " + transaction?.status + ":" + transaction?.id +
112
+ " See console for all available details"
113
+ );
114
+ console.log(
115
+ "Capture result",
116
+ orderData,
117
+ JSON.stringify(orderData, null, 2)
118
+ );
119
+ }
120
+ } catch (error) {
121
+ console.error(error);
122
+ resultMessage(
123
+ "Sorry, your transaction could not be processed... " + error
124
+ );
125
+ }
126
+ }
127
+
128
+ }
129
+ ).render("#paypal-button-container");
130
+
131
+ </script>
132
+
133
+ </body>
134
+ </html>
135
+ `
136
+ }
package/adapter.js ADDED
@@ -0,0 +1,377 @@
1
+ import { CheckoutStatusEnum, PaymentOptionsEnum } from '@storecraft/core/v-api/types.api.enums.js';
2
+ import { fetch_with_auth, throw_bad_response } from './adapter.utils.js';
3
+ import { StorecraftError } from '@storecraft/core/v-api/utils.func.js';
4
+ import html_buy_ui from './adapter.html.js';
5
+
6
+ /**
7
+ * @typedef {import('./types.private.js').paypal_order} CreateResult
8
+ * @typedef {import('@storecraft/core/v-api').PaymentGatewayStatus} PaymentGatewayStatus
9
+ * @typedef {import('@storecraft/core/v-api').CheckoutStatusEnum} CheckoutStatusOptions
10
+ * @typedef {import('@storecraft/core/v-api').OrderData} OrderData
11
+ * @typedef {import('./types.public.js').Config} Config
12
+ * @typedef {import('@storecraft/core/v-payments').payment_gateway<Config, CreateResult>} payment_gateway
13
+ */
14
+
15
+ /**
16
+ * @implements {payment_gateway}
17
+ *
18
+ * @description **Paypal Payment** gateway (https://developer.paypal.com/docs/checkout/)
19
+ */
20
+ export class Paypal {
21
+
22
+ /** @type {Config} */ #_config;
23
+
24
+ /**
25
+ *
26
+ * @param {Config} config
27
+ */
28
+ constructor(config) {
29
+ this.#_config = this.#validate_and_resolve_config(config);
30
+ }
31
+
32
+ /**
33
+ *
34
+ * @param {Config} config
35
+ */
36
+ #validate_and_resolve_config(config) {
37
+ config = {
38
+ default_currency_code: 'USD',
39
+ env: 'prod',
40
+ intent_on_checkout: 'AUTHORIZE',
41
+ ...config,
42
+ }
43
+
44
+ const is_valid = config.client_id && config.secret;
45
+
46
+ if(!is_valid) {
47
+ throw new StorecraftError(
48
+ `Payment gateway ${this.info.name ?? 'unknown'} has invalid config !!!
49
+ Missing client_id or secret`
50
+ )
51
+ }
52
+
53
+ return config;
54
+ }
55
+
56
+ get info() {
57
+ return {
58
+ name: 'Paypal payments',
59
+ description: `Set up standard and advanced payments to present payment buttons to your payers so they can pay with PayPal, debit and credit cards, Pay Later options, Venmo, and alternative payment methods.
60
+ You can get started quickly with this 15-minute copy-and-paste integration. If you have an older Checkout integration, you can upgrade your Checkout integration.`,
61
+ url: 'https://developer.paypal.com/docs/checkout/',
62
+ logo_url: 'https://www.paypalobjects.com/webstatic/mktg/logo/pp_cc_mark_37x23.jpg'
63
+ }
64
+ }
65
+
66
+ get config() {
67
+ return this.#_config;
68
+ }
69
+
70
+ get actions() {
71
+ return [
72
+ {
73
+ handle: 'capture',
74
+ name: 'Capture',
75
+ description: 'Capture an authorized payment'
76
+ },
77
+ {
78
+ handle: 'void',
79
+ name: 'Void',
80
+ description: 'Cancel an authorized payment'
81
+ },
82
+ {
83
+ handle: 'refund',
84
+ name: 'Refund',
85
+ description: 'Refund a captured payment'
86
+ },
87
+ ]
88
+ }
89
+
90
+ /**
91
+ *
92
+ * @type {payment_gateway["invokeAction"]}
93
+ */
94
+ invokeAction(action_handle) {
95
+ switch (action_handle) {
96
+ case 'capture':
97
+ return this.capture.bind(this);
98
+ case 'void':
99
+ return this.void.bind(this);
100
+ case 'refund':
101
+ return this.refund.bind(this);
102
+
103
+ default:
104
+ break;
105
+ }
106
+ }
107
+
108
+ /**
109
+ * @description (Optional) buy link ui
110
+ *
111
+ * @param {Partial<OrderData>} order
112
+ *
113
+ * @return {Promise<string>} html
114
+ */
115
+ async onBuyLinkHtml(order) {
116
+
117
+ return html_buy_ui(
118
+ this.config, order
119
+ )
120
+ }
121
+
122
+ /**
123
+ * @description TODO: the user prefers to capture intent instead
124
+ *
125
+ * @param {OrderData} order
126
+ *
127
+ * @return {Promise<CreateResult>}
128
+ */
129
+ async onCheckoutCreate(order) {
130
+ const { default_currency_code: currency_code, intent_on_checkout } = this.config;
131
+
132
+ /** @type {import('./types.private.js').paypal_order_request} */
133
+ const body = {
134
+ intent: intent_on_checkout==='AUTHORIZE' ? 'AUTHORIZE' : 'CAPTURE',
135
+ purchase_units: [
136
+ {
137
+ custom_id: order.id,
138
+ amount: {
139
+ currency_code: currency_code,
140
+ value: order.pricing.total.toFixed(2),
141
+ },
142
+ invoice_id: `${order.id}_${Date.now()}`
143
+ },
144
+ ],
145
+ }
146
+
147
+ const response = await fetch_with_auth(
148
+ this.config, 'v2/checkout/orders', {
149
+ method: 'post',
150
+ body: JSON.stringify(body),
151
+ }
152
+ );
153
+
154
+ await throw_bad_response(response);
155
+
156
+ /** @type {CreateResult} */
157
+ const json = await response.json();
158
+ return json;
159
+ }
160
+
161
+ /**
162
+ * @description todo: logic for if user wanted capture at approval
163
+ *
164
+ * @param {CreateResult} create_result
165
+ *
166
+ * @return {ReturnType<payment_gateway["onCheckoutComplete"]>}
167
+ */
168
+ async onCheckoutComplete(create_result) {
169
+ // the url based on authorize or capture intent
170
+ const url = this.config.intent_on_checkout==='AUTHORIZE' ?
171
+ `v2/checkout/orders/${create_result.id}/authorize` :
172
+ `v2/checkout/orders/${create_result.id}/capture`;
173
+ const response = await fetch_with_auth(
174
+ this.config, url,
175
+ { method: 'post' }
176
+ );
177
+
178
+ await throw_bad_response(response);
179
+
180
+ /** @type {import('./types.private.js').paypal_order} */
181
+ const payload = await response.json();
182
+
183
+ let status;
184
+ switch(payload.status) {
185
+ case 'COMPLETED':
186
+ status = {
187
+ payment: PaymentOptionsEnum.authorized,
188
+ checkout: CheckoutStatusEnum.complete
189
+ }
190
+ break;
191
+ case 'PAYER_ACTION_REQUIRED':
192
+ status = {
193
+ checkout: CheckoutStatusEnum.requires_action
194
+ }
195
+ break;
196
+ default:
197
+ status = {
198
+ checkout: CheckoutStatusEnum.failed
199
+ }
200
+ break;
201
+ }
202
+
203
+ return {
204
+ status,
205
+ onCheckoutComplete: payload
206
+ }
207
+ }
208
+
209
+ /**
210
+ * @description Fetch the order and analyze it's status
211
+ *
212
+ *
213
+ * @param {CreateResult} create_result
214
+ *
215
+ *
216
+ * @returns {Promise<PaymentGatewayStatus>}
217
+ */
218
+ async status(create_result) {
219
+ const o = await this.retrieve_order(create_result);
220
+ const purchase_unit = o.purchase_units?.[0];
221
+ const authorization = purchase_unit?.payments?.authorizations?.[0];
222
+ const capture = purchase_unit?.payments?.captures?.[0];
223
+ const refund = purchase_unit?.payments?.refunds?.[0];
224
+
225
+ /** @type {PaymentGatewayStatus} */
226
+ const stat = {
227
+ messages: [],
228
+ actions: this.actions
229
+ }
230
+
231
+ /** @param {typeof authorization | typeof capture | typeof refund} u */
232
+ const get_values = u => {
233
+ return {
234
+ currency_code: u.amount.currency_code,
235
+ price: u.amount.value,
236
+ reason: u?.status_details?.reason,
237
+ create_time: new Date(u?.create_time).toUTCString(),
238
+ update_time: new Date(u?.update_time).toUTCString(),
239
+ id: u?.id, status: u.status
240
+ }
241
+ }
242
+
243
+ if(refund) {
244
+ const v = get_values(refund);
245
+ stat.messages = [
246
+ `**${v.price}${v.currency_code}** were tried to be \`REFUNDED\` at \`${v.create_time}\``,
247
+ `The status is \`${v.status}\`, updated at \`${v.update_time}\``,
248
+ v.reason && `The reason for this status is \`${v.reason}\``,
249
+ `Refund ID is \`${v.id}\`.`
250
+ ].filter(Boolean);
251
+
252
+ } else if(capture) {
253
+ const v = get_values(capture);
254
+ stat.messages = [
255
+ `**${v.price}${v.currency_code}** were tried to be \`CAPTURED\` at \`${v.create_time}\``,
256
+ `The status is \`${v.status}\`, updated at \`${v.update_time}\``,
257
+ v.reason && `The reason for this status is \`${v.reason}\``,
258
+ `Capture ID is \`${v.id}\`.`
259
+ ].filter(Boolean);
260
+
261
+ } else if (authorization) {
262
+ const v = get_values(authorization);
263
+ const expiration_time = new Date(authorization?.expiration_time).toUTCString();
264
+ stat.messages = [
265
+ `**${v.price}${v.currency_code}** were tried to be \`AUTHORIZED\` at \`${v.create_time}\``,
266
+ `The status is \`${authorization.status}\`, updated at \`${v.update_time}\``,
267
+ `The authorization will expire at \`${expiration_time}\``,
268
+ v.reason && `The reason for this status is \`${v.reason}\``,
269
+ `Authorization ID is \`${v.id}\`.`
270
+ ].filter(Boolean);
271
+
272
+ } else { // just an intent
273
+ const currency_code = purchase_unit.amount.currency_code;
274
+ const price = purchase_unit.amount.value;
275
+ stat.messages = [
276
+ `An intent to **${o.intent}** of **${price}${currency_code}** was initiated`,
277
+ `The status is \`${o.status}\``
278
+ ];
279
+ }
280
+
281
+ return stat;
282
+ }
283
+
284
+ /**
285
+ * @description [https://developer.paypal.com/api/rest/webhooks/rest/](https://developer.paypal.com/api/rest/webhooks/rest/)
286
+ *
287
+ * @param {Request} request
288
+ */
289
+ async webhook(request) {
290
+ return null;
291
+ }
292
+
293
+ /**
294
+ * @description Retrieve latest order payload
295
+ *
296
+ * @param {CreateResult} create_result first create result, holds paypal id
297
+ *
298
+ * @return {Promise<import('./types.private.js').paypal_order>}
299
+ */
300
+ retrieve_order = async (create_result) => {
301
+ const response = await fetch_with_auth(
302
+ this.config,
303
+ `v2/checkout/orders/${create_result.id}`,
304
+ { method: 'get' }
305
+ );
306
+
307
+ await throw_bad_response(response);
308
+
309
+ /** @type {import('./types.private.js').paypal_order} */
310
+ const jsonData = await response.json();
311
+ return jsonData;
312
+ }
313
+
314
+ // actions
315
+
316
+ /**
317
+ * @description todo: logic for if user wanted capture at approval
318
+ *
319
+ * @param {CreateResult} create_result
320
+ */
321
+ async void(create_result) {
322
+ const paypal_order = await this.retrieve_order(create_result);
323
+ const authorization_id = paypal_order?.purchase_units?.[0]?.payments?.authorizations?.[0]?.id
324
+
325
+ const response = await fetch_with_auth(
326
+ this.config,
327
+ `v2/payments/authorizations/${authorization_id}/void`,
328
+ { method: 'post' }
329
+ );
330
+
331
+ await throw_bad_response(response);
332
+
333
+ return this.status(create_result);
334
+ }
335
+
336
+ /**
337
+ * @description todo: logic for if user wanted capture at approval
338
+ *
339
+ * @param {CreateResult} create_result
340
+ */
341
+ async capture(create_result) {
342
+ const paypal_order = await this.retrieve_order(create_result);
343
+ const authorization_id = paypal_order?.purchase_units?.[0]?.payments?.authorizations?.[0]?.id
344
+
345
+ const response = await fetch_with_auth(
346
+ this.config,
347
+ `v2/payments/authorizations/${authorization_id}/capture`,
348
+ { method: 'post' }
349
+ );
350
+
351
+ await throw_bad_response(response);
352
+
353
+ return this.status(create_result);
354
+ }
355
+
356
+ /**
357
+ * @description todo: logic for if user wanted capture at approval
358
+ *
359
+ * @param {CreateResult} create_result
360
+ */
361
+ async refund(create_result) {
362
+ const paypal_order = await this.retrieve_order(create_result);
363
+ const capture_id = paypal_order?.purchase_units?.[0]?.payments?.captures?.[0]?.id;
364
+
365
+ const response = await fetch_with_auth(
366
+ this.config,
367
+ `v2/payments/captures/${capture_id}/refund`,
368
+ { method: 'post' }
369
+ );
370
+
371
+ await throw_bad_response(response);
372
+
373
+ return this.status(create_result);
374
+ }
375
+
376
+ }
377
+
@@ -0,0 +1,76 @@
1
+
2
+ export const endpoints = {
3
+ test: 'https://api-m.sandbox.paypal.com',
4
+ prod: 'https://api-m.paypal.com'
5
+ }
6
+
7
+ /**
8
+ * @typedef {import("./types.public.js").Config} Config
9
+ */
10
+
11
+ /**
12
+ * @param {Config} config
13
+ */
14
+ export const get_endpoint = (config) => endpoints[config.env==='test' ? 'test': 'prod'];
15
+
16
+ /** @param {Response} response */
17
+ export const throw_bad_response = async (response) => {
18
+ if(!response.ok) throw new Error(await response.text());
19
+ }
20
+
21
+ /**
22
+ * get access token if it has expired
23
+ *
24
+ * @param {Config} config
25
+ * @returns {Promise<{ access_token: string, endpoint: string }>}
26
+ */
27
+ export const getAccessToken = async (config) => {
28
+
29
+ const { client_id, secret } = config;
30
+ const endpoint = get_endpoint(config);
31
+
32
+ const auth = btoa(client_id + ':' + secret);
33
+ // const expired = current_auth.expires_at - Date.now() <= 10*1000
34
+ // if(!expired)
35
+ // return current_auth.latest_auth_response.access_token
36
+
37
+ const response = await fetch(
38
+ `${endpoint}/v1/oauth2/token`,
39
+ {
40
+ method: 'post',
41
+ body: 'grant_type=client_credentials',
42
+ headers: {
43
+ Authorization: `Basic ${auth}`,
44
+ },
45
+ }
46
+ );
47
+
48
+ await throw_bad_response(response);
49
+
50
+ const jsonData = await response.json()
51
+ // current_auth.latest_auth_response = jsonData
52
+ return {
53
+ endpoint,
54
+ access_token: jsonData.access_token
55
+ }
56
+ }
57
+
58
+ /**
59
+ * make a request, taking into account auth and endpoints for test and prod
60
+ * @param {Config} config
61
+ * @param {string} path relative path, i.e `v2/checkout/orders/..`
62
+ * @param {RequestInit} init
63
+ */
64
+ export const fetch_with_auth = async (config, path, init={}) => {
65
+ const { access_token, endpoint } = await getAccessToken(config);
66
+
67
+ return fetch(
68
+ `${endpoint}/${path}`, {
69
+ ...init,
70
+ headers: {
71
+ 'Content-Type': 'application/json',
72
+ Authorization: `Bearer ${access_token}`,
73
+ },
74
+ }
75
+ );
76
+ }
package/index.js ADDED
@@ -0,0 +1 @@
1
+ export * from './adapter.js'
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@storecraft/payments-paypal",
3
+ "version": "1.0.0",
4
+ "description": "Official Paypal integration with Storecraft",
5
+ "license": "MIT",
6
+ "author": "Tomer Shalev (https://github.com/store-craft)",
7
+ "homepage": "https://github.com/store-craft/storecraft",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/store-craft/storecraft.git",
11
+ "directory": "packages/payments-paypal"
12
+ },
13
+ "keywords": [
14
+ "commerce",
15
+ "dashboard",
16
+ "code",
17
+ "storecraft"
18
+ ],
19
+ "type": "module",
20
+ "main": "index.js",
21
+ "types": "./types.public.d.ts",
22
+ "scripts": {
23
+ "payments-paypal:test": "uvu -c",
24
+ "payments-paypal:publish": "npm publish --access public"
25
+ },
26
+ "dependencies": {
27
+ },
28
+ "devDependencies": {
29
+ "@storecraft/core": "^1.0.0",
30
+ "@types/node": "^20.11.0",
31
+ "uvu": "^0.5.6",
32
+ "dotenv": "^16.3.1"
33
+ }
34
+ }
@@ -0,0 +1,11 @@
1
+ import 'dotenv/config';
2
+ import { test } from 'uvu';
3
+ import * as assert from 'uvu/assert';
4
+
5
+ test('todo', async () => {
6
+
7
+ assert.ok(true, 'todo')
8
+
9
+ });
10
+
11
+ test.run();
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compileOnSave": false,
3
+ "compilerOptions": {
4
+ "noEmit": true,
5
+ "allowJs": true,
6
+ "checkJs": true,
7
+ "target": "ESNext",
8
+ "resolveJsonModule": true,
9
+ "moduleResolution": "NodeNext",
10
+ "module": "NodeNext",
11
+ "composite": true,
12
+ },
13
+ "include": ["*", "*/*", "src/*"]
14
+ }