@hackthedev/dsync-pay 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/.gitattributes ADDED
@@ -0,0 +1,2 @@
1
+ # Auto detect text files and perform LF normalization
2
+ * text=auto
@@ -0,0 +1,43 @@
1
+ name: Publish to npm
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+
8
+ permissions:
9
+ contents: write
10
+
11
+ jobs:
12
+ publish:
13
+ runs-on: ubuntu-latest
14
+
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ with:
18
+ persist-credentials: true
19
+
20
+ - name: Skip version bump commits
21
+ run: |
22
+ if git log -1 --pretty=%B | grep -q "chore: bump version"; then
23
+ echo "Version bump commit detected, skipping."
24
+ exit 0
25
+ fi
26
+
27
+ - uses: actions/setup-node@v4
28
+ with:
29
+ node-version: 20
30
+ registry-url: https://registry.npmjs.org/
31
+
32
+ - run: npm ci
33
+
34
+ - run: |
35
+ git config user.name "github-actions"
36
+ git config user.email "actions@github.com"
37
+ npm version patch -m "chore: bump version %s"
38
+ git push
39
+
40
+ - run: npm publish --access public
41
+ env:
42
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
43
+
package/README.md ADDED
@@ -0,0 +1,178 @@
1
+ # dSyncPay
2
+
3
+ As another part of the dSync library family this library is responsible for payment handling currently supporting PayPal and Coinbase Crypto payments. Its works independently and without any percentage cuts by using your own API keys.
4
+
5
+ > [!NOTE]
6
+ >
7
+ > Payment Providers may take a cut from your money or have other fees that are outside of this library's control.
8
+
9
+ ------
10
+
11
+ ## Setup
12
+ ```js
13
+ import dSyncPay from '@hackthedev/dsync-pay';
14
+
15
+ // const app = express();
16
+ const payments = new dSyncPay({
17
+ app,
18
+ domain: 'https://domain.com',
19
+ basePath: '/payments', // optional, default is '/payments'
20
+ paypal: {
21
+ clientId: 'xxx',
22
+ clientSecret: 'xxx',
23
+ sandbox: true // or false for production
24
+ },
25
+ coinbase: {
26
+ apiKey: 'xxx',
27
+ webhookSecret: 'xxx' // optional
28
+ },
29
+
30
+ // events from the library
31
+ onPaymentCreated: (data) => {},
32
+ onPaymentCompleted: (data) => {},
33
+ onPaymentFailed: (data) => {},
34
+ onPaymentCancelled: (data) => {},
35
+ onSubscriptionCreated: (data) => {},
36
+ onSubscriptionActivated: (data) => {},
37
+ onSubscriptionCancelled: (data) => {},
38
+ onError: (error) => {}
39
+ });
40
+ ```
41
+
42
+ ------
43
+
44
+ ## PayPal Usage
45
+
46
+ ### Create an order
47
+
48
+ ```js
49
+ // returnUrl and cancelUrl are auto-generated based on domain + basePath
50
+ const payment = await payments.paypal.createOrder({
51
+ title: 'product name',
52
+ price: 19.99
53
+ // returnUrl automatically becomes https://domain.com/payments/paypal/verify
54
+ // cancelUrl will become https://domain.com/payments/cancel
55
+ });
56
+
57
+ // or override manually
58
+ const payment = await payments.paypal.createOrder({
59
+ title: 'product name',
60
+ price: 19.99,
61
+ returnUrl: 'https://custom.com/success',
62
+ cancelUrl: 'https://custom.com/cancel',
63
+ metadata: { userId: '123' }
64
+ });
65
+
66
+ // manual verify. result.status === 'COMPLETED'. see paypal api.
67
+ const result = await payments.paypal.verifyOrder(orderId);
68
+ ```
69
+
70
+ ### Managing subscriptions
71
+
72
+ ```js
73
+ // requires you to setup a plan one time
74
+ const plan = await payments.paypal.createPlan({
75
+ name: 'monthly premium',
76
+ price: 9.99,
77
+ interval: 'MONTH'
78
+ });
79
+ // save plan.planId
80
+
81
+ // then you can create subscriptions based on that plan
82
+ const sub = await payments.paypal.createSubscription({
83
+ planId: 'P-xxxxx'
84
+ // returnUrl becomes https://domain.com/payments/paypal/subscription/verify
85
+ // cancelUrl becomes https://domain.com/payments/cancel
86
+ });
87
+
88
+ // or override manually
89
+ const sub = await payments.paypal.createSubscription({
90
+ planId: 'P-xxxxx',
91
+ returnUrl: 'https://custom.com/success',
92
+ cancelUrl: 'https://custom.com/cancel'
93
+ });
94
+
95
+ // redirect to sub.approvalUrl
96
+ // also returns sub.subscriptionId
97
+
98
+ // manually verify subscription
99
+ const result = await payments.paypal.verifySubscription(subscriptionId);
100
+ // result.status === 'ACTIVE'
101
+
102
+ // cancel subscription
103
+ await payments.paypal.cancelSubscription(subscriptionId, 'reason');
104
+ ```
105
+
106
+ ------
107
+
108
+ ## Coinbase Usage
109
+
110
+ ### Creating a charge
111
+
112
+ ```js
113
+ // redirectUrl and cancelUrl are auto-generated
114
+ const charge = await payments.coinbase.createCharge({
115
+ title: 'product name',
116
+ price: 19.99
117
+ // redirectUrl becomes https://domain.com/payments/coinbase/verify
118
+ // cancelUrl becomes https://domain.com/payments/cancel
119
+ });
120
+
121
+ // or override manually
122
+ const charge = await payments.coinbase.createCharge({
123
+ title: 'product name',
124
+ price: 19.99,
125
+ redirectUrl: 'https://custom.com/success',
126
+ cancelUrl: 'https://custom.com/cancel',
127
+ metadata: { userId: '123' }
128
+ });
129
+
130
+ // redirect to: charge.hostedUrl
131
+
132
+ // manually verify
133
+ const result = await payments.coinbase.verifyCharge(chargeCode);
134
+ // result.status === 'COMPLETED'
135
+ ```
136
+
137
+ ------
138
+
139
+ ## Routes
140
+
141
+ dSyncPay automatically creates verification routes for handling payment returns as well to make the entire payment process as simple and straight forward as possible.
142
+
143
+ ### PayPal
144
+ * `GET /payments/paypal/verify?token=xxx`
145
+ * `GET /payments/paypal/subscription/verify?subscription_id=xxx`
146
+ * `GET /payments/cancel`
147
+
148
+ ### Coinbase
149
+ * `GET /payments/coinbase/verify?code=xxx`
150
+ * `POST /payments/webhook/coinbase` (if webhookSecret set)
151
+ * `GET /payments/cancel`
152
+
153
+ ### Usage Example
154
+
155
+ ```javascript
156
+ const order = await payments.paypal.createOrder({
157
+ title: 'premium plan',
158
+ price: 19.99
159
+ });
160
+
161
+ // redirect user to order.approvalUrl
162
+ // paypal redirects back to /payments/paypal/verify?token=XXX
163
+ // route automatically verifies and triggers onPaymentCompleted
164
+ ```
165
+
166
+ ### Custom Base Path
167
+
168
+ ```javascript
169
+ const payments = new dSyncPay({
170
+ app,
171
+ domain: 'https://domain.com',
172
+ basePath: '/api/pay', // default is '/payments'
173
+ paypal: { ... }
174
+ });
175
+
176
+ // routes: /api/pay/paypal/verify, /api/pay/coinbase/verify, etc.
177
+ // auto urls: https://domain.com/api/pay/paypal/verify
178
+ ```
package/index.mjs ADDED
@@ -0,0 +1,755 @@
1
+ import crypto from "crypto";
2
+
3
+ export default class dSyncPay {
4
+ constructor({
5
+ app = null,
6
+ domain = null,
7
+ basePath = '/payments',
8
+ paypal = null,
9
+ coinbase = null,
10
+ onPaymentCreated = null,
11
+ onPaymentCompleted = null,
12
+ onPaymentFailed = null,
13
+ onPaymentCancelled = null,
14
+ onSubscriptionCreated = null,
15
+ onSubscriptionActivated = null,
16
+ onSubscriptionCancelled = null,
17
+ onError = null
18
+ } = {}) {
19
+ if (!app) throw new Error("missing express app instance");
20
+ if (!domain) throw new Error("missing domain");
21
+
22
+ this.app = app;
23
+ this.domain = domain.endsWith('/') ? domain.slice(0, -1) : domain;
24
+ this.basePath = basePath;
25
+
26
+ this.callbacks = {
27
+ onPaymentCreated,
28
+ onPaymentCompleted,
29
+ onPaymentFailed,
30
+ onPaymentCancelled,
31
+ onSubscriptionCreated,
32
+ onSubscriptionActivated,
33
+ onSubscriptionCancelled,
34
+ onError
35
+ };
36
+
37
+ if (paypal) {
38
+ if (!paypal.clientId) throw new Error("missing paypal.clientId");
39
+ if (!paypal.clientSecret) throw new Error("missing paypal.clientSecret");
40
+ this.paypal = new this.PayPal(this, paypal);
41
+ }
42
+
43
+ if (coinbase) {
44
+ if (!coinbase.apiKey) throw new Error("missing coinbase.apiKey");
45
+ this.coinbase = new this.Coinbase(this, coinbase);
46
+ }
47
+
48
+ this.registerRoutes(basePath);
49
+ }
50
+
51
+ getUrl(path) {
52
+ return `${this.domain}${this.basePath}${path}`;
53
+ }
54
+
55
+ emit(event, data) {
56
+ const callback = this.callbacks[event];
57
+ if (callback) {
58
+ try {
59
+ callback(data);
60
+ } catch (err) {
61
+ console.error("callback error:", err);
62
+ }
63
+ }
64
+ }
65
+
66
+ generateId(length = 17) {
67
+ let id = '';
68
+ for (let i = 0; i < length; i++) {
69
+ id += Math.floor(Math.random() * 10);
70
+ }
71
+ return id;
72
+ }
73
+
74
+ sha256(data) {
75
+ return crypto.createHash("sha256").update(data).digest("hex");
76
+ }
77
+
78
+ async request(url, options = {}) {
79
+ const {
80
+ method = 'GET',
81
+ headers = {},
82
+ body = null,
83
+ auth = null,
84
+ params = null
85
+ } = options;
86
+
87
+ let finalUrl = url;
88
+ if (params) {
89
+ const query = new URLSearchParams(params).toString();
90
+ finalUrl = `${url}?${query}`;
91
+ }
92
+
93
+ const fetchOptions = {
94
+ method,
95
+ headers: {...headers}
96
+ };
97
+
98
+ if (auth) {
99
+ const credentials = Buffer.from(`${auth.username}:${auth.password}`).toString('base64');
100
+ fetchOptions.headers['Authorization'] = `Basic ${credentials}`;
101
+ }
102
+
103
+ if (body) {
104
+ fetchOptions.body = typeof body === 'string' ? body : JSON.stringify(body);
105
+ if (!fetchOptions.headers['Content-Type']) {
106
+ fetchOptions.headers['Content-Type'] = 'application/json';
107
+ }
108
+ }
109
+
110
+ const response = await fetch(finalUrl, fetchOptions);
111
+ const text = await response.text();
112
+
113
+ if (!response.ok) {
114
+ const error = new Error(`http error: ${response.status}`);
115
+ error.status = response.status;
116
+ error.response = text ? JSON.parse(text) : null;
117
+ throw error;
118
+ }
119
+
120
+ return text ? JSON.parse(text) : null;
121
+ }
122
+
123
+ PayPal = class {
124
+ constructor(parent, config) {
125
+ this.parent = parent;
126
+ this.config = config;
127
+ this.baseUrl = config.sandbox
128
+ ? 'https://api-m.sandbox.paypal.com'
129
+ : 'https://api-m.paypal.com';
130
+ this.tokenCache = null;
131
+ this.tokenExpiry = null;
132
+ }
133
+
134
+ async getAccessToken() {
135
+ if (this.tokenCache && this.tokenExpiry > Date.now()) {
136
+ return this.tokenCache;
137
+ }
138
+
139
+ try {
140
+ const response = await this.parent.request(
141
+ `${this.baseUrl}/v1/oauth2/token`,
142
+ {
143
+ method: 'POST',
144
+ auth: {
145
+ username: this.config.clientId,
146
+ password: this.config.clientSecret
147
+ },
148
+ params: {
149
+ grant_type: 'client_credentials'
150
+ },
151
+ headers: {
152
+ "Accept": "application/json",
153
+ "Accept-Language": "en_US"
154
+ }
155
+ }
156
+ );
157
+
158
+ this.tokenCache = response.access_token;
159
+ this.tokenExpiry = Date.now() + (60 * 60 * 1000);
160
+ return this.tokenCache;
161
+ } catch (error) {
162
+ this.parent.emit('onError', {
163
+ type: 'auth',
164
+ provider: 'paypal',
165
+ error: error.response || error.message
166
+ });
167
+ throw error;
168
+ }
169
+ }
170
+
171
+ async createOrder({
172
+ title,
173
+ description = 'no description',
174
+ price,
175
+ quantity = 1,
176
+ currency = 'EUR',
177
+ returnUrl = this.parent.getUrl('/paypal/verify'),
178
+ cancelUrl = this.parent.getUrl('/cancel'),
179
+ customId = this.parent.generateId(),
180
+ metadata = {}
181
+ }) {
182
+ if (!title) throw new Error('missing title');
183
+ if (!price) throw new Error('missing price');
184
+
185
+ const accessToken = await this.getAccessToken();
186
+ const totalAmount = (price * quantity).toFixed(2);
187
+
188
+ const orderPayload = {
189
+ intent: "CAPTURE",
190
+ purchase_units: [{
191
+ amount: {
192
+ currency_code: currency,
193
+ value: totalAmount,
194
+ breakdown: {
195
+ item_total: {
196
+ currency_code: currency,
197
+ value: totalAmount
198
+ }
199
+ }
200
+ },
201
+ items: [{
202
+ name: title,
203
+ description: description,
204
+ unit_amount: {
205
+ currency_code: currency,
206
+ value: price.toFixed(2)
207
+ },
208
+ quantity: `${quantity}`
209
+ }],
210
+ custom_id: customId
211
+ }],
212
+ application_context: {
213
+ return_url: returnUrl,
214
+ cancel_url: cancelUrl
215
+ }
216
+ };
217
+
218
+ try {
219
+ const response = await this.parent.request(
220
+ `${this.baseUrl}/v2/checkout/orders`,
221
+ {
222
+ method: 'POST',
223
+ headers: {
224
+ "Content-Type": "application/json",
225
+ "Authorization": `Bearer ${accessToken}`
226
+ },
227
+ body: orderPayload
228
+ }
229
+ );
230
+
231
+ const approvalUrl = response.links.find(link => link.rel === "approve").href;
232
+
233
+ const result = {
234
+ provider: 'paypal',
235
+ type: 'order',
236
+ approvalUrl,
237
+ transactionId: customId,
238
+ orderId: response.id,
239
+ amount: parseFloat(totalAmount),
240
+ currency,
241
+ metadata,
242
+ rawResponse: response
243
+ };
244
+
245
+ this.parent.emit('onPaymentCreated', result);
246
+ return result;
247
+ } catch (error) {
248
+ this.parent.emit('onError', {
249
+ type: 'order_creation',
250
+ provider: 'paypal',
251
+ error: error.response || error.message
252
+ });
253
+ throw error;
254
+ }
255
+ }
256
+
257
+ async verifyOrder(orderId) {
258
+ const accessToken = await this.getAccessToken();
259
+
260
+ try {
261
+ const orderResponse = await this.parent.request(
262
+ `${this.baseUrl}/v2/checkout/orders/${orderId}`,
263
+ {
264
+ headers: {
265
+ "Authorization": `Bearer ${accessToken}`
266
+ }
267
+ }
268
+ );
269
+
270
+ const orderStatus = orderResponse.status;
271
+
272
+ if (orderStatus === "APPROVED") {
273
+ await this.parent.request(
274
+ `${this.baseUrl}/v2/checkout/orders/${orderId}/capture`,
275
+ {
276
+ method: 'POST',
277
+ headers: {
278
+ "Authorization": `Bearer ${accessToken}`
279
+ },
280
+ body: {}
281
+ }
282
+ );
283
+ }
284
+
285
+ const purchaseUnit = orderResponse.purchase_units[0];
286
+
287
+ const result = {
288
+ provider: 'paypal',
289
+ type: 'order',
290
+ status: orderStatus,
291
+ transactionId: purchaseUnit.custom_id,
292
+ orderId: orderResponse.id,
293
+ amount: parseFloat(purchaseUnit.amount.value),
294
+ currency: purchaseUnit.amount.currency_code,
295
+ rawResponse: orderResponse
296
+ };
297
+
298
+ if (orderStatus === 'COMPLETED') {
299
+ this.parent.emit('onPaymentCompleted', result);
300
+ } else if (orderStatus === 'VOIDED') {
301
+ this.parent.emit('onPaymentCancelled', result);
302
+ }
303
+
304
+ return result;
305
+ } catch (error) {
306
+ this.parent.emit('onError', {
307
+ type: 'order_verification',
308
+ provider: 'paypal',
309
+ orderId,
310
+ error: error.response || error.message
311
+ });
312
+ throw error;
313
+ }
314
+ }
315
+
316
+ async createProduct(name, description) {
317
+ const accessToken = await this.getAccessToken();
318
+
319
+ const productData = {
320
+ name: name,
321
+ description: description || name,
322
+ type: "SERVICE",
323
+ category: "SOFTWARE"
324
+ };
325
+
326
+ const response = await this.parent.request(
327
+ `${this.baseUrl}/v1/catalogs/products`,
328
+ {
329
+ method: 'POST',
330
+ headers: {
331
+ "Content-Type": "application/json",
332
+ "Authorization": `Bearer ${accessToken}`
333
+ },
334
+ body: productData
335
+ }
336
+ );
337
+
338
+ return response.id;
339
+ }
340
+
341
+ async createPlan({
342
+ name,
343
+ description,
344
+ price,
345
+ currency = 'EUR',
346
+ interval = 'MONTH',
347
+ frequency = 1
348
+ }) {
349
+ if (!name) throw new Error('missing name');
350
+ if (!price) throw new Error('missing price');
351
+
352
+ const accessToken = await this.getAccessToken();
353
+ const productId = await this.createProduct(name, description);
354
+
355
+ const planData = {
356
+ product_id: productId,
357
+ name: name,
358
+ description: description || name,
359
+ billing_cycles: [{
360
+ frequency: {
361
+ interval_unit: interval,
362
+ interval_count: frequency
363
+ },
364
+ tenure_type: "REGULAR",
365
+ sequence: 1,
366
+ total_cycles: 0,
367
+ pricing_scheme: {
368
+ fixed_price: {
369
+ value: price.toFixed(2),
370
+ currency_code: currency
371
+ }
372
+ }
373
+ }],
374
+ payment_preferences: {
375
+ auto_bill_outstanding: true,
376
+ payment_failure_threshold: 3
377
+ }
378
+ };
379
+
380
+ try {
381
+ const response = await this.parent.request(
382
+ `${this.baseUrl}/v1/billing/plans`,
383
+ {
384
+ method: 'POST',
385
+ headers: {
386
+ "Content-Type": "application/json",
387
+ "Authorization": `Bearer ${accessToken}`
388
+ },
389
+ body: planData
390
+ }
391
+ );
392
+
393
+ return {
394
+ provider: 'paypal',
395
+ type: 'subscription_plan',
396
+ planId: response.id,
397
+ name,
398
+ price: parseFloat(price),
399
+ currency,
400
+ interval,
401
+ frequency,
402
+ rawResponse: response
403
+ };
404
+ } catch (error) {
405
+ this.parent.emit('onError', {
406
+ type: 'plan_creation',
407
+ provider: 'paypal',
408
+ error: error.response || error.message
409
+ });
410
+ throw error;
411
+ }
412
+ }
413
+
414
+ async createSubscription({
415
+ planId,
416
+ returnUrl = this.parent.getUrl('/paypal/subscription/verify'),
417
+ cancelUrl = this.parent.getUrl('/cancel'),
418
+ customId = this.parent.generateId(),
419
+ metadata = {}
420
+ }) {
421
+ if (!planId) throw new Error('missing planId');
422
+
423
+ const accessToken = await this.getAccessToken();
424
+
425
+ const subscriptionData = {
426
+ plan_id: planId,
427
+ custom_id: customId,
428
+ application_context: {
429
+ return_url: returnUrl,
430
+ cancel_url: cancelUrl
431
+ }
432
+ };
433
+
434
+ try {
435
+ const response = await this.parent.request(
436
+ `${this.baseUrl}/v1/billing/subscriptions`,
437
+ {
438
+ method: 'POST',
439
+ headers: {
440
+ "Content-Type": "application/json",
441
+ "Authorization": `Bearer ${accessToken}`
442
+ },
443
+ body: subscriptionData
444
+ }
445
+ );
446
+
447
+ const approvalUrl = response.links.find(link => link.rel === "approve").href;
448
+
449
+ const result = {
450
+ provider: 'paypal',
451
+ type: 'subscription',
452
+ approvalUrl,
453
+ transactionId: customId,
454
+ subscriptionId: response.id,
455
+ planId,
456
+ metadata,
457
+ rawResponse: response
458
+ };
459
+
460
+ this.parent.emit('onSubscriptionCreated', result);
461
+ return result;
462
+ } catch (error) {
463
+ this.parent.emit('onError', {
464
+ type: 'subscription_creation',
465
+ provider: 'paypal',
466
+ error: error.response || error.message
467
+ });
468
+ throw error;
469
+ }
470
+ }
471
+
472
+ async verifySubscription(subscriptionId) {
473
+ const accessToken = await this.getAccessToken();
474
+
475
+ try {
476
+ const response = await this.parent.request(
477
+ `${this.baseUrl}/v1/billing/subscriptions/${subscriptionId}`,
478
+ {
479
+ headers: {
480
+ "Authorization": `Bearer ${accessToken}`
481
+ }
482
+ }
483
+ );
484
+
485
+ const result = {
486
+ provider: 'paypal',
487
+ type: 'subscription',
488
+ status: response.status,
489
+ subscriptionId: response.id,
490
+ planId: response.plan_id,
491
+ customId: response.custom_id,
492
+ rawResponse: response
493
+ };
494
+
495
+ if (response.status === 'ACTIVE') {
496
+ this.parent.emit('onSubscriptionActivated', result);
497
+ } else if (response.status === 'CANCELLED') {
498
+ this.parent.emit('onSubscriptionCancelled', result);
499
+ }
500
+
501
+ return result;
502
+ } catch (error) {
503
+ this.parent.emit('onError', {
504
+ type: 'subscription_verification',
505
+ provider: 'paypal',
506
+ subscriptionId,
507
+ error: error.response || error.message
508
+ });
509
+ throw error;
510
+ }
511
+ }
512
+
513
+ async cancelSubscription(subscriptionId, reason = 'customer request') {
514
+ const accessToken = await this.getAccessToken();
515
+
516
+ try {
517
+ await this.parent.request(
518
+ `${this.baseUrl}/v1/billing/subscriptions/${subscriptionId}/cancel`,
519
+ {
520
+ method: 'POST',
521
+ headers: {
522
+ "Content-Type": "application/json",
523
+ "Authorization": `Bearer ${accessToken}`
524
+ },
525
+ body: {reason}
526
+ }
527
+ );
528
+
529
+ const result = {
530
+ provider: 'paypal',
531
+ type: 'subscription',
532
+ subscriptionId,
533
+ status: 'CANCELLED',
534
+ reason
535
+ };
536
+
537
+ this.parent.emit('onSubscriptionCancelled', result);
538
+ return result;
539
+ } catch (error) {
540
+ this.parent.emit('onError', {
541
+ type: 'subscription_cancellation',
542
+ provider: 'paypal',
543
+ subscriptionId,
544
+ error: error.response || error.message
545
+ });
546
+ throw error;
547
+ }
548
+ }
549
+ }
550
+
551
+ Coinbase = class {
552
+ constructor(parent, config) {
553
+ this.parent = parent;
554
+ this.config = config;
555
+ this.baseUrl = 'https://api.commerce.coinbase.com';
556
+ }
557
+
558
+ async createCharge({
559
+ title,
560
+ description = 'no description',
561
+ price,
562
+ quantity = 1,
563
+ currency = 'EUR',
564
+ redirectUrl = this.parent.getUrl('/coinbase/verify'),
565
+ cancelUrl = this.parent.getUrl('/cancel'),
566
+ metadata = {}
567
+ }) {
568
+ if (!title) throw new Error('missing title');
569
+ if (!price) throw new Error('missing price');
570
+
571
+ const totalAmount = (price * quantity).toFixed(2);
572
+
573
+ const chargeData = {
574
+ name: title,
575
+ description: description,
576
+ pricing_type: "fixed_price",
577
+ metadata: metadata,
578
+ local_price: {
579
+ amount: totalAmount,
580
+ currency: currency
581
+ },
582
+ redirect_url: redirectUrl,
583
+ cancel_url: cancelUrl
584
+ };
585
+
586
+ try {
587
+ const response = await this.parent.request(
588
+ `${this.baseUrl}/charges`,
589
+ {
590
+ method: 'POST',
591
+ headers: {
592
+ "Content-Type": "application/json",
593
+ "X-CC-Api-Key": this.config.apiKey,
594
+ "X-CC-Version": "2018-03-22"
595
+ },
596
+ body: chargeData
597
+ }
598
+ );
599
+
600
+ const charge = response.data;
601
+
602
+ const result = {
603
+ provider: 'coinbase',
604
+ type: 'charge',
605
+ hostedUrl: charge.hosted_url,
606
+ chargeId: charge.id,
607
+ chargeCode: charge.code,
608
+ amount: parseFloat(totalAmount),
609
+ currency,
610
+ metadata,
611
+ rawResponse: charge
612
+ };
613
+
614
+ this.parent.emit('onPaymentCreated', result);
615
+ return result;
616
+ } catch (error) {
617
+ this.parent.emit('onError', {
618
+ type: 'charge_creation',
619
+ provider: 'coinbase',
620
+ error: error.response || error.message
621
+ });
622
+ throw error;
623
+ }
624
+ }
625
+
626
+ async verifyCharge(chargeId) {
627
+ try {
628
+ const response = await this.parent.request(
629
+ `${this.baseUrl}/charges/${chargeId}`,
630
+ {
631
+ headers: {
632
+ "Content-Type": "application/json",
633
+ "X-CC-Api-Key": this.config.apiKey,
634
+ "X-CC-Version": "2018-03-22"
635
+ }
636
+ }
637
+ );
638
+
639
+ const charge = response.data;
640
+ const latestStatus = charge.timeline[charge.timeline.length - 1]?.status;
641
+
642
+ const result = {
643
+ provider: 'coinbase',
644
+ type: 'charge',
645
+ status: latestStatus,
646
+ chargeId: charge.id,
647
+ chargeCode: charge.code,
648
+ amount: parseFloat(charge.pricing.local.amount),
649
+ currency: charge.pricing.local.currency,
650
+ metadata: charge.metadata,
651
+ rawResponse: charge
652
+ };
653
+
654
+ if (latestStatus === 'COMPLETED') {
655
+ this.parent.emit('onPaymentCompleted', result);
656
+ } else if (latestStatus === 'CANCELED') {
657
+ this.parent.emit('onPaymentCancelled', result);
658
+ } else if (latestStatus === 'EXPIRED' || latestStatus === 'UNRESOLVED') {
659
+ this.parent.emit('onPaymentFailed', result);
660
+ }
661
+
662
+ return result;
663
+ } catch (error) {
664
+ this.parent.emit('onError', {
665
+ type: 'charge_verification',
666
+ provider: 'coinbase',
667
+ chargeId,
668
+ error: error.response || error.message
669
+ });
670
+ throw error;
671
+ }
672
+ }
673
+
674
+ verifyWebhook(payload, signature, secret) {
675
+ const hmac = crypto.createHmac('sha256', secret);
676
+ hmac.update(payload);
677
+ const computedSignature = hmac.digest('hex');
678
+ return computedSignature === signature;
679
+ }
680
+ }
681
+
682
+ registerRoutes(basePath = '/payments') {
683
+ if (this.paypal) {
684
+ this.app.get(`${basePath}/paypal/verify`, async (req, res) => {
685
+ try {
686
+ const orderId = req.query.token;
687
+ if (!orderId) return res.status(400).json({ok: false, error: 'missing_token'});
688
+
689
+ const result = await this.paypal.verifyOrder(orderId);
690
+ res.json({ok: true, ...result});
691
+ } catch (error) {
692
+ res.status(500).json({ok: false, error: error.message});
693
+ }
694
+ });
695
+
696
+ this.app.get(`${basePath}/paypal/subscription/verify`, async (req, res) => {
697
+ try {
698
+ const subscriptionId = req.query.subscription_id;
699
+ if (!subscriptionId) return res.status(400).json({ok: false, error: 'missing_subscription_id'});
700
+
701
+ const result = await this.paypal.verifySubscription(subscriptionId);
702
+ res.json({ok: true, ...result});
703
+ } catch (error) {
704
+ res.status(500).json({ok: false, error: error.message});
705
+ }
706
+ });
707
+
708
+ this.app.get(`${basePath}/cancel`, async (req, res) => {
709
+ res.send('payment cancelled');
710
+ });
711
+ }
712
+
713
+ if (this.coinbase) {
714
+ this.app.get(`${basePath}/coinbase/verify`, async (req, res) => {
715
+ try {
716
+ const chargeCode = req.query.code;
717
+ if (!chargeCode) return res.status(400).json({ok: false, error: 'missing_code'});
718
+
719
+ const result = await this.coinbase.verifyCharge(chargeCode);
720
+ res.json({ok: true, ...result});
721
+ } catch (error) {
722
+ res.status(500).json({ok: false, error: error.message});
723
+ }
724
+ });
725
+
726
+ if (this.coinbase.config.webhookSecret) {
727
+ this.app.post(`${basePath}/webhook/coinbase`, async (req, res) => {
728
+ try {
729
+ const signature = req.headers['x-cc-webhook-signature'];
730
+ const isValid = this.coinbase.verifyWebhook(
731
+ JSON.stringify(req.body),
732
+ signature,
733
+ this.coinbase.config.webhookSecret
734
+ );
735
+
736
+ if (!isValid) return res.status(401).json({ok: false, error: 'invalid_signature'});
737
+
738
+ const event = req.body;
739
+
740
+ if (event.event.type === 'charge:confirmed') {
741
+ const chargeId = event.event.data.id;
742
+ const result = await this.coinbase.verifyCharge(chargeId);
743
+ }
744
+
745
+ res.status(200).json({ok: true});
746
+ } catch (error) {
747
+ res.status(500).json({ok: false, error: 'webhook_error'});
748
+ }
749
+ });
750
+ }
751
+ }
752
+
753
+ return this;
754
+ }
755
+ }
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "@hackthedev/dsync-pay",
3
+ "version": "1.0.0",
4
+ "description": "As another part of the dSync library family this library is responsible for payment handling currently supporting PayPal and Coinbase Crypto payments. Its works independently and without any percentage cuts by using your own API keys.",
5
+ "homepage": "https://github.com/NETWORK-Z-Dev/dSyncPay#readme",
6
+ "bugs": {
7
+ "url": "https://github.com/NETWORK-Z-Dev/dSyncPay/issues"
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/NETWORK-Z-Dev/dSyncPay.git"
12
+ },
13
+ "license": "ISC",
14
+ "author": "",
15
+ "main": "index.mjs",
16
+ "scripts": {
17
+ "test": "echo \"Error: no test specified\" && exit 1"
18
+ }
19
+ }