@storecraft/payments-stripe 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 +82 -0
- package/adapter.html.js +311 -0
- package/adapter.js +410 -0
- package/index.js +1 -0
- package/package.json +35 -0
- package/tests/storage.test.js +11 -0
- package/tsconfig.json +14 -0
- package/types.public.d.ts +37 -0
package/README.md
ADDED
@@ -0,0 +1,82 @@
|
|
1
|
+
# Stripe payment gateway for **StoreCraft**
|
2
|
+
|
3
|
+
[Stripe](https://docs.stripe.com/payments/place-a-hold-on-a-payment-method) integration
|
4
|
+
|
5
|
+
|
6
|
+
```bash
|
7
|
+
npm i @storecraft/payments-stripe
|
8
|
+
```
|
9
|
+
|
10
|
+
## Howto
|
11
|
+
|
12
|
+
```js
|
13
|
+
import { Stripe } from '@storecraft/payments-stripe';
|
14
|
+
import { Stripe as StripeCls } from 'stripe';
|
15
|
+
|
16
|
+
const config = {
|
17
|
+
//`stripe` publishable key
|
18
|
+
publishable_key: 'pk_....',
|
19
|
+
|
20
|
+
// `stripe` private secret
|
21
|
+
secret: 'sk_.....',
|
22
|
+
|
23
|
+
// (Optional) `stripe` private `webhook` secret
|
24
|
+
webhook_endpoint_secret: 'whsec_.....',
|
25
|
+
|
26
|
+
// config options for `stripe`
|
27
|
+
stripe_config: {
|
28
|
+
httpClient: StripeCls.createFetchHttpClient()
|
29
|
+
},
|
30
|
+
|
31
|
+
// configure `intent` creation
|
32
|
+
stripe_intent_create_params: {
|
33
|
+
currency: 'usd',
|
34
|
+
automatic_payment_methods: {
|
35
|
+
enabled: true,
|
36
|
+
},
|
37
|
+
payment_method_options: {
|
38
|
+
card: {
|
39
|
+
// authorize and capture flow
|
40
|
+
capture_method: 'manual',
|
41
|
+
},
|
42
|
+
},
|
43
|
+
}
|
44
|
+
}
|
45
|
+
|
46
|
+
new Stripe(config);
|
47
|
+
```
|
48
|
+
|
49
|
+
## Developer info and test
|
50
|
+
|
51
|
+
First, some resources from `stripe`
|
52
|
+
|
53
|
+
- [Authorize and Capture Flow](https://docs.stripe.com/payments/place-a-hold-on-a-payment-method)
|
54
|
+
- [Webhooks](https://docs.stripe.com/webhooks)
|
55
|
+
- [Credit Card Generator](https://developer.paypal.com/tools/sandbox/card-testing/#link-creditcardgenerator)
|
56
|
+
|
57
|
+
## Test Webhooks
|
58
|
+
First, consult [Stripe Webhooks Docs](https://docs.stripe.com/webhooks)
|
59
|
+
Then, Install the `stripe` cli.
|
60
|
+
|
61
|
+
```bash
|
62
|
+
stripe listen --skip-verify --forward-to localhost:8000/api/payments/gateways/stripe/webhook
|
63
|
+
```
|
64
|
+
|
65
|
+
This will print the `webhook` **SECRET**
|
66
|
+
|
67
|
+
```bash
|
68
|
+
Ready! Your webhook signing secret is '{{WEBHOOK_SIGNING_SECRET}}' (^C to quit)
|
69
|
+
```
|
70
|
+
|
71
|
+
Copy the `WEBHOOK_SIGNING_SECRET` and put it in the `config` of the gateway.
|
72
|
+
|
73
|
+
Now, start interacting, and test some payments.
|
74
|
+
|
75
|
+
|
76
|
+
## todo:
|
77
|
+
- Add tests
|
78
|
+
|
79
|
+
|
80
|
+
```text
|
81
|
+
Author: Tomer Shalev (tomer.shalev@gmail.com)
|
82
|
+
```
|
package/adapter.html.js
ADDED
@@ -0,0 +1,311 @@
|
|
1
|
+
/**
|
2
|
+
*
|
3
|
+
* @description Official `Stripe` UI integration with `storecraft`.
|
4
|
+
*
|
5
|
+
* Test with dummy data with this generator
|
6
|
+
* https://docs.stripe.com/payments/quickstart
|
7
|
+
*
|
8
|
+
* Or use the following dummy details:
|
9
|
+
* https://docs.stripe.com/testing?testing-method=card-numbers
|
10
|
+
* - Card number: 4242424242424242
|
11
|
+
* - Expiry date: 12/2027
|
12
|
+
* - CVC code: 897
|
13
|
+
*
|
14
|
+
* @param {import("./types.public.js").Config} config
|
15
|
+
* @param {Partial<import("@storecraft/core/v-api").OrderData>} order_data
|
16
|
+
*/
|
17
|
+
export default function html_buy_ui(config, order_data) {
|
18
|
+
|
19
|
+
/** @type {import("./adapter.js").CheckoutCreateResult} */
|
20
|
+
const on_checkout_create = order_data?.payment_gateway?.on_checkout_create;
|
21
|
+
|
22
|
+
return `
|
23
|
+
<!DOCTYPE html>
|
24
|
+
<html lang="en">
|
25
|
+
<head>
|
26
|
+
<meta charset="utf-8" />
|
27
|
+
<title>Accept a payment</title>
|
28
|
+
<meta name="description" content="A demo of a payment on Stripe" />
|
29
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
30
|
+
<script src="https://js.stripe.com/v3/"></script>
|
31
|
+
|
32
|
+
<script defer>
|
33
|
+
// This is your test publishable API key.
|
34
|
+
const stripe = new Stripe("${config.publishable_key}");
|
35
|
+
|
36
|
+
let elements;
|
37
|
+
|
38
|
+
window.onload = function() {
|
39
|
+
initialize();
|
40
|
+
checkStatus();
|
41
|
+
|
42
|
+
document
|
43
|
+
.getElementById("payment-form")
|
44
|
+
.addEventListener("submit", handleSubmit);
|
45
|
+
}
|
46
|
+
|
47
|
+
// Fetches a payment intent and captures the client secret
|
48
|
+
async function initialize() {
|
49
|
+
const clientSecret = "${on_checkout_create.client_secret}";
|
50
|
+
|
51
|
+
const appearance = {
|
52
|
+
theme: 'stripe',
|
53
|
+
};
|
54
|
+
elements = stripe.elements({ appearance, clientSecret });
|
55
|
+
|
56
|
+
const paymentElementOptions = {
|
57
|
+
layout: "tabs",
|
58
|
+
};
|
59
|
+
|
60
|
+
const paymentElement = elements.create("payment", paymentElementOptions);
|
61
|
+
paymentElement.mount("#payment-element");
|
62
|
+
}
|
63
|
+
|
64
|
+
async function handleSubmit(e) {
|
65
|
+
e.preventDefault();
|
66
|
+
setLoading(true);
|
67
|
+
|
68
|
+
const { error } = await stripe.confirmPayment({
|
69
|
+
elements,
|
70
|
+
confirmParams: {
|
71
|
+
// Make sure to change this to your payment completion page
|
72
|
+
return_url: "https://storecraft.app/",
|
73
|
+
},
|
74
|
+
});
|
75
|
+
|
76
|
+
// This point will only be reached if there is an immediate error when
|
77
|
+
// confirming the payment. Otherwise, your customer will be redirected to
|
78
|
+
// your \`return_url\`. For some payment methods like iDEAL, your customer will
|
79
|
+
// be redirected to an intermediate site first to authorize the payment, then
|
80
|
+
// redirected to the \`return_url\`.
|
81
|
+
if (error.type === "card_error" || error.type === "validation_error") {
|
82
|
+
showMessage(error.message);
|
83
|
+
} else {
|
84
|
+
showMessage("An unexpected error occurred.");
|
85
|
+
}
|
86
|
+
|
87
|
+
setLoading(false);
|
88
|
+
}
|
89
|
+
|
90
|
+
// Fetches the payment intent status after payment submission
|
91
|
+
async function checkStatus() {
|
92
|
+
const clientSecret = new URLSearchParams(window.location.search).get(
|
93
|
+
"payment_intent_client_secret"
|
94
|
+
);
|
95
|
+
|
96
|
+
if (!clientSecret) {
|
97
|
+
return;
|
98
|
+
}
|
99
|
+
|
100
|
+
const { paymentIntent } = await stripe.retrievePaymentIntent(clientSecret);
|
101
|
+
|
102
|
+
switch (paymentIntent.status) {
|
103
|
+
case "succeeded":
|
104
|
+
showMessage("Payment succeeded!");
|
105
|
+
break;
|
106
|
+
case "processing":
|
107
|
+
showMessage("Your payment is processing.");
|
108
|
+
break;
|
109
|
+
case "requires_payment_method":
|
110
|
+
showMessage("Your payment was not successful, please try again.");
|
111
|
+
break;
|
112
|
+
default:
|
113
|
+
showMessage("Something went wrong.");
|
114
|
+
break;
|
115
|
+
}
|
116
|
+
}
|
117
|
+
|
118
|
+
// ------- UI helpers -------
|
119
|
+
|
120
|
+
function showMessage(messageText) {
|
121
|
+
const messageContainer = document.querySelector("#payment-message");
|
122
|
+
|
123
|
+
messageContainer.classList.remove("hidden");
|
124
|
+
messageContainer.textContent = messageText;
|
125
|
+
|
126
|
+
setTimeout(function () {
|
127
|
+
messageContainer.classList.add("hidden");
|
128
|
+
messageContainer.textContent = "";
|
129
|
+
}, 4000);
|
130
|
+
}
|
131
|
+
|
132
|
+
// Show a spinner on payment submission
|
133
|
+
function setLoading(isLoading) {
|
134
|
+
if (isLoading) {
|
135
|
+
// Disable the button and show a spinner
|
136
|
+
document.querySelector("#submit").disabled = true;
|
137
|
+
document.querySelector("#spinner").classList.remove("hidden");
|
138
|
+
document.querySelector("#button-text").classList.add("hidden");
|
139
|
+
} else {
|
140
|
+
document.querySelector("#submit").disabled = false;
|
141
|
+
document.querySelector("#spinner").classList.add("hidden");
|
142
|
+
document.querySelector("#button-text").classList.remove("hidden");
|
143
|
+
}
|
144
|
+
}
|
145
|
+
|
146
|
+
</script>
|
147
|
+
|
148
|
+
<style>
|
149
|
+
/* Variables */
|
150
|
+
* {
|
151
|
+
box-sizing: border-box;
|
152
|
+
}
|
153
|
+
|
154
|
+
body {
|
155
|
+
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
|
156
|
+
font-size: 16px;
|
157
|
+
-webkit-font-smoothing: antialiased;
|
158
|
+
display: flex;
|
159
|
+
justify-content: center;
|
160
|
+
align-content: center;
|
161
|
+
height: 100vh;
|
162
|
+
width: 100vw;
|
163
|
+
}
|
164
|
+
|
165
|
+
form {
|
166
|
+
width: 30vw;
|
167
|
+
min-width: 500px;
|
168
|
+
align-self: center;
|
169
|
+
box-shadow: 0px 0px 0px 0.5px rgba(50, 50, 93, 0.1),
|
170
|
+
0px 2px 5px 0px rgba(50, 50, 93, 0.1), 0px 1px 1.5px 0px rgba(0, 0, 0, 0.07);
|
171
|
+
border-radius: 7px;
|
172
|
+
padding: 40px;
|
173
|
+
}
|
174
|
+
|
175
|
+
.hidden {
|
176
|
+
display: none;
|
177
|
+
}
|
178
|
+
|
179
|
+
#payment-message {
|
180
|
+
color: rgb(105, 115, 134);
|
181
|
+
font-size: 16px;
|
182
|
+
line-height: 20px;
|
183
|
+
padding-top: 12px;
|
184
|
+
text-align: center;
|
185
|
+
}
|
186
|
+
|
187
|
+
#payment-element {
|
188
|
+
margin-bottom: 24px;
|
189
|
+
}
|
190
|
+
|
191
|
+
/* Buttons and links */
|
192
|
+
button {
|
193
|
+
background: #5469d4;
|
194
|
+
font-family: Arial, sans-serif;
|
195
|
+
color: #ffffff;
|
196
|
+
border-radius: 4px;
|
197
|
+
border: 0;
|
198
|
+
padding: 12px 16px;
|
199
|
+
font-size: 16px;
|
200
|
+
font-weight: 600;
|
201
|
+
cursor: pointer;
|
202
|
+
display: block;
|
203
|
+
transition: all 0.2s ease;
|
204
|
+
box-shadow: 0px 4px 5.5px 0px rgba(0, 0, 0, 0.07);
|
205
|
+
width: 100%;
|
206
|
+
}
|
207
|
+
button:hover {
|
208
|
+
filter: contrast(115%);
|
209
|
+
}
|
210
|
+
button:disabled {
|
211
|
+
opacity: 0.5;
|
212
|
+
cursor: default;
|
213
|
+
}
|
214
|
+
|
215
|
+
/* spinner/processing state, errors */
|
216
|
+
.spinner,
|
217
|
+
.spinner:before,
|
218
|
+
.spinner:after {
|
219
|
+
border-radius: 50%;
|
220
|
+
}
|
221
|
+
.spinner {
|
222
|
+
color: #ffffff;
|
223
|
+
font-size: 22px;
|
224
|
+
text-indent: -99999px;
|
225
|
+
margin: 0px auto;
|
226
|
+
position: relative;
|
227
|
+
width: 20px;
|
228
|
+
height: 20px;
|
229
|
+
box-shadow: inset 0 0 0 2px;
|
230
|
+
-webkit-transform: translateZ(0);
|
231
|
+
-ms-transform: translateZ(0);
|
232
|
+
transform: translateZ(0);
|
233
|
+
}
|
234
|
+
.spinner:before,
|
235
|
+
.spinner:after {
|
236
|
+
position: absolute;
|
237
|
+
content: "";
|
238
|
+
}
|
239
|
+
.spinner:before {
|
240
|
+
width: 10.4px;
|
241
|
+
height: 20.4px;
|
242
|
+
background: #5469d4;
|
243
|
+
border-radius: 20.4px 0 0 20.4px;
|
244
|
+
top: -0.2px;
|
245
|
+
left: -0.2px;
|
246
|
+
-webkit-transform-origin: 10.4px 10.2px;
|
247
|
+
transform-origin: 10.4px 10.2px;
|
248
|
+
-webkit-animation: loading 2s infinite ease 1.5s;
|
249
|
+
animation: loading 2s infinite ease 1.5s;
|
250
|
+
}
|
251
|
+
.spinner:after {
|
252
|
+
width: 10.4px;
|
253
|
+
height: 10.2px;
|
254
|
+
background: #5469d4;
|
255
|
+
border-radius: 0 10.2px 10.2px 0;
|
256
|
+
top: -0.1px;
|
257
|
+
left: 10.2px;
|
258
|
+
-webkit-transform-origin: 0px 10.2px;
|
259
|
+
transform-origin: 0px 10.2px;
|
260
|
+
-webkit-animation: loading 2s infinite ease;
|
261
|
+
animation: loading 2s infinite ease;
|
262
|
+
}
|
263
|
+
|
264
|
+
@-webkit-keyframes loading {
|
265
|
+
0% {
|
266
|
+
-webkit-transform: rotate(0deg);
|
267
|
+
transform: rotate(0deg);
|
268
|
+
}
|
269
|
+
100% {
|
270
|
+
-webkit-transform: rotate(360deg);
|
271
|
+
transform: rotate(360deg);
|
272
|
+
}
|
273
|
+
}
|
274
|
+
@keyframes loading {
|
275
|
+
0% {
|
276
|
+
-webkit-transform: rotate(0deg);
|
277
|
+
transform: rotate(0deg);
|
278
|
+
}
|
279
|
+
100% {
|
280
|
+
-webkit-transform: rotate(360deg);
|
281
|
+
transform: rotate(360deg);
|
282
|
+
}
|
283
|
+
}
|
284
|
+
|
285
|
+
@media only screen and (max-width: 600px) {
|
286
|
+
form {
|
287
|
+
width: 80vw;
|
288
|
+
min-width: initial;
|
289
|
+
}
|
290
|
+
}
|
291
|
+
|
292
|
+
</style>
|
293
|
+
</head>
|
294
|
+
|
295
|
+
<body>
|
296
|
+
<!-- Display a payment form -->
|
297
|
+
<form id="payment-form">
|
298
|
+
<div id="payment-element">
|
299
|
+
<!--Stripe.js injects the Payment Element-->
|
300
|
+
</div>
|
301
|
+
<button id="submit">
|
302
|
+
<div class="spinner hidden" id="spinner"></div>
|
303
|
+
<span id="button-text">Pay now</span>
|
304
|
+
</button>
|
305
|
+
<div id="payment-message" class="hidden"></div>
|
306
|
+
</form>
|
307
|
+
</body>
|
308
|
+
|
309
|
+
</html>
|
310
|
+
`
|
311
|
+
}
|
package/adapter.js
ADDED
@@ -0,0 +1,410 @@
|
|
1
|
+
import { CheckoutStatusEnum, PaymentOptionsEnum } from '@storecraft/core/v-api/types.api.enums.js';
|
2
|
+
import { StorecraftError } from '@storecraft/core/v-api/utils.func.js';
|
3
|
+
import html_buy_ui from './adapter.html.js';
|
4
|
+
import { Stripe as StripeCls } from 'stripe'
|
5
|
+
|
6
|
+
/**
|
7
|
+
* @typedef {StripeCls.Response<StripeCls.PaymentIntent>} CheckoutCreateResult
|
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, CheckoutCreateResult>} payment_gateway
|
13
|
+
*/
|
14
|
+
|
15
|
+
/**
|
16
|
+
* @description in a {@link StripeCls.PaymentIntent}, is a `metadata` key-value
|
17
|
+
* storage where we store the `order_id` of the `storecraft` order.
|
18
|
+
*/
|
19
|
+
export const metadata_storecraft_order_id = 'storecraft_order_id'
|
20
|
+
|
21
|
+
/**
|
22
|
+
* @implements {payment_gateway}
|
23
|
+
*
|
24
|
+
* @description **Stripe** gateway (https://docs.stripe.com/payments/place-a-hold-on-a-payment-method)
|
25
|
+
*/
|
26
|
+
export class Stripe {
|
27
|
+
|
28
|
+
/** @type {Config} */ #_config;
|
29
|
+
|
30
|
+
/**
|
31
|
+
*
|
32
|
+
* @param {Config} config
|
33
|
+
*/
|
34
|
+
constructor(config) {
|
35
|
+
this.#_config = this.#validate_and_resolve_config(config);
|
36
|
+
this.stripe = new StripeCls(
|
37
|
+
this.#_config.secret_key, this.#_config.stripe_config ?? {}
|
38
|
+
);
|
39
|
+
}
|
40
|
+
|
41
|
+
/**
|
42
|
+
*
|
43
|
+
* @param {Config} config
|
44
|
+
*/
|
45
|
+
#validate_and_resolve_config(config) {
|
46
|
+
config = {
|
47
|
+
stripe_config: {
|
48
|
+
httpClient: StripeCls.createFetchHttpClient()
|
49
|
+
},
|
50
|
+
stripe_intent_create_params: {
|
51
|
+
currency: 'usd',
|
52
|
+
automatic_payment_methods: {
|
53
|
+
enabled: true,
|
54
|
+
},
|
55
|
+
payment_method_options: {
|
56
|
+
card: {
|
57
|
+
capture_method: 'manual',
|
58
|
+
},
|
59
|
+
},
|
60
|
+
},
|
61
|
+
...config,
|
62
|
+
}
|
63
|
+
|
64
|
+
const is_valid = config.publishable_key && config.secret_key;
|
65
|
+
|
66
|
+
if(!is_valid) {
|
67
|
+
throw new StorecraftError(
|
68
|
+
`Payment gateway ${this.info.name ?? 'unknown'} has invalid config !!!
|
69
|
+
Missing client_id or secret`
|
70
|
+
)
|
71
|
+
}
|
72
|
+
|
73
|
+
return config;
|
74
|
+
}
|
75
|
+
|
76
|
+
get info() {
|
77
|
+
return {
|
78
|
+
name: 'Stripe',
|
79
|
+
description: `Stripe powers online and in-person payment processing and financial solutions for businesses of all sizes.`,
|
80
|
+
url: 'https://docs.stripe.com/payments/place-a-hold-on-a-payment-method',
|
81
|
+
logo_url: 'https://images.ctfassets.net/fzn2n1nzq965/HTTOloNPhisV9P4hlMPNA/cacf1bb88b9fc492dfad34378d844280/Stripe_icon_-_square.svg?q=80&w=256'
|
82
|
+
}
|
83
|
+
}
|
84
|
+
|
85
|
+
get config() {
|
86
|
+
return this.#_config;
|
87
|
+
}
|
88
|
+
|
89
|
+
get actions() {
|
90
|
+
return [
|
91
|
+
{
|
92
|
+
handle: 'capture',
|
93
|
+
name: 'Capture',
|
94
|
+
description: 'Capture an authorized payment'
|
95
|
+
},
|
96
|
+
{
|
97
|
+
handle: 'cancel',
|
98
|
+
name: 'Cancel',
|
99
|
+
description: 'Cancel an a payment'
|
100
|
+
},
|
101
|
+
{
|
102
|
+
handle: 'refund',
|
103
|
+
name: 'Refund',
|
104
|
+
description: 'Refund a captured payment'
|
105
|
+
},
|
106
|
+
]
|
107
|
+
}
|
108
|
+
|
109
|
+
/**
|
110
|
+
*
|
111
|
+
* @type {payment_gateway["invokeAction"]}
|
112
|
+
*/
|
113
|
+
invokeAction(action_handle) {
|
114
|
+
switch (action_handle) {
|
115
|
+
case 'capture':
|
116
|
+
return this.capture.bind(this);
|
117
|
+
case 'cancel':
|
118
|
+
return this.cancel.bind(this);
|
119
|
+
case 'refund':
|
120
|
+
return this.refund.bind(this);
|
121
|
+
|
122
|
+
default:
|
123
|
+
break;
|
124
|
+
}
|
125
|
+
}
|
126
|
+
|
127
|
+
/**
|
128
|
+
* @description (Optional) buy link ui
|
129
|
+
*
|
130
|
+
* @param {Partial<OrderData>} order
|
131
|
+
*
|
132
|
+
* @return {Promise<string>} html
|
133
|
+
*/
|
134
|
+
async onBuyLinkHtml(order) {
|
135
|
+
|
136
|
+
return html_buy_ui(
|
137
|
+
this.config, order
|
138
|
+
)
|
139
|
+
}
|
140
|
+
|
141
|
+
/**
|
142
|
+
* @description on checkout create `hook`
|
143
|
+
*
|
144
|
+
* @param {OrderData} order
|
145
|
+
*
|
146
|
+
* @return {Promise<CheckoutCreateResult>}
|
147
|
+
*/
|
148
|
+
async onCheckoutCreate(order) {
|
149
|
+
|
150
|
+
const paymentIntent = await this.stripe.paymentIntents.create(
|
151
|
+
{
|
152
|
+
amount: Math.floor(order.pricing.total * 100),
|
153
|
+
...this.config.stripe_intent_create_params,
|
154
|
+
metadata: {
|
155
|
+
[metadata_storecraft_order_id]: order.id
|
156
|
+
}
|
157
|
+
}
|
158
|
+
);
|
159
|
+
|
160
|
+
return paymentIntent;
|
161
|
+
}
|
162
|
+
|
163
|
+
/**
|
164
|
+
* @description On checkout complete hook. With stripe, this corresponds
|
165
|
+
* to synchronous payments flows, which is discouraged by `stripe`.
|
166
|
+
* They advocate async flows where confirmation happens async from
|
167
|
+
* client side into their servers, and then you are notified via a
|
168
|
+
* webhook.
|
169
|
+
*
|
170
|
+
* @param {CheckoutCreateResult} create_result
|
171
|
+
*
|
172
|
+
* @return {ReturnType<payment_gateway["onCheckoutComplete"]>}
|
173
|
+
*/
|
174
|
+
async onCheckoutComplete(create_result) {
|
175
|
+
|
176
|
+
const intent = await this.stripe.paymentIntents.confirm(
|
177
|
+
create_result.id
|
178
|
+
);
|
179
|
+
|
180
|
+
let status;
|
181
|
+
switch(intent.status) {
|
182
|
+
case 'succeeded':
|
183
|
+
status = {
|
184
|
+
payment: PaymentOptionsEnum.captured,
|
185
|
+
checkout: CheckoutStatusEnum.complete
|
186
|
+
}
|
187
|
+
break;
|
188
|
+
case 'requires_capture':
|
189
|
+
status = {
|
190
|
+
payment: PaymentOptionsEnum.authorized,
|
191
|
+
checkout: CheckoutStatusEnum.complete
|
192
|
+
}
|
193
|
+
break;
|
194
|
+
case 'requires_confirmation':
|
195
|
+
case 'requires_action':
|
196
|
+
status = {
|
197
|
+
checkout: CheckoutStatusEnum.requires_action
|
198
|
+
}
|
199
|
+
break;
|
200
|
+
default:
|
201
|
+
status = {
|
202
|
+
checkout: CheckoutStatusEnum.failed
|
203
|
+
}
|
204
|
+
break;
|
205
|
+
}
|
206
|
+
|
207
|
+
return {
|
208
|
+
status,
|
209
|
+
onCheckoutComplete: intent
|
210
|
+
}
|
211
|
+
}
|
212
|
+
|
213
|
+
/**
|
214
|
+
* @description Fetch the order and analyze it's status
|
215
|
+
*
|
216
|
+
*
|
217
|
+
* @param {CheckoutCreateResult} create_result
|
218
|
+
*
|
219
|
+
*
|
220
|
+
* @returns {Promise<PaymentGatewayStatus>}
|
221
|
+
*/
|
222
|
+
async status(create_result) {
|
223
|
+
const o = await this.retrieve_order(create_result);
|
224
|
+
const lc = /** @type {StripeCls.Charge} */ (o.latest_charge);
|
225
|
+
/** @param {number} a */
|
226
|
+
const fmt = a => (a/100).toFixed(2) + o.currency.toUpperCase();
|
227
|
+
|
228
|
+
/** @type {PaymentGatewayStatus} */
|
229
|
+
const stat = {
|
230
|
+
messages: [],
|
231
|
+
actions: this.actions
|
232
|
+
}
|
233
|
+
|
234
|
+
if(o) { // just an intent
|
235
|
+
const date = new Date(o.created).toUTCString();
|
236
|
+
stat.messages = [
|
237
|
+
`A payment intent of **${fmt(o.amount)}** was initiated at ${date}`,
|
238
|
+
`The status is \`${o.status}\` and the ID is \`${o.id}\``
|
239
|
+
];
|
240
|
+
}
|
241
|
+
|
242
|
+
if(lc?.captured) {
|
243
|
+
stat.messages.push(
|
244
|
+
`**${fmt(lc.amount_captured)}** was \`CAPTURED\``,
|
245
|
+
);
|
246
|
+
}
|
247
|
+
if(lc?.refunded) {
|
248
|
+
const date = lc?.refunds?.data?.[0]?.created ? (new Date(lc?.refunds?.data?.[0]?.created).toUTCString()) : 'unknown time';
|
249
|
+
stat.messages.push(
|
250
|
+
`**${fmt(lc.amount_refunded)}** was \`REFUNDED\` at \`${date}\``,
|
251
|
+
);
|
252
|
+
}
|
253
|
+
|
254
|
+
if(o?.canceled_at) {
|
255
|
+
const date = new Date(o.canceled_at).toUTCString();
|
256
|
+
stat.messages.push(
|
257
|
+
...[
|
258
|
+
`Intent was \`CANCELLED\` at \`${date}\`.`,
|
259
|
+
o.cancellation_reason && `Cancellation reason is ${o.cancellation_reason}`
|
260
|
+
].filter(Boolean)
|
261
|
+
);
|
262
|
+
}
|
263
|
+
|
264
|
+
return stat;
|
265
|
+
}
|
266
|
+
|
267
|
+
/**
|
268
|
+
* @description [https://docs.stripe.com/webhooks](https://docs.stripe.com/webhooks)
|
269
|
+
* @param {import('@storecraft/core').ApiRequest} request
|
270
|
+
* @param {import('@storecraft/core').ApiResponse} response
|
271
|
+
*
|
272
|
+
* @type {payment_gateway["webhook"]}
|
273
|
+
*/
|
274
|
+
async webhook(request, response) {
|
275
|
+
const sig = request.headers.get('Stripe-Signature');
|
276
|
+
|
277
|
+
let event;
|
278
|
+
|
279
|
+
try {
|
280
|
+
event = await this.stripe.webhooks.constructEventAsync(
|
281
|
+
request.rawBody, sig, this.config.webhook_endpoint_secret, undefined,
|
282
|
+
StripeCls.createSubtleCryptoProvider()
|
283
|
+
);
|
284
|
+
}
|
285
|
+
catch (err) {
|
286
|
+
response.status = 400;
|
287
|
+
response.end();
|
288
|
+
console.log(err.message);
|
289
|
+
return;
|
290
|
+
}
|
291
|
+
|
292
|
+
let order_id;
|
293
|
+
/** @type {StripeCls.PaymentIntent} */
|
294
|
+
let payment_intent;
|
295
|
+
|
296
|
+
let payment_status = PaymentOptionsEnum.unpaid;
|
297
|
+
|
298
|
+
// Handle the event
|
299
|
+
switch (event?.type) {
|
300
|
+
case 'payment_intent.succeeded':
|
301
|
+
case 'payment_intent.payment_failed':
|
302
|
+
case 'payment_intent.requires_action':
|
303
|
+
case 'payment_intent.amount_capturable_updated':
|
304
|
+
case 'payment_intent.canceled':
|
305
|
+
payment_intent = event.data.object;
|
306
|
+
order_id = payment_intent.metadata[metadata_storecraft_order_id];
|
307
|
+
|
308
|
+
if(payment_intent.status==='requires_capture')
|
309
|
+
payment_status = PaymentOptionsEnum.authorized;
|
310
|
+
else if(payment_intent.status==='canceled')
|
311
|
+
payment_status = PaymentOptionsEnum.unpaid;
|
312
|
+
else if(payment_intent.status==='processing')
|
313
|
+
payment_status = PaymentOptionsEnum.unpaid;
|
314
|
+
else if(payment_intent.status==='requires_action')
|
315
|
+
payment_status = PaymentOptionsEnum.unpaid;
|
316
|
+
else if(payment_intent.status==='succeeded')
|
317
|
+
payment_status = PaymentOptionsEnum.captured;
|
318
|
+
|
319
|
+
break;
|
320
|
+
case 'charge.refunded':
|
321
|
+
case 'charge.refund.updated':
|
322
|
+
payment_status = PaymentOptionsEnum.refunded;
|
323
|
+
|
324
|
+
default:
|
325
|
+
console.log(`Unhandled event type ${event.type}`);
|
326
|
+
}
|
327
|
+
|
328
|
+
console.log('CCCC')
|
329
|
+
|
330
|
+
// Return a response to acknowledge receipt of the event
|
331
|
+
response.sendJson({received: true});
|
332
|
+
|
333
|
+
console.log('DDDD')
|
334
|
+
|
335
|
+
return {
|
336
|
+
order_id,
|
337
|
+
status: {
|
338
|
+
payment: payment_status,
|
339
|
+
checkout: CheckoutStatusEnum.complete
|
340
|
+
}
|
341
|
+
}
|
342
|
+
}
|
343
|
+
|
344
|
+
/**
|
345
|
+
* @description Retrieve latest order payload
|
346
|
+
*
|
347
|
+
* @param {CheckoutCreateResult} create_result first create result, holds `Stripe` intent
|
348
|
+
*
|
349
|
+
*/
|
350
|
+
retrieve_order = (create_result) => {
|
351
|
+
return this.stripe.paymentIntents.retrieve(
|
352
|
+
create_result.id,
|
353
|
+
{
|
354
|
+
expand: ['latest_charge']
|
355
|
+
}
|
356
|
+
)
|
357
|
+
}
|
358
|
+
|
359
|
+
// actions
|
360
|
+
|
361
|
+
/**
|
362
|
+
* @description todo: logic for if user wanted capture at approval
|
363
|
+
*
|
364
|
+
* @param {CheckoutCreateResult} create_result
|
365
|
+
*/
|
366
|
+
async cancel(create_result) {
|
367
|
+
await this.stripe.paymentIntents.cancel(
|
368
|
+
create_result.id,
|
369
|
+
{
|
370
|
+
cancellation_reason: 'abandoned'
|
371
|
+
}
|
372
|
+
);
|
373
|
+
|
374
|
+
return this.status(create_result);
|
375
|
+
}
|
376
|
+
|
377
|
+
/**
|
378
|
+
* @description todo: logic for if user wanted capture at approval
|
379
|
+
*
|
380
|
+
* @param {CheckoutCreateResult} create_result
|
381
|
+
*/
|
382
|
+
async capture(create_result) {
|
383
|
+
await this.stripe.paymentIntents.capture(
|
384
|
+
create_result.id,
|
385
|
+
{
|
386
|
+
amount_to_capture: create_result.amount
|
387
|
+
}
|
388
|
+
);
|
389
|
+
|
390
|
+
return this.status(create_result);
|
391
|
+
}
|
392
|
+
|
393
|
+
/**
|
394
|
+
* @description todo: logic for if user wanted capture at approval
|
395
|
+
*
|
396
|
+
* @param {CheckoutCreateResult} create_result
|
397
|
+
*/
|
398
|
+
async refund(create_result) {
|
399
|
+
const refund = await this.stripe.refunds.create(
|
400
|
+
{
|
401
|
+
payment_intent: create_result.id,
|
402
|
+
amount: create_result.amount,
|
403
|
+
}
|
404
|
+
);
|
405
|
+
|
406
|
+
return this.status(create_result);
|
407
|
+
}
|
408
|
+
|
409
|
+
}
|
410
|
+
|
package/index.js
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
export * from './adapter.js'
|
package/package.json
ADDED
@@ -0,0 +1,35 @@
|
|
1
|
+
{
|
2
|
+
"name": "@storecraft/payments-stripe",
|
3
|
+
"version": "1.0.0",
|
4
|
+
"description": "Official Storecraft <-> Stripe integration",
|
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-stripe"
|
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-stripe:test": "uvu -c",
|
24
|
+
"payments-stripe:publish": "npm publish --access public"
|
25
|
+
},
|
26
|
+
"dependencies": {
|
27
|
+
"stripe": "^16.6.0"
|
28
|
+
},
|
29
|
+
"devDependencies": {
|
30
|
+
"@storecraft/core": "^1.0.0",
|
31
|
+
"@types/node": "^20.11.0",
|
32
|
+
"dotenv": "^16.3.1",
|
33
|
+
"uvu": "^0.5.6"
|
34
|
+
}
|
35
|
+
}
|
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
|
+
}
|
@@ -0,0 +1,37 @@
|
|
1
|
+
export * from './index.js';
|
2
|
+
import type { Stripe } from 'stripe'
|
3
|
+
|
4
|
+
/**
|
5
|
+
* @description gateway config
|
6
|
+
*/
|
7
|
+
export type Config = {
|
8
|
+
|
9
|
+
/**
|
10
|
+
* @description `stripe` publishable key
|
11
|
+
*/
|
12
|
+
publishable_key: string;
|
13
|
+
|
14
|
+
/**
|
15
|
+
* @description `stripe` private secret
|
16
|
+
*/
|
17
|
+
secret_key: string;
|
18
|
+
|
19
|
+
/**
|
20
|
+
* @description (Optional) `webhook` Endpoint private secret in case
|
21
|
+
* you are configuring webhook for async payments
|
22
|
+
* [https://docs.stripe.com/webhooks?verify=check-signatures-library](https://docs.stripe.com/webhooks?verify=check-signatures-library)
|
23
|
+
*/
|
24
|
+
webhook_endpoint_secret?: string;
|
25
|
+
|
26
|
+
/**
|
27
|
+
* @description config options for `stripe`
|
28
|
+
*/
|
29
|
+
stripe_config?: Stripe.StripeConfig;
|
30
|
+
|
31
|
+
/**
|
32
|
+
* @description configure `intent` creation
|
33
|
+
*/
|
34
|
+
stripe_intent_create_params?: Omit<Stripe.PaymentIntentCreateParams, 'amount'>;
|
35
|
+
}
|
36
|
+
|
37
|
+
|