├── .cli.json ├── .env.example ├── .github ├── ISSUE_TEMPLATE │ ├── 1.Bug_report.md │ └── config.yml ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── .node-version ├── Dockerfile ├── LICENSE ├── README.md ├── client └── html │ ├── css │ ├── global.css │ └── normalize.css │ ├── favicon.ico │ ├── index.html │ ├── index.js │ ├── success.html │ ├── success.js │ └── utils.js ├── demo.yaml ├── package.json ├── server.js └── spec ├── capybara_support.rb ├── e2e_spec.rb ├── server_spec.rb └── spec_helper.rb /.cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "link", 3 | "configureDotEnv": true, 4 | "integrations": [ 5 | { 6 | "name": "main", 7 | "clients": ["html", "react-cra"], 8 | "servers": [ 9 | "node", 10 | "php", 11 | "python", 12 | "ruby" 13 | ] 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Stripe keys 2 | # https://dashboard.stripe.com/test/apikeys 3 | STRIPE_PUBLISHABLE_KEY=pk_12345 4 | STRIPE_SECRET_KEY=sk_12345 5 | # https://stripe.com/docs/webhooks/signatures 6 | STRIPE_WEBHOOK_SECRET=whsec_1234 7 | 8 | # Environment variables 9 | STATIC_DIR=../../client 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/1.Bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a bug report for this sample. 4 | --- 5 | 6 | # Bug report 7 | 8 | ## Describe the bug 9 | 10 | A clear and concise description of what the bug is. 11 | 12 | ## To Reproduce 13 | 14 | Steps to reproduce the behavior, please provide code snippets or a repository: 15 | 16 | 1. Go to '...' 17 | 2. Click on '....' 18 | 3. Scroll down to '....' 19 | 4. See error 20 | 21 | ## Expected behavior 22 | 23 | A clear and concise description of what you expected to happen. 24 | 25 | ## Screenshots 26 | 27 | If applicable, add screenshots to help explain your problem. 28 | 29 | ## System information 30 | 31 | - OS: [e.g. macOS, Windows] 32 | - Browser (if applies) [e.g. chrome, safari] 33 | - Server environment [e.g. Go, Java, Node, PHP, Python, Ruby, TypeScript] 34 | 35 | ## Additional context 36 | 37 | Add any other context about the problem here. 38 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Need help with your integration? 4 | url: https://webchat.freenode.net/#stripe 5 | about: Ask questions in our technical chat on IRC. 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | # ruby dependencies 9 | - package-ecosystem: "bundler" 10 | directory: "/server/ruby/" 11 | schedule: 12 | interval: "weekly" 13 | day: "thursday" 14 | 15 | # python dependencies 16 | - package-ecosystem: "pip" 17 | directory: "/server/python/" 18 | schedule: 19 | interval: "weekly" 20 | day: "thursday" 21 | 22 | # php dependencies 23 | - package-ecosystem: "composer" 24 | directory: "/server/php/" 25 | schedule: 26 | interval: "weekly" 27 | day: "thursday" 28 | 29 | # node dependencies 30 | - package-ecosystem: "npm" 31 | directory: "/server/node/" 32 | schedule: 33 | interval: "weekly" 34 | day: "thursday" 35 | 36 | # # go dependencies 37 | # - package-ecosystem: "gomod" 38 | # directory: "/server/go/" 39 | # schedule: 40 | # interval: "weekly" 41 | # 42 | # # java dependencies 43 | # - package-ecosystem: "maven" 44 | # directory: "/server/java/" 45 | # schedule: 46 | # interval: "weekly" 47 | # 48 | # dotnet dependencies 49 | - package-ecosystem: "nuget" 50 | directory: "/server/dotnet/" 51 | schedule: 52 | interval: "weekly" 53 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI for stripe-samples/link-with-stripe 2 | on: 3 | push: 4 | branches: 5 | - master 6 | - '!dependabot/**' 7 | workflow_dispatch: 8 | 9 | env: 10 | STRIPE_PUBLISHABLE_KEY: ${{ secrets.TEST_STRIPE_PUBLISHABLE_KEY }} 11 | STRIPE_SECRET_KEY: ${{ secrets.TEST_STRIPE_SECRET_KEY }} 12 | 13 | concurrency: 14 | group: ci-${{ github.head_ref }} 15 | cancel-in-progress: true 16 | 17 | jobs: 18 | server_test: 19 | runs-on: ubuntu-20.04 20 | steps: 21 | - uses: actions/checkout@v2 22 | 23 | - uses: actions/checkout@v2 24 | with: 25 | repository: 'stripe-samples/sample-ci' 26 | path: 'sample-ci' 27 | 28 | - name: Setup dependencies 29 | run: | 30 | source sample-ci/helpers.sh 31 | 32 | setup_dependencies 33 | 34 | - name: Run tests 35 | run: | 36 | source sample-ci/helpers.sh 37 | 38 | install_docker_compose_settings 39 | export STRIPE_WEBHOOK_SECRET=$(retrieve_webhook_secret) 40 | cat <> .env 41 | DOMAIN=http://web:4242 42 | EOF 43 | 44 | for lang in $(cat .cli.json | server_langs_for_integration main) 45 | do 46 | [ "$lang" = "php" ] && continue 47 | [ "$lang" = "java" ] && continue 48 | [ "$lang" = "go" ] && continue 49 | [ "$lang" = "dotnet" ] && continue 50 | 51 | configure_docker_compose_for_integration . "$lang" ../../client/html 52 | 53 | docker-compose up -d && wait_web_server 54 | docker-compose exec -T runner bundle exec rspec spec/server_spec.rb 55 | done 56 | 57 | - name: Collect debug information 58 | if: ${{ failure() }} 59 | run: | 60 | cat docker-compose.yml 61 | docker-compose ps -a 62 | docker-compose logs web 63 | 64 | e2e_test: 65 | runs-on: ubuntu-20.04 66 | steps: 67 | - uses: actions/checkout@v2 68 | 69 | - uses: actions/checkout@v2 70 | with: 71 | repository: 'stripe-samples/sample-ci' 72 | path: 'sample-ci' 73 | 74 | - name: Setup dependencies 75 | run: | 76 | source sample-ci/helpers.sh 77 | 78 | setup_dependencies 79 | 80 | - name: Prepare tests 81 | run: | 82 | echo '--format RSpec::Github::Formatter --format progress' >> .rspec 83 | 84 | - name: Run tests for client/html 85 | if: ${{ always() }} 86 | env: 87 | SERVER_URL: http://web:4242 88 | run: | 89 | source sample-ci/helpers.sh 90 | 91 | install_docker_compose_settings 92 | export STRIPE_WEBHOOK_SECRET=$(retrieve_webhook_secret) 93 | cat <> .env 94 | DOMAIN=${SERVER_URL} 95 | EOF 96 | 97 | configure_docker_compose_for_integration . node ../../client/html 98 | docker-compose --profile=e2e up -d && wait_web_server 99 | docker-compose exec -T runner bundle exec rspec spec/e2e_spec.rb 100 | 101 | - name: Run tests for client/react-cra 102 | if: ${{ always() }} 103 | env: 104 | SERVER_URL: http://frontend:3000 105 | run: | 106 | source sample-ci/helpers.sh 107 | 108 | echo "$(cat client/react-cra/package.json | jq '.proxy = "http://web:4242"')" > client/react-cra/package.json 109 | 110 | install_docker_compose_settings 111 | export STRIPE_WEBHOOK_SECRET=$(retrieve_webhook_secret) 112 | cat <> .env 113 | DOMAIN=${SERVER_URL} 114 | EOF 115 | 116 | configure_docker_compose_for_integration . node ../../client/react-cra 117 | docker-compose --profile=frontend up -d && wait_web_server 118 | docker-compose exec -T runner bundle exec rspec spec/e2e_spec.rb 119 | 120 | - name: Collect debug information 121 | if: ${{ failure() }} 122 | run: | 123 | cat docker-compose.yml 124 | docker-compose ps -a 125 | docker-compose --profile=frontend logs web 126 | 127 | docker cp $(docker-compose ps -qa runner | head -1):/work/tmp . 128 | 129 | - name: Upload capybara screenshots 130 | if: ${{ failure() }} 131 | uses: actions/upload-artifact@v2 132 | with: 133 | name: screenshots 134 | path: | 135 | tmp/capybara 136 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .DS_Store 3 | .vscode 4 | 5 | # Dependencies 6 | node_modules 7 | package-lock.json 8 | yarn.lock 9 | !/yarn.lock 10 | composer.lock 11 | 12 | # Ruby files 13 | Gemfile.lock 14 | 15 | # Python files 16 | __pycache__ 17 | venv 18 | env 19 | 20 | # PHP files 21 | vendor 22 | logs 23 | 24 | # Java files 25 | .settings 26 | target/ 27 | .classpath 28 | .factorypath 29 | .project 30 | 31 | # Typescript 32 | dist 33 | 34 | # Dotnet files 35 | obj/ 36 | bin/ 37 | **/obj/** 38 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 18.20.2 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16 2 | 3 | WORKDIR /usr/src/app 4 | 5 | COPY package*.json ./ 6 | 7 | RUN npm install 8 | 9 | COPY . . 10 | 11 | RUN npm run build 12 | 13 | EXPOSE 8080 14 | 15 | CMD [ "npm", "start" ] 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Stripe, Inc. (https://stripe.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Checkout faster with Link 2 | 3 | An [Express server](http://expressjs.com) implementation 4 | 5 | ## Requirements 6 | 7 | - Node v10+ 8 | - [Configured .env file](../README.md) 9 | 10 | ## How to run 11 | 12 | 1. Confirm `.env` configuration 13 | 14 | Ensure the API keys are configured in `.env` in this directory. It should include the following keys: 15 | 16 | ```yaml 17 | # Stripe API keys - see https://stripe.com/docs/development/quickstart#api-keys 18 | STRIPE_PUBLISHABLE_KEY=pk_test... 19 | STRIPE_SECRET_KEY=sk_test... 20 | 21 | # Required to verify signatures in the webhook handler. 22 | # See README on how to use the Stripe CLI to test webhooks 23 | STRIPE_WEBHOOK_SECRET=whsec_... 24 | 25 | # Path to front-end implementation. Two versions are available, one in HTML and the other using crate-react-app 26 | STATIC_DIR=../../client/html 27 | 28 | # or 29 | 30 | STATIC_DIR=../../client/react-cra 31 | ``` 32 | 33 | 2. Install dependencies 34 | 35 | ``` 36 | npm install 37 | ``` 38 | 39 | 3. Run the application 40 | 41 | ``` 42 | npm start 43 | ``` 44 | 45 | 3. Go to `localhost:4242` to see the demo 46 | -------------------------------------------------------------------------------- /client/html/css/global.css: -------------------------------------------------------------------------------- 1 | /* Variables */ 2 | :root { 3 | --gray-offset: rgba(0, 0, 0, 0.03); 4 | --gray-border: rgba(0, 0, 0, 0.15); 5 | --gray-light: rgba(0, 0, 0, 0.4); 6 | --gray-mid: rgba(0, 0, 0, 0.7); 7 | --gray-dark: rgba(0, 0, 0, 0.9); 8 | --body-color: var(--gray-mid); 9 | --headline-color: var(--gray-dark); 10 | --accent-color: #0066f0; 11 | --body-font-family: -apple-system, BlinkMacSystemFont, sans-serif; 12 | --radius: 20px; 13 | --form-width: 343px; 14 | } 15 | 16 | /* Base */ 17 | * { 18 | box-sizing: border-box; 19 | } 20 | body { 21 | font-family: var(--body-font-family); 22 | font-size: 16px; 23 | color: var(--body-color); 24 | -webkit-font-smoothing: antialiased; 25 | } 26 | h1, 27 | h2, 28 | h3, 29 | h4, 30 | h5, 31 | h6 { 32 | color: var(--body-color); 33 | margin-top: 4px; 34 | margin-bottom: 6px; 35 | } 36 | h1 { 37 | font-size: 27px; 38 | color: var(--headline-color); 39 | } 40 | h4 { 41 | font-weight: 500; 42 | font-size: 14px; 43 | color: var(--gray-light); 44 | } 45 | 46 | /* Layout */ 47 | .sr-root { 48 | display: flex; 49 | flex-direction: row; 50 | width: 100%; 51 | max-width: 980px; 52 | padding: 48px; 53 | align-content: center; 54 | justify-content: center; 55 | height: auto; 56 | min-height: 100vh; 57 | margin: 0 auto; 58 | } 59 | .sr-header { 60 | margin-bottom: 32px; 61 | } 62 | .sr-payment-summary { 63 | margin-bottom: 20px; 64 | } 65 | .sr-main, 66 | .sr-content { 67 | display: flex; 68 | flex-direction: column; 69 | justify-content: center; 70 | height: 100%; 71 | align-self: center; 72 | } 73 | .sr-main { 74 | width: var(--form-width); 75 | } 76 | .sr-content { 77 | padding-left: 48px; 78 | } 79 | .sr-header__logo { 80 | background-image: var(--logo-image); 81 | height: 24px; 82 | background-size: contain; 83 | background-repeat: no-repeat; 84 | width: 100%; 85 | } 86 | .sr-legal-text { 87 | color: var(--gray-light); 88 | text-align: center; 89 | font-size: 13px; 90 | line-height: 17px; 91 | margin-top: 12px; 92 | } 93 | .sr-field-error { 94 | color: var(--accent-color); 95 | text-align: left; 96 | font-size: 13px; 97 | line-height: 17px; 98 | margin-top: 12px; 99 | } 100 | 101 | /* Form */ 102 | .sr-form-row { 103 | margin: 16px 0; 104 | } 105 | label { 106 | 107 | font-size: 13px; 108 | font-weight: 500; 109 | margin-bottom: 8px; 110 | display: inline-block; 111 | } 112 | 113 | /* Inputs */ 114 | .sr-input, 115 | .sr-select, 116 | input[type="text"] { 117 | border: 1px solid var(--gray-border); 118 | border-radius: var(--radius); 119 | padding: 5px 12px; 120 | height: 44px; 121 | width: 100%; 122 | transition: box-shadow 0.2s ease; 123 | background: white; 124 | -moz-appearance: none; 125 | -webkit-appearance: none; 126 | appearance: none; 127 | color: #32325d; 128 | } 129 | .sr-input:focus, 130 | input[type="text"]:focus, 131 | button:focus, 132 | .focused { 133 | box-shadow: 0 0 0 1px rgba(50, 151, 211, 0.3), 0 1px 1px 0 rgba(0, 0, 0, 0.07), 134 | 0 0 0 4px rgba(50, 151, 211, 0.3); 135 | outline: none; 136 | z-index: 9; 137 | } 138 | .sr-input::placeholder, 139 | input[type="text"]::placeholder { 140 | color: var(--gray-light); 141 | } 142 | 143 | /* Checkbox */ 144 | .sr-checkbox-label { 145 | position: relative; 146 | cursor: pointer; 147 | } 148 | 149 | .sr-checkbox-label input { 150 | opacity: 0; 151 | margin-right: 6px; 152 | } 153 | 154 | .sr-checkbox-label .sr-checkbox-check { 155 | position: absolute; 156 | left: 0; 157 | height: 16px; 158 | width: 16px; 159 | background-color: white; 160 | border: 1px solid var(--gray-border); 161 | border-radius: 4px; 162 | transition: all 0.2s ease; 163 | } 164 | 165 | .sr-checkbox-label input:focus ~ .sr-checkbox-check { 166 | box-shadow: 0 0 0 1px rgba(50, 151, 211, 0.3), 0 1px 1px 0 rgba(0, 0, 0, 0.07), 167 | 0 0 0 4px rgba(50, 151, 211, 0.3); 168 | outline: none; 169 | } 170 | 171 | .sr-checkbox-label input:checked ~ .sr-checkbox-check { 172 | background-color: var(--accent-color); 173 | background-repeat: no-repeat; 174 | background-size: 16px; 175 | background-position: -1px -1px; 176 | } 177 | 178 | /* Select */ 179 | .sr-select { 180 | display: block; 181 | height: 44px; 182 | margin: 0; 183 | background-repeat: no-repeat, repeat; 184 | background-position: right 12px top 50%, 0 0; 185 | background-size: 0.65em auto, 100%; 186 | } 187 | .sr-select::-ms-expand { 188 | display: none; 189 | } 190 | .sr-select:hover { 191 | cursor: pointer; 192 | } 193 | .sr-select:focus { 194 | box-shadow: 0 0 0 1px rgba(50, 151, 211, 0.3), 0 1px 1px 0 rgba(0, 0, 0, 0.07), 195 | 0 0 0 4px rgba(50, 151, 211, 0.3); 196 | outline: none; 197 | } 198 | .sr-select option { 199 | font-weight: 400; 200 | } 201 | .sr-select:invalid { 202 | color: var(--gray-light); 203 | } 204 | 205 | /* Combo inputs */ 206 | .sr-combo-inputs { 207 | display: flex; 208 | flex-direction: column; 209 | } 210 | .sr-combo-inputs input, 211 | .sr-combo-inputs .sr-select { 212 | border-radius: 0; 213 | border-bottom: 0; 214 | } 215 | .sr-combo-inputs > input:first-child, 216 | .sr-combo-inputs > .sr-select:first-child { 217 | border-radius: var(--radius) var(--radius) 0 0; 218 | } 219 | .sr-combo-inputs > input:last-child, 220 | .sr-combo-inputs > .sr-select:last-child { 221 | border-radius: 0 0 var(--radius) var(--radius); 222 | border-bottom: 1px solid var(--gray-border); 223 | } 224 | .sr-combo-inputs > .sr-combo-inputs-row:last-child input:first-child { 225 | border-radius: 0 0 0 var(--radius); 226 | border-bottom: 1px solid var(--gray-border); 227 | } 228 | .sr-combo-inputs > .sr-combo-inputs-row:last-child input:last-child { 229 | border-radius: 0 0 var(--radius) 0; 230 | border-bottom: 1px solid var(--gray-border); 231 | } 232 | .sr-combo-inputs > .sr-combo-inputs-row:first-child input:first-child { 233 | border-radius: var(--radius) 0 0 0; 234 | } 235 | .sr-combo-inputs > .sr-combo-inputs-row:first-child input:last-child { 236 | border-radius: 0 var(--radius) 0 0; 237 | } 238 | .sr-combo-inputs > .sr-combo-inputs-row:first-child input:only-child { 239 | border-radius: var(--radius) var(--radius) 0 0; 240 | } 241 | .sr-combo-inputs-row { 242 | width: 100%; 243 | display: flex; 244 | } 245 | 246 | .sr-combo-inputs-row > input { 247 | width: 100%; 248 | border-radius: 0; 249 | } 250 | 251 | .sr-combo-inputs-row > input:first-child:not(:only-child) { 252 | border-right: 0; 253 | } 254 | 255 | .sr-combo-inputs-row:not(:first-of-type) .sr-input { 256 | border-radius: 0 0 var(--radius) var(--radius); 257 | } 258 | 259 | .sr-result { 260 | height: 44px; 261 | -webkit-transition: height 1s ease; 262 | -moz-transition: height 1s ease; 263 | -o-transition: height 1s ease; 264 | transition: height 1s ease; 265 | color: var(--font-color); 266 | overflow: auto; 267 | } 268 | .sr-result code { 269 | overflow: scroll; 270 | } 271 | .sr-result.expand { 272 | height: 350px; 273 | } 274 | 275 | /* Buttons and links */ 276 | button { 277 | background: var(--accent-color); 278 | border-radius: var(--radius); 279 | color: white; 280 | border: 0; 281 | padding: 12px 16px; 282 | margin-top: 16px; 283 | font-weight: 600; 284 | cursor: pointer; 285 | transition: all 0.2s ease; 286 | display: block; 287 | } 288 | button:hover { 289 | filter: contrast(115%); 290 | } 291 | button:active { 292 | transform: translateY(0px) scale(0.98); 293 | filter: brightness(0.9); 294 | } 295 | button:disabled { 296 | opacity: 0.5; 297 | cursor: not-allowed; 298 | } 299 | 300 | .sr-payment-form button, 301 | .fullwidth { 302 | width: 100%; 303 | } 304 | 305 | a { 306 | color: var(--accent-color); 307 | text-decoration: none; 308 | transition: all 0.2s ease; 309 | } 310 | 311 | a:hover { 312 | filter: brightness(0.8); 313 | } 314 | 315 | a:active { 316 | filter: brightness(0.5); 317 | } 318 | 319 | /* Code block */ 320 | .sr-callout { 321 | background: var(--gray-offset); 322 | padding: 12px; 323 | border-radius: var(--radius); 324 | max-height: 200px; 325 | overflow: auto; 326 | } 327 | code, 328 | pre { 329 | font-family: "SF Mono", "IBM Plex Mono", "Menlo", monospace; 330 | font-size: 12px; 331 | } 332 | 333 | /* Stripe Element placeholder */ 334 | .sr-card-element { 335 | padding-top: 12px; 336 | } 337 | 338 | /* Responsiveness */ 339 | @media (max-width: 720px) { 340 | .sr-root { 341 | flex-direction: column; 342 | justify-content: flex-start; 343 | padding: 48px 20px; 344 | min-width: 320px; 345 | } 346 | 347 | .sr-header__logo { 348 | background-position: center; 349 | } 350 | 351 | .sr-payment-summary { 352 | text-align: center; 353 | } 354 | 355 | .sr-content { 356 | display: none; 357 | } 358 | 359 | .sr-main { 360 | width: 100%; 361 | } 362 | } 363 | 364 | /* Pasha styles – Brand-overrides, can split these out */ 365 | :root { 366 | --accent-color: #ed5f74; 367 | --headline-color: var(--accent-color); 368 | } 369 | 370 | .pasha-image-stack { 371 | display: grid; 372 | grid-gap: 12px; 373 | grid-template-columns: auto auto; 374 | } 375 | 376 | .pasha-image-stack img { 377 | border-radius: var(--radius); 378 | background-color: var(--gray-border); 379 | box-shadow: 0 7px 14px 0 rgba(50, 50, 93, 0.1), 380 | 0 3px 6px 0 rgba(0, 0, 0, 0.07); 381 | transition: all 0.8s ease; 382 | opacity: 0; 383 | } 384 | 385 | .pasha-image-stack img:nth-child(1) { 386 | transform: translate(12px, -12px); 387 | opacity: 1; 388 | } 389 | .pasha-image-stack img:nth-child(2) { 390 | transform: translate(-24px, 16px); 391 | opacity: 1; 392 | } 393 | .pasha-image-stack img:nth-child(3) { 394 | transform: translate(68px, -100px); 395 | opacity: 1; 396 | } 397 | 398 | /* todo: spinner/processing state, errors, animations */ 399 | 400 | .spinner, 401 | .spinner:before, 402 | .spinner:after { 403 | border-radius: 50%; 404 | } 405 | .spinner { 406 | color: #ffffff; 407 | font-size: 22px; 408 | text-indent: -99999px; 409 | margin: 0px auto; 410 | position: relative; 411 | width: 20px; 412 | height: 20px; 413 | box-shadow: inset 0 0 0 2px; 414 | -webkit-transform: translateZ(0); 415 | -ms-transform: translateZ(0); 416 | transform: translateZ(0); 417 | } 418 | .spinner:before, 419 | .spinner:after { 420 | position: absolute; 421 | content: ""; 422 | } 423 | .spinner:before { 424 | width: 10.4px; 425 | height: 20.4px; 426 | background: var(--accent-color); 427 | border-radius: 20.4px 0 0 20.4px; 428 | top: -0.2px; 429 | left: -0.2px; 430 | -webkit-transform-origin: 10.4px 10.2px; 431 | transform-origin: 10.4px 10.2px; 432 | -webkit-animation: loading 2s infinite ease 1.5s; 433 | animation: loading 2s infinite ease 1.5s; 434 | } 435 | .spinner:after { 436 | width: 10.4px; 437 | height: 10.2px; 438 | background: var(--accent-color); 439 | border-radius: 0 10.2px 10.2px 0; 440 | top: -0.1px; 441 | left: 10.2px; 442 | -webkit-transform-origin: 0px 10.2px; 443 | transform-origin: 0px 10.2px; 444 | -webkit-animation: loading 2s infinite ease; 445 | animation: loading 2s infinite ease; 446 | } 447 | @-webkit-keyframes loading { 448 | 0% { 449 | -webkit-transform: rotate(0deg); 450 | transform: rotate(0deg); 451 | } 452 | 100% { 453 | -webkit-transform: rotate(360deg); 454 | transform: rotate(360deg); 455 | } 456 | } 457 | @keyframes loading { 458 | 0% { 459 | -webkit-transform: rotate(0deg); 460 | transform: rotate(0deg); 461 | } 462 | 100% { 463 | -webkit-transform: rotate(360deg); 464 | transform: rotate(360deg); 465 | } 466 | } 467 | 468 | /* Animated form */ 469 | 470 | .sr-root { 471 | animation: 0.4s form-in; 472 | animation-fill-mode: both; 473 | animation-timing-function: ease; 474 | } 475 | 476 | .sr-payment-form .sr-form-row { 477 | animation: 0.4s field-in; 478 | animation-fill-mode: both; 479 | animation-timing-function: ease; 480 | transform-origin: 50% 0%; 481 | } 482 | 483 | /* need saas for loop :D */ 484 | .sr-payment-form .sr-form-row:nth-child(1) { 485 | animation-delay: 0; 486 | } 487 | .sr-payment-form .sr-form-row:nth-child(2) { 488 | animation-delay: 60ms; 489 | } 490 | .sr-payment-form .sr-form-row:nth-child(3) { 491 | animation-delay: 120ms; 492 | } 493 | .sr-payment-form .sr-form-row:nth-child(4) { 494 | animation-delay: 180ms; 495 | } 496 | .sr-payment-form .sr-form-row:nth-child(5) { 497 | animation-delay: 240ms; 498 | } 499 | .sr-payment-form .sr-form-row:nth-child(6) { 500 | animation-delay: 300ms; 501 | } 502 | .hidden { 503 | display: none; 504 | } 505 | 506 | @keyframes field-in { 507 | 0% { 508 | opacity: 0; 509 | transform: translateY(8px) scale(0.95); 510 | } 511 | 100% { 512 | opacity: 1; 513 | transform: translateY(0px) scale(1); 514 | } 515 | } 516 | 517 | @keyframes form-in { 518 | 0% { 519 | opacity: 0; 520 | transform: scale(0.98); 521 | } 522 | 100% { 523 | opacity: 1; 524 | transform: scale(1); 525 | } 526 | } 527 | 528 | #messages { 529 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New'; 530 | display: none; /* hide initially, then show once the first message arrives */ 531 | background-color: #0A253C; 532 | color: #00D924; 533 | padding: 20px; 534 | margin: 20px 0; 535 | border-radius: var(--radius); 536 | font-size:0.7em; 537 | } 538 | -------------------------------------------------------------------------------- /client/html/css/normalize.css: -------------------------------------------------------------------------------- 1 | /*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */ 2 | 3 | /* Document 4 | ========================================================================== */ 5 | 6 | /** 7 | * 1. Correct the line height in all browsers. 8 | * 2. Prevent adjustments of font size after orientation changes in iOS. 9 | */ 10 | 11 | html { 12 | line-height: 1.15; /* 1 */ 13 | -webkit-text-size-adjust: 100%; /* 2 */ 14 | } 15 | 16 | /* Sections 17 | ========================================================================== */ 18 | 19 | /** 20 | * Remove the margin in all browsers. 21 | */ 22 | 23 | body { 24 | margin: 0; 25 | } 26 | 27 | /** 28 | * Render the `main` element consistently in IE. 29 | */ 30 | 31 | main { 32 | display: block; 33 | } 34 | 35 | /** 36 | * Correct the font size and margin on `h1` elements within `section` and 37 | * `article` contexts in Chrome, Firefox, and Safari. 38 | */ 39 | 40 | h1 { 41 | font-size: 2em; 42 | margin: 0.67em 0; 43 | } 44 | 45 | /* Grouping content 46 | ========================================================================== */ 47 | 48 | /** 49 | * 1. Add the correct box sizing in Firefox. 50 | * 2. Show the overflow in Edge and IE. 51 | */ 52 | 53 | hr { 54 | box-sizing: content-box; /* 1 */ 55 | height: 0; /* 1 */ 56 | overflow: visible; /* 2 */ 57 | } 58 | 59 | /** 60 | * 1. Correct the inheritance and scaling of font size in all browsers. 61 | * 2. Correct the odd `em` font sizing in all browsers. 62 | */ 63 | 64 | pre { 65 | font-family: monospace, monospace; /* 1 */ 66 | font-size: 1em; /* 2 */ 67 | } 68 | 69 | /* Text-level semantics 70 | ========================================================================== */ 71 | 72 | /** 73 | * Remove the gray background on active links in IE 10. 74 | */ 75 | 76 | a { 77 | background-color: transparent; 78 | } 79 | 80 | /** 81 | * 1. Remove the bottom border in Chrome 57- 82 | * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. 83 | */ 84 | 85 | abbr[title] { 86 | border-bottom: none; /* 1 */ 87 | text-decoration: underline; /* 2 */ 88 | text-decoration: underline dotted; /* 2 */ 89 | } 90 | 91 | /** 92 | * Add the correct font weight in Chrome, Edge, and Safari. 93 | */ 94 | 95 | b, 96 | strong { 97 | font-weight: bolder; 98 | } 99 | 100 | /** 101 | * 1. Correct the inheritance and scaling of font size in all browsers. 102 | * 2. Correct the odd `em` font sizing in all browsers. 103 | */ 104 | 105 | code, 106 | kbd, 107 | samp { 108 | font-family: monospace, monospace; /* 1 */ 109 | font-size: 1em; /* 2 */ 110 | } 111 | 112 | /** 113 | * Add the correct font size in all browsers. 114 | */ 115 | 116 | small { 117 | font-size: 80%; 118 | } 119 | 120 | /** 121 | * Prevent `sub` and `sup` elements from affecting the line height in 122 | * all browsers. 123 | */ 124 | 125 | sub, 126 | sup { 127 | font-size: 75%; 128 | line-height: 0; 129 | position: relative; 130 | vertical-align: baseline; 131 | } 132 | 133 | sub { 134 | bottom: -0.25em; 135 | } 136 | 137 | sup { 138 | top: -0.5em; 139 | } 140 | 141 | /* Embedded content 142 | ========================================================================== */ 143 | 144 | /** 145 | * Remove the border on images inside links in IE 10. 146 | */ 147 | 148 | img { 149 | border-style: none; 150 | } 151 | 152 | /* Forms 153 | ========================================================================== */ 154 | 155 | /** 156 | * 1. Change the font styles in all browsers. 157 | * 2. Remove the margin in Firefox and Safari. 158 | */ 159 | 160 | button, 161 | input, 162 | optgroup, 163 | select, 164 | textarea { 165 | font-family: inherit; /* 1 */ 166 | font-size: 100%; /* 1 */ 167 | line-height: 1.15; /* 1 */ 168 | margin: 0; /* 2 */ 169 | } 170 | 171 | /** 172 | * Show the overflow in IE. 173 | * 1. Show the overflow in Edge. 174 | */ 175 | 176 | button, 177 | input { /* 1 */ 178 | overflow: visible; 179 | } 180 | 181 | /** 182 | * Remove the inheritance of text transform in Edge, Firefox, and IE. 183 | * 1. Remove the inheritance of text transform in Firefox. 184 | */ 185 | 186 | button, 187 | select { /* 1 */ 188 | text-transform: none; 189 | } 190 | 191 | /** 192 | * Correct the inability to style clickable types in iOS and Safari. 193 | */ 194 | 195 | button, 196 | [type="button"], 197 | [type="reset"], 198 | [type="submit"] { 199 | -webkit-appearance: button; 200 | } 201 | 202 | /** 203 | * Remove the inner border and padding in Firefox. 204 | */ 205 | 206 | button::-moz-focus-inner, 207 | [type="button"]::-moz-focus-inner, 208 | [type="reset"]::-moz-focus-inner, 209 | [type="submit"]::-moz-focus-inner { 210 | border-style: none; 211 | padding: 0; 212 | } 213 | 214 | /** 215 | * Restore the focus styles unset by the previous rule. 216 | */ 217 | 218 | button:-moz-focusring, 219 | [type="button"]:-moz-focusring, 220 | [type="reset"]:-moz-focusring, 221 | [type="submit"]:-moz-focusring { 222 | outline: 1px dotted ButtonText; 223 | } 224 | 225 | /** 226 | * Correct the padding in Firefox. 227 | */ 228 | 229 | fieldset { 230 | padding: 0.35em 0.75em 0.625em; 231 | } 232 | 233 | /** 234 | * 1. Correct the text wrapping in Edge and IE. 235 | * 2. Correct the color inheritance from `fieldset` elements in IE. 236 | * 3. Remove the padding so developers are not caught out when they zero out 237 | * `fieldset` elements in all browsers. 238 | */ 239 | 240 | legend { 241 | box-sizing: border-box; /* 1 */ 242 | color: inherit; /* 2 */ 243 | display: table; /* 1 */ 244 | max-width: 100%; /* 1 */ 245 | padding: 0; /* 3 */ 246 | white-space: normal; /* 1 */ 247 | } 248 | 249 | /** 250 | * Add the correct vertical alignment in Chrome, Firefox, and Opera. 251 | */ 252 | 253 | progress { 254 | vertical-align: baseline; 255 | } 256 | 257 | /** 258 | * Remove the default vertical scrollbar in IE 10+. 259 | */ 260 | 261 | textarea { 262 | overflow: auto; 263 | } 264 | 265 | /** 266 | * 1. Add the correct box sizing in IE 10. 267 | * 2. Remove the padding in IE 10. 268 | */ 269 | 270 | [type="checkbox"], 271 | [type="radio"] { 272 | box-sizing: border-box; /* 1 */ 273 | padding: 0; /* 2 */ 274 | } 275 | 276 | /** 277 | * Correct the cursor style of increment and decrement buttons in Chrome. 278 | */ 279 | 280 | [type="number"]::-webkit-inner-spin-button, 281 | [type="number"]::-webkit-outer-spin-button { 282 | height: auto; 283 | } 284 | 285 | /** 286 | * 1. Correct the odd appearance in Chrome and Safari. 287 | * 2. Correct the outline style in Safari. 288 | */ 289 | 290 | [type="search"] { 291 | -webkit-appearance: textfield; /* 1 */ 292 | outline-offset: -2px; /* 2 */ 293 | } 294 | 295 | /** 296 | * Remove the inner padding in Chrome and Safari on macOS. 297 | */ 298 | 299 | [type="search"]::-webkit-search-decoration { 300 | -webkit-appearance: none; 301 | } 302 | 303 | /** 304 | * 1. Correct the inability to style clickable types in iOS and Safari. 305 | * 2. Change font properties to `inherit` in Safari. 306 | */ 307 | 308 | ::-webkit-file-upload-button { 309 | -webkit-appearance: button; /* 1 */ 310 | font: inherit; /* 2 */ 311 | } 312 | 313 | /* Interactive 314 | ========================================================================== */ 315 | 316 | /* 317 | * Add the correct display in Edge, IE 10+, and Firefox. 318 | */ 319 | 320 | details { 321 | display: block; 322 | } 323 | 324 | /* 325 | * Add the correct display in all browsers. 326 | */ 327 | 328 | summary { 329 | display: list-item; 330 | } 331 | 332 | /* Misc 333 | ========================================================================== */ 334 | 335 | /** 336 | * Add the correct display in IE 10+. 337 | */ 338 | 339 | template { 340 | display: none; 341 | } 342 | 343 | /** 344 | * Add the correct display in IE 10. 345 | */ 346 | 347 | [hidden] { 348 | display: none; 349 | } -------------------------------------------------------------------------------- /client/html/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stripe-samples/link/234ba08a73e02543177c31935d39f729151a1fa5/client/html/favicon.ico -------------------------------------------------------------------------------- /client/html/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Link with Stripe 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 |
19 |

Accept a payment

20 | 21 |
22 |

Contact info

23 | 24 | 25 |

Shipping

26 |
27 | 28 |

Payment

29 |
30 | 31 | 32 |
33 | 34 |
35 |
36 |
37 | 38 | 39 | -------------------------------------------------------------------------------- /client/html/index.js: -------------------------------------------------------------------------------- 1 | document.addEventListener('DOMContentLoaded', async (e) => { 2 | const { publishableKey } = await fetch("/config").then(res => res.json()); 3 | 4 | const stripe = Stripe(publishableKey); 5 | 6 | const { clientSecret } = await fetch("/create-payment-intent", { 7 | method: "POST", 8 | headers: { 9 | "Content-Type": "application/json" 10 | } 11 | }).then(res => res.json()); 12 | 13 | addMessage(`Client secret: ${clientSecret}`); 14 | 15 | // Customize the appearance of Elements using the Appearance API. 16 | const appearance = { 17 | theme: 'stripe', 18 | variables: { 19 | colorPrimary: '#ed5f74', 20 | borderRadius: '20px', 21 | fontFamily: '--body-font-family: -apple-system, BlinkMacSystemFont, sans-serif', 22 | colorBackground: '#fafafa', 23 | }, 24 | }; 25 | 26 | // Create an elements group from the Stripe instance, passing the clientSecret (obtained in step 2) and appearance (optional). 27 | const elements = stripe.elements({ clientSecret, appearance }); 28 | 29 | // Create and mount the Payment Element 30 | const paymentElement = elements.create("payment"); 31 | paymentElement.mount("#payment-element"); 32 | 33 | 34 | // Create and mount the linkAuthentication Element 35 | const linkAuthenticationElement = elements.create("linkAuthentication"); 36 | linkAuthenticationElement.mount("#link-authentication-element"); 37 | 38 | // If the customer's email is known when the page is loaded, you can 39 | // pass the email to the linkAuthenticationElement on mount: 40 | // 41 | // linkAuthenticationElement.mount("#link-authentication-element", { 42 | // defaultValues: { 43 | // email: 'jenny.rosen@example.com', 44 | // } 45 | // }) 46 | 47 | // If you need access to the email address entered: 48 | // 49 | linkAuthenticationElement.on('change', (event) => { 50 | const email = event.value.email; 51 | console.log({ email }); 52 | }) 53 | 54 | 55 | // Create and mount the Shipping Address Element 56 | const shippingAddressElement = elements.create("address", { mode: 'shipping'}); //AllowedCountries: ['US'] }); 57 | shippingAddressElement.mount("#shipping-address-element"); 58 | 59 | // If you need access to the shipping address entered 60 | // 61 | // shippingAddressElement.on('change', (event) => { 62 | // const address = event.value; 63 | // console.log({ address }); 64 | // }) 65 | 66 | const form = document.getElementById('payment-form'); 67 | form.addEventListener('submit', async (event) => { 68 | addMessage('Submitting payment...'); 69 | event.preventDefault(); 70 | 71 | const { error } = await stripe.confirmPayment({ 72 | elements, 73 | confirmParams: { 74 | return_url: "http://localhost:4242/payment/next", 75 | } 76 | }); 77 | 78 | if (error) { 79 | // Show error to your customer (for example, payment details incomplete) 80 | console.log(error.message); 81 | addMessage(`Error: ${error.message}`); 82 | } else { 83 | // Your customer will be redirected to your `return_url`. For some payment 84 | // methods like iDEAL, your customer will be redirected to an intermediate 85 | // site first to authorize the payment, then redirected to the `return_url`. 86 | } 87 | }) 88 | }) 89 | -------------------------------------------------------------------------------- /client/html/success.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Link with Stripe 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 |
19 |

Success

20 | 21 | Restart 22 |
23 |
24 |
25 | 26 | 27 | -------------------------------------------------------------------------------- /client/html/success.js: -------------------------------------------------------------------------------- 1 | document.addEventListener('DOMContentLoaded', async (e) => { 2 | // Initialize Stripe.js with your publishable key. 3 | const {publishableKey} = await fetch("/config").then(res => res.json()); 4 | const stripe = Stripe(publishableKey); 5 | 6 | // Get the PaymentIntent clientSecret from query string params. 7 | const params = new URLSearchParams(window.location.search); 8 | const clientSecret = params.get('payment_intent_client_secret'); 9 | 10 | // Retrieve the PaymentIntent. 11 | const {paymentIntent} = await stripe.retrievePaymentIntent(clientSecret) 12 | addMessage("Payment Intent Status: " + paymentIntent.status); 13 | addMessage(paymentIntent.id); 14 | }); 15 | -------------------------------------------------------------------------------- /client/html/utils.js: -------------------------------------------------------------------------------- 1 | // Helper for displaying status messages. 2 | const addMessage = (message) => { 3 | const messagesDiv = document.querySelector('#messages'); 4 | messagesDiv.style.display = 'block'; 5 | const messageWithLinks = addDashboardLinks(message); 6 | messagesDiv.innerHTML += `> ${messageWithLinks}
`; 7 | console.log(`Debug: ${message}`); 8 | }; 9 | 10 | // Adds links for known Stripe objects to the Stripe dashboard. 11 | const addDashboardLinks = (message) => { 12 | const piDashboardBase = 'https://dashboard.stripe.com/test/payments'; 13 | return message.replace( 14 | /(pi_(\S*)\b)/g, 15 | `$1` 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /demo.yaml: -------------------------------------------------------------------------------- 1 | # min 5 chars 2 | name: "Link & address element Demo" 3 | 4 | # acct_xxxx 5 | acct_id: "acct_1JK2VFLBM2ZiyHQm" 6 | 7 | # date your demo was created 8 | # format: 2021-06-01 9 | created_date: 2022-05-20 10 | 11 | # Your app will be available at both domains: 12 | # https://.stripedemos.com 13 | # https://.stripesandbox.com 14 | # recommendation: obfuscate the URL a bit by adding a random string 15 | subdomain: "link-address-demo" 16 | 17 | # min 5 chars 18 | description: "autofill address" 19 | 20 | # Slack username 21 | creator: kater 22 | 23 | # port 24 | port: 8080 25 | 26 | # private 27 | private: false 28 | 29 | # the slack channel of your team (ex: #solutions) 30 | team: "#solutions" 31 | 32 | # enter a simple password here you can provide to your customer 33 | # leave blank for no password 34 | password: 35 | 36 | # date you want your deployment to be deleted prior to 6 mos 37 | # (You can always re-deploy it; your code will not be deleted) 38 | # format: 2021-06-01 39 | exp_date: 40 | 41 | # all demos have a max deployment life of 6 months 42 | # unless you set the following to true 43 | persistent: true 44 | 45 | # if you need to reduce coldstarts, say how many instances you want 46 | # see https://cloud.google.com/run/docs/configuring/min-instances#command-line 47 | min_instances: 0 48 | 49 | # if you need a database, add one of the following: 50 | # 'mysql' or 'postgres' 51 | db_type: 52 | 53 | # your app will automatically be moved to the _archive folder 54 | # after 90 days if it is not deployed at that time 55 | # To prevent that, set the following field to false 56 | archivable: false 57 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stripe-sample-demo", 3 | "version": "1.0.0", 4 | "description": "A Stripe demo", 5 | "main": "server.js", 6 | "scripts": { 7 | "start": "node server.js", 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "author": "stripe-demos", 11 | "license": "ISC", 12 | "dependencies": { 13 | "body-parser": "^1.19.0", 14 | "cookie-parser": "^1.4.6", 15 | "dotenv": "^16.0.0", 16 | "express": "^4.17.1", 17 | "stripe": "^11.2.0" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const app = express(); 3 | const { resolve } = require('path'); 4 | // Replace if using a different env file or config 5 | const env = require('dotenv').config({ path: './.env' }); 6 | 7 | const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY, { 8 | appInfo: { // For sample support and debugging, not required for production: 9 | name: "stripe-samples/link-with-stripe", 10 | version: "0.0.1", 11 | url: "https://github.com/stripe-samples/link-with-stripe", 12 | } 13 | }); 14 | 15 | app.use(express.static(process.env.STATIC_DIR)); 16 | 17 | app.use( 18 | express.json({ 19 | // We need the raw body to verify webhook signatures. 20 | // Let's compute it only when hitting the Stripe webhook endpoint. 21 | verify: function(req, res, buf) { 22 | if (req.originalUrl.startsWith('/webhook')) { 23 | req.rawBody = buf.toString(); 24 | } 25 | } 26 | }) 27 | ); 28 | 29 | app.get('/config', (req, res) => { 30 | res.send({ 31 | publishableKey: process.env.STRIPE_PUBLISHABLE_KEY, 32 | }); 33 | }); 34 | 35 | app.post('/create-payment-intent', async (req, res) => { 36 | // Create a PaymentIntent with the amount, currency, and a payment method type. 37 | // 38 | // See the documentation [0] for the full list of supported parameters. 39 | // 40 | // [0] https://stripe.com/docs/api/payment_intents/create 41 | try { 42 | const paymentIntent = await stripe.paymentIntents.create({ 43 | amount: 1999, 44 | currency: 'usd', 45 | 46 | // Best practice is to enable Link through the dashboard 47 | // and use automatic payment methods. For this demo, 48 | // we explicitly pass payment_method_types: ['link', 'card'], 49 | // to be extra clear which payment method types are enabled. 50 | // 51 | // automatic_payment_methods: { enabled: true }, 52 | // 53 | payment_method_types: ['link', 'card'], 54 | }); 55 | 56 | // Send publishable key and PaymentIntent details to client 57 | res.send({ 58 | clientSecret: paymentIntent.client_secret 59 | }); 60 | 61 | } catch(e) { 62 | return res.status(400).send({ 63 | error: { 64 | message: e.message 65 | } 66 | }); 67 | } 68 | }); 69 | 70 | app.get('/payment/next', async (req, res) => { 71 | const intent = await stripe.paymentIntents.retrieve( 72 | req.query.payment_intent, 73 | { 74 | expand: ["payment_method"], 75 | } 76 | ); 77 | const status = intent.status; 78 | 79 | res.redirect(`/success?payment_intent_client_secret=${intent.client_secret}`); 80 | }); 81 | 82 | app.get('/success', async (req, res) => { 83 | const path = resolve(process.env.STATIC_DIR + '/success.html'); 84 | res.sendFile(path); 85 | }); 86 | 87 | // Expose a endpoint as a webhook handler for asynchronous events. 88 | // Configure your webhook in the stripe developer dashboard 89 | // https://dashboard.stripe.com/test/webhooks 90 | app.post('/webhook', async (req, res) => { 91 | let data, eventType; 92 | 93 | // Check if webhook signing is configured. 94 | if (process.env.STRIPE_WEBHOOK_SECRET) { 95 | // Retrieve the event by verifying the signature using the raw body and secret. 96 | let event; 97 | let signature = req.headers['stripe-signature']; 98 | try { 99 | event = stripe.webhooks.constructEvent( 100 | req.rawBody, 101 | signature, 102 | process.env.STRIPE_WEBHOOK_SECRET 103 | ); 104 | } catch (err) { 105 | console.log(`⚠️ Webhook signature verification failed.`); 106 | return res.sendStatus(400); 107 | } 108 | data = event.data; 109 | eventType = event.type; 110 | } else { 111 | // Webhook signing is recommended, but if the secret is not configured in `config.js`, 112 | // we can retrieve the event data directly from the request body. 113 | data = req.body.data; 114 | eventType = req.body.type; 115 | } 116 | 117 | if (eventType === 'payment_intent.succeeded') { 118 | // Funds have been captured 119 | // Fulfill any orders, e-mail receipts, etc 120 | // To cancel the payment after capture you will need to issue a Refund (https://stripe.com/docs/api/refunds) 121 | console.log('💰 Payment captured!'); 122 | } else if (eventType === 'payment_intent.payment_failed') { 123 | console.log('❌ Payment failed.'); 124 | } 125 | res.sendStatus(200); 126 | }); 127 | 128 | app.listen(4242, () => console.log(`Node server listening at http://localhost:4242`)); 129 | -------------------------------------------------------------------------------- /spec/capybara_support.rb: -------------------------------------------------------------------------------- 1 | require 'capybara/rspec' 2 | require 'selenium-webdriver' 3 | require 'capybara-screenshot/rspec' 4 | 5 | Capybara.server_host = Socket.ip_address_list.detect(&:ipv4_private?).ip_address 6 | 7 | Capybara.register_driver :chrome do |app| 8 | opts = {browser: :chrome, url: ENV.fetch('SELENIUM_URL', 'http://selenium:4444/wd/hub')} 9 | Capybara::Selenium::Driver.new(app, **opts) 10 | end 11 | 12 | Capybara::Screenshot.register_driver(:chrome) do |driver, path| 13 | driver.browser.save_screenshot(path) 14 | end 15 | 16 | Capybara.javascript_driver = :chrome 17 | Capybara.default_driver = :chrome 18 | Capybara.default_max_wait_time = 20 19 | Capybara.enable_aria_label = true 20 | Capybara.save_path = 'tmp/capybara' 21 | 22 | module CapybaraHelpers 23 | SERVER_URL = ENV.fetch('SERVER_URL', 'http://web:4242') 24 | 25 | def server_url(path) 26 | url = URI(SERVER_URL) 27 | url.path = path 28 | url 29 | end 30 | end 31 | 32 | RSpec.configure do |config| 33 | config.include CapybaraHelpers 34 | end 35 | 36 | -------------------------------------------------------------------------------- /spec/e2e_spec.rb: -------------------------------------------------------------------------------- 1 | require 'capybara_support' 2 | 3 | RSpec.describe 'PaymentElement', type: :system do 4 | before do 5 | visit server_url('/') 6 | end 7 | 8 | example 'happy path' do 9 | within_frame first('form iframe[title*="Secure email input frame"]') do 10 | fill_in 'email', with: "test#{SecureRandom.hex(4)}@example.com" 11 | end 12 | 13 | within_frame first('form iframe[title*="Shipping address input frame"]') do 14 | fill_in 'Field-nameInput', with: "jenny rosen" 15 | fill_in 'addressLine1', with: "123 Main St" 16 | sleep(2) 17 | body = find('body') 18 | body.send_keys(:tab) 19 | fill_in 'locality', with: "San Francisco" 20 | fill_in 'Field-postalCodeInput', with: "94111" 21 | select 'California', from: 'Field-administrativeAreaInput' 22 | end 23 | 24 | within_frame first('form iframe[title*="Secure payment input frame"]') do 25 | fill_in 'number', with: '4242424242424242' 26 | fill_in 'expiry', with: '12 / 33' 27 | fill_in 'cvc', with: '123' 28 | end 29 | 30 | click_on 'Pay' 31 | 32 | expect(page).to have_no_content('Accept a payment') 33 | expect(page).to have_content('Success') 34 | expect(page).to have_content('Payment Intent Status: succeeded') 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/server_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative './spec_helper.rb' 2 | 3 | RSpec.describe " integration" do 4 | it "serves the index route" do 5 | # Get the index html page 6 | response = get("/") 7 | expect(response).not_to be_nil 8 | end 9 | 10 | it "serves config with publishableKey" do 11 | resp = get_json("/config") 12 | expect(resp).to have_key("publishableKey") 13 | expect(resp['publishableKey']).to start_with("pk_test") 14 | end 15 | 16 | it "Creates a payment intent" do 17 | resp, status = post_json("/create-payment-intent", {}) 18 | expect(status).to eq(200) 19 | expect(resp).to have_key("clientSecret") 20 | expect(resp["clientSecret"]).to start_with("pi_") 21 | client_secret = resp["clientSecret"] 22 | pi = Stripe::PaymentIntent.retrieve(client_secret.split("_secret").first) 23 | expect(pi.payment_method_types).to eq(['link', 'card']) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'byebug' 2 | require 'json' 3 | require 'rest-client' 4 | require 'stripe' 5 | require 'dotenv' 6 | 7 | # This file was generated by the `rspec --init` command. Conventionally, all 8 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 9 | # The generated `.rspec` file contains `--require spec_helper` which will cause 10 | # this file to always be loaded, without a need to explicitly require it in any 11 | # files. 12 | # 13 | # Given that it is always loaded, you are encouraged to keep this file as 14 | # light-weight as possible. Requiring heavyweight dependencies from this file 15 | # will add to the boot time of your test suite on EVERY test run, even for an 16 | # individual file that may not need all of that loaded. Instead, consider making 17 | # a separate helper file that requires the additional dependencies and performs 18 | # the additional setup, and require it from the spec files that actually need 19 | # it. 20 | # 21 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 22 | RSpec.configure do |config| 23 | # rspec-expectations config goes here. You can use an alternate 24 | # assertion/expectation library such as wrong or the stdlib/minitest 25 | # assertions if you prefer. 26 | config.expect_with :rspec do |expectations| 27 | # This option will default to `true` in RSpec 4. It makes the `description` 28 | # and `failure_message` of custom matchers include text for helper methods 29 | # defined using `chain`, e.g.: 30 | # be_bigger_than(2).and_smaller_than(4).description 31 | # # => "be bigger than 2 and smaller than 4" 32 | # ...rather than: 33 | # # => "be bigger than 2" 34 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 35 | end 36 | 37 | # rspec-mocks config goes here. You can use an alternate test double 38 | # library (such as bogus or mocha) by changing the `mock_with` option here. 39 | config.mock_with :rspec do |mocks| 40 | # Prevents you from mocking or stubbing a method that does not exist on 41 | # a real object. This is generally recommended, and will default to 42 | # `true` in RSpec 4. 43 | mocks.verify_partial_doubles = true 44 | end 45 | 46 | # This option will default to `:apply_to_host_groups` in RSpec 4 (and will 47 | # have no way to turn it off -- the option exists only for backwards 48 | # compatibility in RSpec 3). It causes shared context metadata to be 49 | # inherited by the metadata hash of host groups and examples, rather than 50 | # triggering implicit auto-inclusion in groups with matching metadata. 51 | config.shared_context_metadata_behavior = :apply_to_host_groups 52 | 53 | # The settings below are suggested to provide a good initial experience 54 | # with RSpec, but feel free to customize to your heart's content. 55 | =begin 56 | # This allows you to limit a spec run to individual examples or groups 57 | # you care about by tagging them with `:focus` metadata. When nothing 58 | # is tagged with `:focus`, all examples get run. RSpec also provides 59 | # aliases for `it`, `describe`, and `context` that include `:focus` 60 | # metadata: `fit`, `fdescribe` and `fcontext`, respectively. 61 | config.filter_run_when_matching :focus 62 | 63 | # Allows RSpec to persist some state between runs in order to support 64 | # the `--only-failures` and `--next-failure` CLI options. We recommend 65 | # you configure your source control system to ignore this file. 66 | config.example_status_persistence_file_path = "spec/examples.txt" 67 | 68 | # Limits the available syntax to the non-monkey patched syntax that is 69 | # recommended. For more details, see: 70 | # - http://rspec.info/blog/2012/06/rspecs-new-expectation-syntax/ 71 | # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ 72 | # - http://rspec.info/blog/2014/05/notable-changes-in-rspec-3/#zero-monkey-patching-mode 73 | config.disable_monkey_patching! 74 | 75 | # This setting enables warnings. It's recommended, but in some cases may 76 | # be too noisy due to issues in dependencies. 77 | config.warnings = true 78 | 79 | # Many RSpec users commonly either run the entire suite or an individual 80 | # file, and it's useful to allow more verbose output when running an 81 | # individual spec file. 82 | if config.files_to_run.one? 83 | # Use the documentation formatter for detailed output, 84 | # unless a formatter has already been configured 85 | # (e.g. via a command-line flag). 86 | config.default_formatter = "doc" 87 | end 88 | 89 | # Print the 10 slowest examples and example groups at the 90 | # end of the spec run, to help surface which specs are running 91 | # particularly slow. 92 | config.profile_examples = 10 93 | 94 | # Run specs in random order to surface order dependencies. If you find an 95 | # order dependency and want to debug it, you can fix the order by providing 96 | # the seed, which is printed after each run. 97 | # --seed 1234 98 | config.order = :random 99 | 100 | # Seed global randomization in this process using the `--seed` CLI option. 101 | # Setting this allows you to use `--seed` to deterministically reproduce 102 | # test failures related to randomization by passing the same `--seed` value 103 | # as the one that triggered the failure. 104 | Kernel.srand config.seed 105 | =end 106 | end 107 | 108 | SERVER_URL = ENV.fetch('SERVER_URL', 'http://localhost:4242') 109 | Dotenv.load 110 | Stripe.api_key = ENV['STRIPE_SECRET_KEY'] 111 | Stripe.max_network_retries = 2 112 | Stripe.api_version = "2020-08-27" 113 | 114 | def server_url 115 | SERVER_URL 116 | end 117 | 118 | def get(path, *args, **kwargs) 119 | RestClient.get("#{SERVER_URL}#{path}", *args, **kwargs) 120 | end 121 | 122 | def get_json(path, *args, **kwargs) 123 | response = RestClient.get("#{SERVER_URL}#{path}", *args, **kwargs) 124 | JSON.parse(response.body) 125 | end 126 | 127 | def post_json(path, payload, **kwargs) 128 | defaults = {content_type: :json} 129 | response = RestClient.post( 130 | "#{SERVER_URL}#{path}", 131 | payload.to_json, 132 | defaults.merge(**kwargs) 133 | ) 134 | [JSON.parse(response.body), response.code] 135 | rescue => e 136 | begin 137 | [JSON.parse(e.http_body), e.http_code] 138 | rescue => e 139 | puts "Response:" 140 | p response 141 | throw "Failed to parse failed response" 142 | end 143 | end 144 | --------------------------------------------------------------------------------