@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 +2 -0
- package/.github/workflows/publish.yml +43 -0
- package/README.md +178 -0
- package/index.mjs +755 -0
- package/package.json +19 -0
package/.gitattributes
ADDED
|
@@ -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
|
+
}
|