├── .formatter.exs ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── README.md ├── assets ├── css │ └── app.css ├── js │ └── app.js ├── tailwind.config.js └── vendor │ └── topbar.js ├── config ├── config.exs ├── dev.exs ├── prod.exs ├── runtime.exs └── test.exs ├── coveralls.json ├── example.md ├── lib ├── app.ex ├── app │ ├── application.ex │ └── users.ex ├── app_web.ex └── app_web │ ├── components │ ├── core_components.ex │ ├── layouts.ex │ └── layouts │ │ ├── app.html.heex │ │ └── root.html.heex │ ├── controllers │ ├── app_controller.ex │ ├── app_html.ex │ ├── app_html │ │ └── app.html.heex │ ├── auth_controller.ex │ ├── checkout_session_controller.ex │ ├── error_html.ex │ ├── error_json.ex │ ├── page_controller.ex │ ├── page_html.ex │ └── page_html │ │ ├── cancel.html.heex │ │ ├── home.html.heex │ │ └── success.html.heex │ ├── endpoint.ex │ ├── gettext.ex │ ├── router.ex │ └── telemetry.ex ├── mix.exs ├── mix.lock ├── priv ├── gettext │ ├── en │ │ └── LC_MESSAGES │ │ │ └── errors.po │ └── errors.pot └── static │ ├── favicon.ico │ ├── images │ └── phoenix.png │ └── robots.txt └── test ├── app └── users_test.exs ├── app_web └── controllers │ ├── app_controller_test.exs │ ├── auth_controller_test.exs │ ├── checkout_session_controller_test.exs │ ├── error_html_test.exs │ ├── error_json_test.exs │ └── page_controller_test.exs ├── support ├── conn_case.ex ├── data_case.ex └── fixtures │ └── conn_fixtures.ex └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:phoenix], 3 | plugins: [Phoenix.LiveView.HTMLFormatter], 4 | inputs: [ 5 | "*.{heex,ex,exs}", 6 | "priv/*/seeds.exs", 7 | "{config,lib,test}/**/*.{heex,ex,exs}" 8 | ], 9 | subdirectories: ["priv/*/migrations"], 10 | line_length: 80 11 | ] 12 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: mix 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | time: "17:00" 8 | timezone: Europe/London 9 | ignore: 10 | # ignore all patch updates in dev dependencies ref: github.com/dwyl/technology-stack/issues/126 11 | - dependency-name: "credo" 12 | update-types: ["version-update:semver-patch"] 13 | - dependency-name: "excoveralls" 14 | update-types: ["version-update:semver-patch"] 15 | - dependency-name: "ex_doc" 16 | update-types: ["version-update:semver-patch"] 17 | - dependency-name: "esbuild" 18 | update-types: ["version-update:semver-patch"] 19 | - dependency-name: "floki" 20 | update-types: ["version-update:semver-patch"] 21 | - dependency-name: "gettext" 22 | update-types: ["version-update:semver-patch"] 23 | - dependency-name: "mock" 24 | update-types: ["version-update:semver-patch"] 25 | - dependency-name: "phoenix_live_dashboard" 26 | update-types: ["version-update:semver-patch"] 27 | - dependency-name: "phoenix_live_reload" 28 | update-types: ["version-update:semver-patch"] 29 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | name: Build and test 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | otp: ['24.3.4'] 16 | elixir: ['1.14.1'] 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Set up Elixir 20 | uses: erlef/setup-beam@v1 21 | with: 22 | otp-version: ${{ matrix.otp }} 23 | elixir-version: ${{ matrix.elixir }} 24 | - name: Restore deps and _build cache 25 | uses: actions/cache@v3 26 | with: 27 | path: | 28 | deps 29 | _build 30 | key: deps-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }} 31 | restore-keys: | 32 | deps-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }} 33 | - name: Install dependencies 34 | run: mix deps.get 35 | - name: Check code is formatted 36 | run: mix format --check-formatted 37 | - name: Run Tests 38 | run: mix coveralls.json 39 | env: 40 | MIX_ENV: test 41 | AUTH_API_KEY: ${{ secrets.AUTH_API_KEY }} 42 | - name: Upload coverage to Codecov 43 | uses: codecov/codecov-action@v1 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (https://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | 39 | 40 | # The directory Mix will write compiled artifacts to. 41 | /_build/ 42 | 43 | # If you run "mix test --cover", coverage assets end up here. 44 | /cover/ 45 | 46 | # The directory Mix downloads your dependencies sources to. 47 | /deps/ 48 | 49 | # Where 3rd-party dependencies like ExDoc output generated docs. 50 | /doc/ 51 | 52 | # Ignore .fetch files in case you like to edit your project deps locally. 53 | /.fetch 54 | 55 | # If the VM crashes, it generates a dump, let's ignore it too. 56 | erl_crash.dump 57 | 58 | # Also ignore archive artifacts (built via "mix archive.build"). 59 | *.ez 60 | 61 | # Ignore package tarball (built via "mix hex.build"). 62 | app-*.tar 63 | 64 | # Ignore assets that are produced by build tools. 65 | /priv/static/assets/ 66 | 67 | # Ignore digested assets cache. 68 | /priv/static/cache_manifest.json 69 | 70 | # In case you use Node.js/npm, you want to ignore these. 71 | npm-debug.log 72 | /assets/node_modules/ 73 | 74 | # Env files 75 | .env 76 | cache.dets 77 | cache_test.dets 78 | 79 | # Noise 80 | .DS_Store -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # Learn Payment Processing 💳 4 | 5 | ![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/dwyl/learn-payment-processing/ci.yml?label=build&style=flat-square&branch=main) 6 | [![codecov.io](https://img.shields.io/codecov/c/github/dwyl/learn-payment-processing/main.svg?style=flat-square)](https://codecov.io/github/dwyl/learn-payment-processing?branch=main) 7 | [![HitCount](https://hits.dwyl.com/dwyl/learn-payment-processing.svg?style=flat-square)](https://hits.dwyl.com/dwyl/learn-payment-processing) 8 | [![contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat-square)](https://github.com/dwyl/phoenix-chat-example/issues) 9 | 10 | Learn what payment processing is 11 | and how you can add it to your App 12 | to get _paid_! 🎉 13 | 14 |
15 | 16 | # Why? 🥕 17 | 18 | Sadly, not all applications can be "free"; 19 | the **`people`** that build them need to be **paid** 20 | and the underlying infrastructure costs money too. 21 | Many App/Websites cover their costs through advertising. 22 | This is effectively selling [out] the "users" data, 23 | which we are 24 | [not fans](https://github.com/dwyl/learn-react/issues/23#issuecomment-552212087) 25 | of ... 26 | 27 | [![you-are-the-product](https://user-images.githubusercontent.com/194400/210129020-c3fe00a5-5721-448b-a919-7b9c89ba2fae.png "You Are the Product!")](https://slate.com/technology/2018/04/are-you-really-facebooks-product-the-history-of-a-dangerous-idea.html) 28 | 29 | > "_When something online is free, 30 | > you're not the customer, 31 | > you're the product._" 32 | > ~ [Jonathan Zittrain](https://blogs.harvard.edu/futureoftheinternet/2012/03/21/meme-patrol-when-something-online-is-free-youre-not-the-customer-youre-the-product/) 33 | 34 | We prefer to charge an **affordable fee** - 35 | enough to **cover** all our **costs** 36 | and continue building our 37 | [roadmap](https://github.com/dwyl/product-roadmap) - 38 | and **_fiercely_ guard** the **privacy** 39 | of the **`people`** using the App. 40 | 41 | The goal of this guide is to cover 42 | both the theory and _practice_ 43 | of payment processing 44 | _and_ to showcase payment processing 45 | in a standalone web app. 46 | 47 | # Who? 🤓 48 | 49 | This guide is meant as both 50 | an **_internal_ reference** for us **`@dwyl`**
51 | and a **_fully_ Open Source resource** 52 | that _anyone_ can read and learn from. 53 | 54 | As always, if you find it helpful/useful please star the repo on GitHub ⭐ 🙏 Thanks! 55 | 56 | If you get stuck or have any questions/suggestions, 57 | please [open an issue](https://github.com/dwyl/learn-payment-processing/issues). 58 | 59 | 60 | # What 💭 61 | 62 | Some Apps are sold via **one time purchase** 63 | others are **subscription-based** (recurring payments). 64 | Regardless of the type of payment, 65 | they share one thing in common: 66 | **payment processing gateways 67 | are used to collect funds**. 68 | 69 | But what _is_ "payment processing"...? 70 | 71 | 72 | ## Payment Processor or Gateway? 🤷‍♀️ 73 | 74 | ![payment-processor-gateway](https://user-images.githubusercontent.com/17494745/208946952-4da1600e-9936-4491-9a6e-a5a7e09c023c.png) 75 | 76 | A **payment processor** functions as an intermediary 77 | between the customer's bank (or digital wallet) 78 | and the merchant 79 | (and their bank). 80 | It is the entity responsible for communicating 81 | between both parties in the transaction. 82 | 83 | A **payment gateway** 84 | is a virtual 85 | [*point of sale*](https://en.wikipedia.org/wiki/Point_of_sale) 86 | for online payments. 87 | Similar to when a customer swipes/taps their card 88 | on a **_physical_ credit card terminal**, 89 | online stores need a gateway to securely collect 90 | the customer's card details. 91 | A payment gateway 92 | acts like is a _virtual_ credit card terminal. 93 | 94 | The whole process of online payment 95 | usually assumes the merchant 96 | has a 97 | [**merchant account**](https://www.investopedia.com/terms/m/merchant-account.asp). 98 | A merchant account is simply 99 | a type of business bank account 100 | that *allows a business 101 | to receive credit card 102 | and other electronic funds transfers*. 103 | 104 | > **Note**: the terms 105 | > `payment processor` 106 | > and `payment gateway` 107 | > usually fall under the same term; 108 | `payment processor`. 109 | This is because they work together to handle payment processing. 110 | So if you see platforms like `Stripe` 111 | being mentioned as a "payment processor", 112 | it's because it offers both `payment gateway` 113 | and `payment processor` 114 | bundled together 115 | alongside a myriad of other features 116 | such as fraud prevention. 117 | 118 | ## Which Payment Processing Provider? 🤔 119 | 120 | There are several Payment processing providers, 121 | the most recognizable are: 122 | [`PayPal`](https://developer.paypal.com/api/rest/), 123 | [`Stripe`](https://stripe.com) 124 | or 125 | [`Square`](https://developer.squareup.com/us/en). 126 | 127 | We have used each of these in the past 128 | and they are fairly straightforward to integrate. 129 | 130 | Let's do a quick rundown of the various payment providers 131 | we want to support in our `App`: 132 | 133 | ### `PayPal` - the _Original_ Payment Processor 💵 134 | 135 | Started in 1998, 136 | `PayPal` is one of the _original_ 137 | and most successful 138 | general purpose online payment processors. 139 | 140 | If you've done much online shopping, 141 | you have probably seen a payment interface 142 | allowing purchases through `Paypal`: 143 | 144 | ![paypal](https://user-images.githubusercontent.com/17494745/208951049-421e123a-e082-433e-8b08-60c7da8c8a57.png) 145 | 146 | If you want to alow `people` to purchase 147 | an through `PayPal`, 148 | you'd have to setup a `PayPal` account 149 | and use one of their SDKs: 150 | [developer.paypal.com](https://developer.paypal.com/home) 151 | 152 | This uses the 153 | `PayPal` E-commerce platform 154 | [paypal.com/us/business/platforms](https://www.paypal.com/us/business/platforms-and-marketplaces) 155 | to setup a payment gateway and processor 156 | for `people` to pay with Paypal on your site. 157 | 158 | The biggest advantage of `PayPal` 159 | is that has been a "Digital Wallet" from the early days. 160 | Which means people _store_ funds in their `PayPal` account 161 | as if it were a Bank. 162 | An individual can _sell_ something online 163 | e.g. on 164 | [`eBay`](https://www.ebay.com/help/selling/listings/choosing-get-paid/accepting-other-payment-methods?id=4184) 165 | or 166 | [`etsy`](https://help.etsy.com/hc/en-us/articles/360000104828-How-to-Pay-With-PayPal?segment=shopping) 167 | and collect payment with `PayPal`. 168 | These funds stay in their `PayPal` account unless they extract them 169 | to a Bank Account. Many people hold balances in their `PayPal` for future online shopping. 170 | People consider this to be their "spending money". 171 | We want to help those people _invest_ the money wisely in themselves! 172 | 173 | The history/evolution of `PayPal` is a fascinating success story, 174 | [wikipedia.org/wiki/PayPal#Early_history](https://en.wikipedia.org/wiki/PayPal). 175 | We are only concerned about the _present_; 176 | `PayPal` has over **`400 million people` _actively_ using their platform** 177 | and can be used in more than 200 countries/regions. 178 | A _significant_ proportion of `people` buying things online 179 | _already_ use and _trust_ `PayPal`. 180 | We want to offer the ability to checkout with a `PayPal` 181 | account as one of the lowest friction payment methods. 182 | For people who are signed into their `PayPal` account, 183 | checkout can be **2 clicks/taps**. 🚀 184 | 185 | 186 | ### `Apple Pay` 🍎 187 | 188 | There are **`1.2 Billion` _active_ `iPhone`** users worldwide. 189 | `iPhone` recently surpassed 190 | [**`50%` Smart Phone Market Share**](https://github.com/dwyl/learn-flutter/pull/69#issuecomment-1319811970) 191 | in the **US** 192 | and in some wealthier countries like Norway, 193 | it's as high as 194 | [**`68%`**](https://github.com/dwyl/learn-flutter/pull/69#issuecomment-1319826473). 195 | Many of the `people` using `iPhone` 196 | have a payment card associated with their Apple Account. 197 | [**`Apple Pay`**](https://www.apple.com/apple-pay/) 198 | has **`500 million`** registered users worldwide: 199 | [fortunly.com/apple-pay-statistics](https://fortunly.com/statistics/apple-pay-statistics/) 200 | 201 | To use `Apple Pay` _directly_ we would need 202 | to create an account with `Apple` 203 | and use their `SDK`: 204 | [developer.apple.com/apple-pay/implementation/](https://developer.apple.com/apple-pay/implementation/) 205 | 206 | `while` we don't expect _many_ of the people using our `App` to pay with `Apple Pay` 207 | (_and we certainly won't encourage them as `Apple` takes a **massive `30%` cut** ..._) 208 | 209 | ### `Google Pay` 🤖 210 | 211 | [`Google Pay`](https://pay.google.com/about/business/implementation/), 212 | originally called `Google Checkout`, 213 | then `Google Wallet` 214 | then rebranded to `Android Pay` 215 | and now back to `Google Pay/Wallet` (...🙄) 216 | is a payment service available 217 | to all `people` with a `Google` account - 218 | including everyone with an `Android` device - 219 | who have added a credit/debit card to their account. 220 | It's difficult to know exactly how many people 221 | have and _use_ `Google Pay` 222 | because `Google` does not make the info `public` 223 | and the _vast_ majority of `people` with an `Android` 224 | device either don't _have_ a Credit Card 225 | (think children and the 226 | [unbanked](https://en.wikipedia.org/wiki/Unbanked)) ... 227 | But the most recent estimates are 228 | **`200 million` people** 229 | in 230 | [**47 countries**](https://support.google.com/googlepay/answer/12429287?hl=en#zippy=%2Cpay-in-store). 231 | see: wikipedia.org/wiki/Google_Pay_(payment_method) 232 | 233 | To add `Google Pay` 234 | as a payment method 235 | you have to create an business account 236 | and use their **`SDK`**: 237 | [developers.google.com/pay/api](https://developers.google.com/pay/api) 238 | and integrate it in your App/Website. 239 | 240 | ### Credit/Debit Cards 💳 241 | 242 | Last but not least are **`credit/debit cards`**, 243 | by _far_ the most prevalent payment method 244 | both in the _real_ world and online. 245 | 246 | All the bran-specific payment providers 247 | such as `Apple Pay`, `Google Pay`, `Amazon Pay` etc. 248 | _assume_ you have a `credit/debit card`, 249 | so why _bother_ with the others? 250 | Simple: most friction and _perceived_ security. 251 | Many people are _reluctant_ to use their credit/debit card 252 | because they fear fraud or identity theft. 253 | 254 | These fears are _mostly_ resolved in 2022 255 | as the credit/debit card companies have 256 | sophisticated fraud detection/prevention systems. 257 | But we [@dwyl] still don't want to be _storing_ 258 | any card details ourselves, we want a trusted 259 | [PCI DSS compliant](https://en.wikipedia.org/wiki/Payment_Card_Industry_Data_Security_Standard) 260 | payment processor to handle the parts we don't have time to be experts in 261 | so that we can focus on the UI/UX of our `App`. 262 | 263 |
264 | 265 | ## Do We Need a Merchant Account? 🛒 266 | 267 | On top of managing accounts with each of the payment providers, 268 | we would need to create our own merchant account 269 | so each one of these services can connect to it 270 | and process the transactions. 271 | 272 | Setting up and maintaining a Merchant Account 273 | is a heap of effort 274 | and can easily require 275 | a _dedicated_ person or _team_ of people 276 | just for running the checkout process in your App/company. 277 | This is where **payment platforms** 278 | like `Stripe` come into play. 279 | 280 | 281 | 282 | ## Payment Platforms 😍 283 | 284 | Payment platforms **_simplify_ the process of connecting 285 | to multiple third-parties**. 286 | It offers more than a payment service 287 | so that merchants only have to liaise with _one_ company 288 | rather than multiple ones. 289 | 290 | This has a great impact on how an application 291 | is *designed* and *implemented*, 292 | and allows to for a better management 293 | and 294 | [decoupling](https://en.wikipedia.org/wiki/Single-responsibility_principle) 295 | of responsibilities. 296 | 297 | Instead of our own API having to manage different providers, 298 | we can use a platform like `Stripe` to do the work for us. 299 | This is how the application should be laid out! 300 | 301 | ![design](https://user-images.githubusercontent.com/17494745/208956397-cda6d895-8034-45b0-bc91-61befb012fb3.png) 302 | 303 | As you can see, it is much simpler! 304 | By offering a bundle of essential payment technologies, 305 | these companies are reducing the merchant's work 306 | of having to manage each of them separately. 307 | In addition to this, there are a number of other advantages, 308 | such as security, data monitoring and reporting. 309 | 310 | For example, `Stripe` is like having a multiple 311 | **payment processors**, **payment gateways** and **merchant account** 312 | bundled into one, 313 | along with a [myriad of other features](https://stripe.com/en-pt). 314 | 315 | ### `Stripe` 🚀 316 | 317 | `Stripe` is considered by many to be 318 | the [*de facto*](https://trends.builtwith.com/payment/Stripe) 319 | way of accepting credit cards 320 | and electronic payments on the web. 321 | Beyond collecting card payments 322 | it has a number of additional features, 323 | including: 324 | [smart retries](https://stripe.com/docs/billing/revenue-recovery/smart-retries), 325 | [automatic card updater](https://stripe.com/docs/saving-cards), 326 | [fraud tooling](https://stripe.com/en-gb-pt/radar), 327 | and other 328 | [add-ons](https://stripe.com/partners/directory). 329 | 330 | Until we starting to research this in-depth, 331 | we were considering using `Stripe` 332 | because we've used in it previous projects. 333 | But then we discovered ***`Paddle`***! 334 | 335 | ### `Paddle` 😮 336 | 337 | `Paddle` is a new class of payment processor 338 | that includes all additional services 339 | in their simple fee structure. 340 | Their slogan is: 341 | "_The better way to sell software_". 342 | Which immediately got our attention 343 | as that is what we are selling! 344 | 345 | While `Stripe` can be compared to a payment gateway 346 | that deals with multiple channels, 347 | `Paddle` offers similar features 348 | but acts a *reseller service* - 349 | **Merchant of Record (MoR)**. 350 | 351 | ![mor](https://user-images.githubusercontent.com/194400/210158068-92f2ec93-c108-4789-bc03-fd9b34c472bf.png) 352 | 353 | A MoR is a term to describe the legal entity 354 | selling goods or services to an end customer: 355 | [paddle.com/blog/what-is-merchant-of-record](https://www.paddle.com/blog/what-is-merchant-of-record)
356 | It's who the end customer owes payment for their purchase, 357 | and it is who handles payments and tax liability for each transaction. 358 | This is great for *tax handling*, 359 | which is especially relevant in Europe: 360 | [outseta.com/posts/startup-payment-processing](https://www.outseta.com/posts/startup-payment-processing) 361 | and one of the reasons people 362 | consider `Paddle` in lieu of `Stripe`. 363 | `Stripe` is making strides in also having 364 | better tax compliance: [stripe.com/newsroom/news/taxjar](https://stripe.com/newsroom/news/taxjar) 365 | but it's not quite there, 366 | at the moment of writing. 367 | 368 | 369 | So businesses can choose to be their own merchant of record 370 | and setup an infrastructure and processes needed to manage 371 | payments with `Stripe` and deal with liabilities 372 | and tax handling themselves. 373 | *Or* they can use MoR service providers like `Paddle` 374 | who take the burden of all of payment processing 375 | and legal compliance away. Of course, these usually incur higher fees than `Stripe`. 376 | 377 | ## I'm confused ... Which one should I choose? 🤷‍♀️ 378 | 379 | That's a great question that has come up before: 380 | [indiehackers.com/post/**stripe-vs-paddle**-89161b0d5c](https://www.indiehackers.com/post/stripe-vs-paddle-89161b0d5c) 381 | `Paddle` themselves have a good comparison: 382 | [paddle.com/**compare/stripe**](https://www.paddle.com/compare/stripe) 383 | 384 | Several others have reached the same conclusion, e.g: 385 | [splitbee.io/blog/why-we-moved-from-stripe-to-paddle](https://splitbee.io/blog/why-we-moved-from-stripe-to-paddle) 386 | and 387 | [reddit.com/r/SaaS/comments/q3kao9/paddle_vs_chargebee_vs_stripe_any_recommendations](https://www.reddit.com/r/SaaS/comments/q3kao9/paddle_vs_chargebee_vs_stripe_any_recommendations) 388 | 389 | It's still early days for `Paddle` 390 | whereas `Stripe` has several years of head-start: 391 | https://stackshare.io/stackups/paddle-vs-stripe 392 | At present very few companies use `Paddle`, 393 | but those who do are quite vocal about it: 394 | 395 | ![paddle-tailwind-ui](https://user-images.githubusercontent.com/194400/210031861-3a5a0d76-e406-4b4b-8bea-2fa7b04e0f82.png) 396 | 397 | 398 | Depending on the use-case or your choice, 399 | each product provides different pricing plans, fees and features 400 | and you should make this decision 401 | based on the requirements of your project 402 | and how much you are willing to spend. 403 | 404 | What's important here is you know 405 | *how online payments work*, 406 | what parties are involved 407 | and how you can **leverage these platforms** 408 | to make this process easier. 409 | 410 | Remember, we are dealing with **sensitive information**. 411 | Credit card info should be handled with *extreme caution* 412 | amd these platforms makes it easier for us to do just that. 413 | 414 | But implementation-wise, 415 | when designing and implementing your application, 416 | you should notice that 417 | the process is similar between providers. 418 | Your application will make use of their SDKs 419 | to integrate different channels 420 | and payment alternatives to process transactions. 421 | 422 | And guess what, 423 | we are going to be doing that in the next section! 424 | 425 | # How? 💻 426 | 427 | The demo/example has quite a few steps, 428 | so we split it out into it's own doc: 429 | [`example.md`](https://github.com/dwyl/learn-payment-processing/blob/main/example.md) 430 | -------------------------------------------------------------------------------- /assets/css/app.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss/base"; 2 | @import "tailwindcss/components"; 3 | @import "tailwindcss/utilities"; 4 | 5 | /* This file is for your main application CSS */ 6 | -------------------------------------------------------------------------------- /assets/js/app.js: -------------------------------------------------------------------------------- 1 | // If you want to use Phoenix channels, run `mix help phx.gen.channel` 2 | // to get started and then uncomment the line below. 3 | // import "./user_socket.js" 4 | 5 | // You can include dependencies in two ways. 6 | // 7 | // The simplest option is to put them in assets/vendor and 8 | // import them using relative paths: 9 | // 10 | // import "../vendor/some-package.js" 11 | // 12 | // Alternatively, you can `npm install some-package --prefix assets` and import 13 | // them using a path starting with the package name: 14 | // 15 | // import "some-package" 16 | // 17 | 18 | // Include phoenix_html to handle method=PUT/DELETE in forms and buttons. 19 | import "phoenix_html" 20 | // Establish Phoenix Socket and LiveView configuration. 21 | import {Socket} from "phoenix" 22 | import {LiveSocket} from "phoenix_live_view" 23 | import topbar from "../vendor/topbar" 24 | 25 | let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") 26 | let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}}) 27 | 28 | // Show progress bar on live navigation and form submits 29 | topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) 30 | window.addEventListener("phx:page-loading-start", info => topbar.delayedShow(200)) 31 | window.addEventListener("phx:page-loading-stop", info => topbar.hide()) 32 | 33 | // connect if there are any LiveViews on the page 34 | liveSocket.connect() 35 | 36 | // expose liveSocket on window for web console debug logs and latency simulation: 37 | // >> liveSocket.enableDebug() 38 | // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session 39 | // >> liveSocket.disableLatencySim() 40 | window.liveSocket = liveSocket 41 | 42 | -------------------------------------------------------------------------------- /assets/tailwind.config.js: -------------------------------------------------------------------------------- 1 | // See the Tailwind configuration guide for advanced usage 2 | // https://tailwindcss.com/docs/configuration 3 | 4 | const plugin = require("tailwindcss/plugin") 5 | 6 | module.exports = { 7 | content: [ 8 | "./js/**/*.js", 9 | "../lib/*_web.ex", 10 | "../lib/*_web/**/*.*ex" 11 | ], 12 | theme: { 13 | extend: { 14 | colors: { 15 | brand: "#FD4F00", 16 | } 17 | }, 18 | }, 19 | plugins: [ 20 | require("@tailwindcss/forms"), 21 | plugin(({addVariant}) => addVariant("phx-no-feedback", [".phx-no-feedback&", ".phx-no-feedback &"])), 22 | plugin(({addVariant}) => addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"])), 23 | plugin(({addVariant}) => addVariant("phx-submit-loading", [".phx-submit-loading&", ".phx-submit-loading &"])), 24 | plugin(({addVariant}) => addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"])) 25 | ] 26 | } -------------------------------------------------------------------------------- /assets/vendor/topbar.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license MIT 3 | * topbar 1.0.0, 2021-01-06 4 | * Modifications: 5 | * - add delayedShow(time) (2022-09-21) 6 | * https://buunguyen.github.io/topbar 7 | * Copyright (c) 2021 Buu Nguyen 8 | */ 9 | (function (window, document) { 10 | "use strict"; 11 | 12 | // https://gist.github.com/paulirish/1579671 13 | (function () { 14 | var lastTime = 0; 15 | var vendors = ["ms", "moz", "webkit", "o"]; 16 | for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) { 17 | window.requestAnimationFrame = 18 | window[vendors[x] + "RequestAnimationFrame"]; 19 | window.cancelAnimationFrame = 20 | window[vendors[x] + "CancelAnimationFrame"] || 21 | window[vendors[x] + "CancelRequestAnimationFrame"]; 22 | } 23 | if (!window.requestAnimationFrame) 24 | window.requestAnimationFrame = function (callback, element) { 25 | var currTime = new Date().getTime(); 26 | var timeToCall = Math.max(0, 16 - (currTime - lastTime)); 27 | var id = window.setTimeout(function () { 28 | callback(currTime + timeToCall); 29 | }, timeToCall); 30 | lastTime = currTime + timeToCall; 31 | return id; 32 | }; 33 | if (!window.cancelAnimationFrame) 34 | window.cancelAnimationFrame = function (id) { 35 | clearTimeout(id); 36 | }; 37 | })(); 38 | 39 | var canvas, 40 | currentProgress, 41 | showing, 42 | progressTimerId = null, 43 | fadeTimerId = null, 44 | delayTimerId = null, 45 | addEvent = function (elem, type, handler) { 46 | if (elem.addEventListener) elem.addEventListener(type, handler, false); 47 | else if (elem.attachEvent) elem.attachEvent("on" + type, handler); 48 | else elem["on" + type] = handler; 49 | }, 50 | options = { 51 | autoRun: true, 52 | barThickness: 3, 53 | barColors: { 54 | 0: "rgba(26, 188, 156, .9)", 55 | ".25": "rgba(52, 152, 219, .9)", 56 | ".50": "rgba(241, 196, 15, .9)", 57 | ".75": "rgba(230, 126, 34, .9)", 58 | "1.0": "rgba(211, 84, 0, .9)", 59 | }, 60 | shadowBlur: 10, 61 | shadowColor: "rgba(0, 0, 0, .6)", 62 | className: null, 63 | }, 64 | repaint = function () { 65 | canvas.width = window.innerWidth; 66 | canvas.height = options.barThickness * 5; // need space for shadow 67 | 68 | var ctx = canvas.getContext("2d"); 69 | ctx.shadowBlur = options.shadowBlur; 70 | ctx.shadowColor = options.shadowColor; 71 | 72 | var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0); 73 | for (var stop in options.barColors) 74 | lineGradient.addColorStop(stop, options.barColors[stop]); 75 | ctx.lineWidth = options.barThickness; 76 | ctx.beginPath(); 77 | ctx.moveTo(0, options.barThickness / 2); 78 | ctx.lineTo( 79 | Math.ceil(currentProgress * canvas.width), 80 | options.barThickness / 2 81 | ); 82 | ctx.strokeStyle = lineGradient; 83 | ctx.stroke(); 84 | }, 85 | createCanvas = function () { 86 | canvas = document.createElement("canvas"); 87 | var style = canvas.style; 88 | style.position = "fixed"; 89 | style.top = style.left = style.right = style.margin = style.padding = 0; 90 | style.zIndex = 100001; 91 | style.display = "none"; 92 | if (options.className) canvas.classList.add(options.className); 93 | document.body.appendChild(canvas); 94 | addEvent(window, "resize", repaint); 95 | }, 96 | topbar = { 97 | config: function (opts) { 98 | for (var key in opts) 99 | if (options.hasOwnProperty(key)) options[key] = opts[key]; 100 | }, 101 | delayedShow: function(time) { 102 | if (showing) return; 103 | if (delayTimerId) return; 104 | delayTimerId = setTimeout(() => topbar.show(), time); 105 | }, 106 | show: function () { 107 | if (showing) return; 108 | showing = true; 109 | if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId); 110 | if (!canvas) createCanvas(); 111 | canvas.style.opacity = 1; 112 | canvas.style.display = "block"; 113 | topbar.progress(0); 114 | if (options.autoRun) { 115 | (function loop() { 116 | progressTimerId = window.requestAnimationFrame(loop); 117 | topbar.progress( 118 | "+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2) 119 | ); 120 | })(); 121 | } 122 | }, 123 | progress: function (to) { 124 | if (typeof to === "undefined") return currentProgress; 125 | if (typeof to === "string") { 126 | to = 127 | (to.indexOf("+") >= 0 || to.indexOf("-") >= 0 128 | ? currentProgress 129 | : 0) + parseFloat(to); 130 | } 131 | currentProgress = to > 1 ? 1 : to; 132 | repaint(); 133 | return currentProgress; 134 | }, 135 | hide: function () { 136 | clearTimeout(delayTimerId); 137 | delayTimerId = null; 138 | if (!showing) return; 139 | showing = false; 140 | if (progressTimerId != null) { 141 | window.cancelAnimationFrame(progressTimerId); 142 | progressTimerId = null; 143 | } 144 | (function loop() { 145 | if (topbar.progress("+.1") >= 1) { 146 | canvas.style.opacity -= 0.05; 147 | if (canvas.style.opacity <= 0.05) { 148 | canvas.style.display = "none"; 149 | fadeTimerId = null; 150 | return; 151 | } 152 | } 153 | fadeTimerId = window.requestAnimationFrame(loop); 154 | })(); 155 | }, 156 | }; 157 | 158 | if (typeof module === "object" && typeof module.exports === "object") { 159 | module.exports = topbar; 160 | } else if (typeof define === "function" && define.amd) { 161 | define(function () { 162 | return topbar; 163 | }); 164 | } else { 165 | this.topbar = topbar; 166 | } 167 | }.call(this, window, document)); 168 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Config module. 3 | # 4 | # This configuration file is loaded before any dependency and 5 | # is restricted to this project. 6 | 7 | # General application configuration 8 | import Config 9 | 10 | # Configures the endpoint 11 | config :app, AppWeb.Endpoint, 12 | url: [host: "localhost"], 13 | render_errors: [ 14 | formats: [html: AppWeb.ErrorHTML, json: AppWeb.ErrorJSON], 15 | layout: false 16 | ], 17 | pubsub_server: App.PubSub, 18 | live_view: [signing_salt: "u48tsthN"] 19 | 20 | # Configure esbuild (the version is required) 21 | config :esbuild, 22 | version: "0.14.41", 23 | default: [ 24 | args: 25 | ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), 26 | cd: Path.expand("../assets", __DIR__), 27 | env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} 28 | ] 29 | 30 | # Configure tailwind (the version is required) 31 | config :tailwind, 32 | version: "3.1.8", 33 | default: [ 34 | args: ~w( 35 | --config=tailwind.config.js 36 | --input=css/app.css 37 | --output=../priv/static/assets/app.css 38 | ), 39 | cd: Path.expand("../assets", __DIR__) 40 | ] 41 | 42 | # Configures Elixir's Logger 43 | config :logger, :console, 44 | format: "$time $metadata[$level] $message\n", 45 | metadata: [:request_id] 46 | 47 | # Use Jason for JSON parsing in Phoenix 48 | config :phoenix, :json_library, Jason 49 | 50 | # Import environment specific config. This must remain at the bottom 51 | # of this file so it overrides the configuration defined above. 52 | import_config "#{config_env()}.exs" 53 | 54 | # Stripe 55 | config :stripity_stripe, api_key: System.get_env("STRIPE_API_KEY") 56 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # For development, we disable any cache and enable 4 | # debugging and code reloading. 5 | # 6 | # The watchers configuration can be used to run external 7 | # watchers to your application. For example, we use it 8 | # with esbuild to bundle .js and .css sources. 9 | config :app, AppWeb.Endpoint, 10 | # Binding to loopback ipv4 address prevents access from other machines. 11 | # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. 12 | http: [ip: {127, 0, 0, 1}, port: 4000], 13 | check_origin: false, 14 | code_reloader: true, 15 | debug_errors: true, 16 | secret_key_base: 17 | "bv1heeBXJGvlEsc6SI66xUox+004UlT+aRAH+UlGgMxGuGMXCbEK32pVx0QNlJxN", 18 | watchers: [ 19 | esbuild: 20 | {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]}, 21 | tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]} 22 | ] 23 | 24 | # ## SSL Support 25 | # 26 | # In order to use HTTPS in development, a self-signed 27 | # certificate can be generated by running the following 28 | # Mix task: 29 | # 30 | # mix phx.gen.cert 31 | # 32 | # Run `mix help phx.gen.cert` for more information. 33 | # 34 | # The `http:` config above can be replaced with: 35 | # 36 | # https: [ 37 | # port: 4001, 38 | # cipher_suite: :strong, 39 | # keyfile: "priv/cert/selfsigned_key.pem", 40 | # certfile: "priv/cert/selfsigned.pem" 41 | # ], 42 | # 43 | # If desired, both `http:` and `https:` keys can be 44 | # configured to run both http and https servers on 45 | # different ports. 46 | 47 | # Watch static and templates for browser reloading. 48 | config :app, AppWeb.Endpoint, 49 | live_reload: [ 50 | patterns: [ 51 | ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$", 52 | ~r"priv/gettext/.*(po)$", 53 | ~r"lib/app_web/(live|views)/.*(ex)$", 54 | ~r"lib/app_web/templates/.*(eex)$" 55 | ] 56 | ] 57 | 58 | # Enable dev routes for dashboard and mailbox 59 | config :app, dev_routes: true 60 | 61 | # Do not include metadata nor timestamps in development logs 62 | config :logger, :console, format: "[$level] $message\n" 63 | 64 | # Set a higher stacktrace during development. Avoid configuring such 65 | # in production as building large stacktraces may be expensive. 66 | config :phoenix, :stacktrace_depth, 20 67 | 68 | # Initialize plugs at runtime for faster development compilation 69 | config :phoenix, :plug_init_mode, :runtime 70 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # For production, don't forget to configure the url host 4 | # to something meaningful, Phoenix uses this information 5 | # when generating URLs. 6 | 7 | # Note we also include the path to a cache manifest 8 | # containing the digested version of static files. This 9 | # manifest is generated by the `mix phx.digest` task, 10 | # which you should run after static files are built and 11 | # before starting your production server. 12 | config :app, AppWeb.Endpoint, 13 | cache_static_manifest: "priv/static/cache_manifest.json" 14 | 15 | # Do not print debug messages in production 16 | config :logger, level: :info 17 | 18 | # Runtime production configuration, including reading 19 | # of environment variables, is done on config/runtime.exs. 20 | -------------------------------------------------------------------------------- /config/runtime.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # config/runtime.exs is executed for all environments, including 4 | # during releases. It is executed after compilation and before the 5 | # system starts, so it is typically used to load production configuration 6 | # and secrets from environment variables or elsewhere. Do not define 7 | # any compile-time configuration in here, as it won't be applied. 8 | # The block below contains prod specific runtime configuration. 9 | 10 | # ## Using releases 11 | # 12 | # If you use `mix release`, you need to explicitly enable the server 13 | # by passing the PHX_SERVER=true when you start it: 14 | # 15 | # PHX_SERVER=true bin/app start 16 | # 17 | # Alternatively, you can use `mix phx.gen.release` to generate a `bin/server` 18 | # script that automatically sets the env var above. 19 | if System.get_env("PHX_SERVER") do 20 | config :app, AppWeb.Endpoint, server: true 21 | end 22 | 23 | if config_env() == :prod do 24 | # The secret key base is used to sign/encrypt cookies and other secrets. 25 | # A default value is used in config/dev.exs and config/test.exs but you 26 | # want to use a different value for prod and you most likely don't want 27 | # to check this value into version control, so we use an environment 28 | # variable instead. 29 | secret_key_base = 30 | System.get_env("SECRET_KEY_BASE") || 31 | raise """ 32 | environment variable SECRET_KEY_BASE is missing. 33 | You can generate one by calling: mix phx.gen.secret 34 | """ 35 | 36 | host = System.get_env("PHX_HOST") || "example.com" 37 | port = String.to_integer(System.get_env("PORT") || "4000") 38 | 39 | config :app, AppWeb.Endpoint, 40 | url: [host: host, port: 443, scheme: "https"], 41 | http: [ 42 | # Enable IPv6 and bind on all interfaces. 43 | # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. 44 | # See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html 45 | # for details about using IPv6 vs IPv4 and loopback vs public addresses. 46 | ip: {0, 0, 0, 0, 0, 0, 0, 0}, 47 | port: port 48 | ], 49 | secret_key_base: secret_key_base 50 | 51 | # ## SSL Support 52 | # 53 | # To get SSL working, you will need to add the `https` key 54 | # to your endpoint configuration: 55 | # 56 | # config :app, AppWeb.Endpoint, 57 | # https: [ 58 | # ..., 59 | # port: 443, 60 | # cipher_suite: :strong, 61 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), 62 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH") 63 | # ] 64 | # 65 | # The `cipher_suite` is set to `:strong` to support only the 66 | # latest and more secure SSL ciphers. This means old browsers 67 | # and clients may not be supported. You can set it to 68 | # `:compatible` for wider support. 69 | # 70 | # `:keyfile` and `:certfile` expect an absolute path to the key 71 | # and cert in disk or a relative path inside priv, for example 72 | # "priv/ssl/server.key". For all supported SSL configuration 73 | # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1 74 | # 75 | # We also recommend setting `force_ssl` in your endpoint, ensuring 76 | # no data is ever sent via http, always redirecting to https: 77 | # 78 | # config :app, AppWeb.Endpoint, 79 | # force_ssl: [hsts: true] 80 | # 81 | # Check `Plug.SSL` for all available options in `force_ssl`. 82 | 83 | # ## Configuring the mailer 84 | # 85 | # In production you need to configure the mailer to use a different adapter. 86 | # Also, you may need to configure the Swoosh API client of your choice if you 87 | # are not using SMTP. Here is an example of the configuration: 88 | # 89 | # config :app, App.Mailer, 90 | # adapter: Swoosh.Adapters.Mailgun, 91 | # api_key: System.get_env("MAILGUN_API_KEY"), 92 | # domain: System.get_env("MAILGUN_DOMAIN") 93 | # 94 | # For this example you need include a HTTP client required by Swoosh API client. 95 | # Swoosh supports Hackney and Finch out of the box: 96 | # 97 | # config :swoosh, :api_client, Swoosh.ApiClient.Hackney 98 | # 99 | # See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details. 100 | end 101 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # We don't run a server during test. If one is required, 4 | # you can enable the server option below. 5 | config :app, AppWeb.Endpoint, 6 | http: [ip: {127, 0, 0, 1}, port: 4002], 7 | secret_key_base: 8 | "lrn6ftfPZu7KSTQ54v4foc/gq2FgYfB9/ckQw+Hhi/NNcUM7nf/mUlTqZWJOXAoK", 9 | server: false 10 | 11 | # Print only warnings and errors during test 12 | config :logger, level: :warning 13 | 14 | # Initialize plugs at runtime for faster test compilation 15 | config :phoenix, :plug_init_mode, :runtime 16 | 17 | # Don't worry, this AUTH_API_KEY is NOT valid. 18 | # It's just for ensuring it passes on GitHub CI 19 | # see: github.com/dwyl/mvp/issues/258 20 | config :auth_plug, 21 | api_key: 22 | "2PzB7PPnpuLsbWmWtXpGyI+kfSQSQ1zUW2Atz/+8PdZuSEJzHgzGnJWV35nTKRwx/authdemo.fly.dev" 23 | -------------------------------------------------------------------------------- /coveralls.json: -------------------------------------------------------------------------------- 1 | { 2 | "coverage_options": { 3 | "minimum_coverage": 100 4 | }, 5 | "skip_files": [ 6 | "test/", 7 | "lib/app/application.ex", 8 | "lib/app/release.ex", 9 | "lib/app_web.ex", 10 | "lib/app_web/gettext.ex", 11 | "lib/app_web/components/", 12 | "lib/app_web/telemetry.ex" 13 | ] 14 | } -------------------------------------------------------------------------------- /example.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # Let's Build! 👩‍💻 ![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/dwyl/learn-payment-processing/ci.yml?label=build&style=flat-square&branch=main) 4 | 5 | 6 |
7 | 8 | In this section, 9 | we are going to implement an example application 10 | using `Stripe` with `Elixir`. 11 | 12 | 13 | > If you are new to `Elixir`, 14 | we recommend reading 15 | [`learn-elixir`](https://github.com/dwyl/learn-elixir) 16 | and [`learn-phoenix-framework`](https://github.com/dwyl/learn-phoenix-framework) 17 | ***`before`*** you start this walk-through, 18 | as focus on payment processing 19 | and not on the basics of a `Phoenix` project. 20 | 21 | > **Note**: The reason we went with `Stripe` 22 | and not any other alternative like `Paddle` 23 | is because `Stripe` allows us to create a Developer account 24 | without having to fill business-related information, 25 | KYC and a company website.
26 | > We are going to see how far we can get with `Stripe` 27 | and document the process. 28 | If we decide we _need_ the features/simplicity 29 | offered by `Paddle` we will switch and document that too. 30 | 31 | ## Pre-requisites 📝 32 | 33 | For this tutorial, 34 | you will need to create a `Stripe` account. 35 | Visit: 36 | [stripe.com/register](https://dashboard.stripe.com/register) 37 | to create an account. 38 | 39 | ![stripe-register](https://user-images.githubusercontent.com/194400/211686517-6ccd95d0-1006-46d0-a4aa-6cadf01025a9.png) 40 | 41 | After inputting the required `Email`, `Full name` and `Password` 42 | you will receive an email requiring you to **`Verify your email`**: 43 | 44 | ![stripe-verify-email](https://user-images.githubusercontent.com/194400/211687211-fbfd50c9-9df3-44fd-bd09-62e1c3982430.png) 45 | 46 | Once you click the button, 47 | you will see a screen similar to this: 48 | 49 | ![activate-payments-skip](https://user-images.githubusercontent.com/194400/211686999-5a90827f-e2be-4155-9051-21b513cf9084.png) 50 | 51 | Click on **`skip for now`** to proceed to the main dashboard: 52 | 53 | ![stripe-dashboard](https://user-images.githubusercontent.com/194400/211688125-eb3aabfa-8142-49f7-b043-2b0ab4b3ab70.png) 54 | 55 | If you type "**API**" in the search box 56 | and choose **`Developers > API Keys`** ... 57 | 58 | ![search-api-keys](https://user-images.githubusercontent.com/194400/211688294-a16d1c2e-ec2e-46f0-b89a-5c2481a77442.png) 59 | 60 | Or navigate directly to it: 61 | [dashboard.stripe.com/test/**apikeys**](https://dashboard.stripe.com/test/apikeys) 62 | 63 | ![stripe-test-api-keys](https://user-images.githubusercontent.com/194400/211688826-429d2905-49f9-4974-8654-08b3607b43f5.png) 64 | 65 | > **Note**: don't worry, these `API Keys` aren't valid. 66 | > This is just for illustration purposes. 67 | 68 | The **_Test_ API keys** will be used later. 69 | Save them and don't share them with anyone. 70 | We are going to be using these 71 | as 72 | [environment variables](https://github.com/dwyl/learn-environment-variables). 73 | 74 | 75 | ## 1. Create a Phoenix project 76 | 77 | Let's start by creating a Phoenix project. 78 | Run: 79 | ```sh 80 | mix phx.new app --no-ecto --no-mailer --no-dashboard --no-gettext 81 | ``` 82 | and when prompted, type `y` to accept downloading the dependencies. 83 | 84 | After this, if you run 85 | `mix phx.server` 86 | and visit `localhost:4000`, 87 | you will be able to see the default landing page. 88 | 89 | ![default](https://user-images.githubusercontent.com/17494745/209141154-a9d88988-6a36-4faa-8bbf-f1cf09684bf5.png) 90 | 91 | We want the `person` using the `App` to be able to log in. 92 | We will check if they have *paid* or not 93 | for using the `App`. 94 | If they haven't, they are redirected to a `buy` page. 95 | If they have, they will have access to it! 96 | 97 | It's a simple `App`, for sure. 98 | But it's still important 99 | to know ***how*** to properly implement it. 100 | 101 | ## 2. Add `auth_plug` for user login 102 | 103 | We will be using 104 | [`auth_plug`](https://github.com/dwyl/auth_plug) 105 | so `people` are able to login. 106 | 107 | Let's install it. 108 | Add the following to the `deps` section in `mix.exs`: 109 | 110 | ```elixir 111 | def deps do 112 | [ 113 | {:auth_plug, "~> 1.5.1"}, 114 | ] 115 | end 116 | ``` 117 | 118 | Once you've saved the file, 119 | run: 120 | ``` 121 | mix deps.get 122 | ``` 123 | 124 | Follow the 125 | [instructions](https://github.com/dwyl/auth_plug#2-get-your-auth_api_key-) 126 | and get your `AUTH_API_KEY`. 127 | 128 | Next create a file called: `.env` 129 | and paste the `AUTH_API_KEY`, e.g: 130 | 131 | ```sh 132 | export AUTH_API_KEY=YOURKEYHERE 133 | ``` 134 | 135 | > **Note**: **never `commit` the `.env` file** to `GitHub` 136 | as it contains sensitive information you don't want others to see. 137 | 138 | Open: 139 | `lib/app_web/router.ex` 140 | and add the following lines of code: 141 | 142 | ```elixir 143 | pipeline :auth, do: plug(AuthPlug) 144 | 145 | scope "/dashboard", AppWeb do 146 | pipe_through :browser 147 | pipe_through :auth 148 | 149 | get "/", AppController, :home 150 | end 151 | ``` 152 | 153 | The `/dashboard` protected endpoint will only be accessible 154 | for logged in users because we are using the 155 | `:auth` pipeline. 156 | 157 | We are using `AppController`, 158 | which is not yet created. 159 | Create the following 4 files: 160 | 161 | 1. `lib/app_web/controllers/app_controller.ex` 162 | 2. `lib/app_web/controllers/app_html.ex` 163 | 3. `lib/app_web/controllers/app_html/app.html.heex` 164 | 4. `test/app_web/controllers/auth_controller_test.exs` 165 | 166 | 167 | In `app_controller.ex`, add the following code: 168 | 169 | ```elixir 170 | defmodule AppWeb.AppController do 171 | use AppWeb, :controller 172 | 173 | def home(conn, _params) do 174 | render(conn, :app, layout: false) 175 | end 176 | end 177 | ``` 178 | 179 | In `app_html.ex`, 180 | add the following code: 181 | 182 | ```elixir 183 | defmodule AppWeb.AppHTML do 184 | use AppWeb, :html 185 | 186 | embed_templates "app_html/*" 187 | end 188 | ``` 189 | 190 | In `app_html/app.html.heex`: 191 | 192 | ```html 193 |
194 | logged in 195 |
196 | ``` 197 | 198 | In `test/app_web/controllers/auth_controller_test.exs`: 199 | 200 | ```elixir 201 | defmodule AppWeb.AuthControllerTest do 202 | use AppWeb.ConnCase, async: true 203 | 204 | test "Logout link displayed when loggedin", %{conn: conn} do 205 | data = %{ 206 | username: "test_username", 207 | email: "test@email.com", 208 | givenName: "John Doe", 209 | picture: "this", 210 | auth_provider: "GitHub", 211 | id: 1 212 | } 213 | 214 | jwt = AuthPlug.Token.generate_jwt!(data) 215 | 216 | conn = get(conn, "/?jwt=#{jwt}") 217 | assert html_response(conn, 200) =~ "logout" 218 | end 219 | 220 | test "get /logout with valid JWT", %{conn: conn} do 221 | data = %{ 222 | email: "al@dwyl.com", 223 | givenName: "Al", 224 | picture: "this", 225 | auth_provider: "GitHub", 226 | id: 1 227 | } 228 | 229 | jwt = AuthPlug.Token.generate_jwt!(data) 230 | 231 | conn = 232 | conn 233 | |> put_req_header("authorization", jwt) 234 | |> get("/logout") 235 | 236 | assert "/" = redirected_to(conn, 302) 237 | end 238 | 239 | test "test login link redirect to authdemo.fly.dev", %{conn: conn} do 240 | conn = get(conn, "/login") 241 | assert redirected_to(conn, 302) =~ "authdemo.fly.dev" 242 | end 243 | end 244 | ``` 245 | 246 | 247 | If you run: 248 | ```sh 249 | source .env 250 | ``` 251 | 252 | and restart your server with: 253 | `mix phx.server` 254 | and access `/dashboard` directly, 255 | you will be redirected to a page 256 | where you can SSO using `Google` or `Github`. 257 | 258 | ![redirect-to-auth](https://user-images.githubusercontent.com/194400/212059730-91137e3a-9a34-43e1-9cbe-9732511adcbd.png) 259 | 260 | After logging in, 261 | the user has access to the URL! 262 | 263 | successful_login 264 | 265 | Congratulations, you just added basic authenticated to our application! 266 | However, that's not quite what we want. 267 | We want the user to be logged in 268 | and *also* being a paying costumer so they have access to `/dashboard`. 269 | 270 | We can make it prettier, though. 271 | Let's add two endpoints: 272 | - one to login (`/login`) 273 | - one to logout (`/logout`) 274 | 275 | From the landing page, 276 | we are going to be adding a button that will log the user in, 277 | and redirect to the `/login` URL. 278 | After logging in, he will be redirected back to the home page. 279 | We will need to conditionally render the home page (`/`) 280 | according to the user being logged in or not. 281 | 282 | For this, we are going to be using 283 | an [`optional auth pipeline`](https://github.com/dwyl/auth_plug#optional-auth). 284 | It will add allow us show custom actions on people who are authenticated or not. 285 | This pipeline will add the logged in to the `conn.assigns`. 286 | 287 | Head on to `lib/app_web/router.ex` 288 | and add/change the following piece of code. 289 | 290 | ```elixir 291 | pipeline :authoptional, do: plug(AuthPlugOptional, %{}) 292 | 293 | scope "/", AppWeb do 294 | pipe_through :browser 295 | pipe_through :authoptional 296 | 297 | get "/", PageController, :home 298 | get "/login", AuthController, :login 299 | get "/logout", AuthController, :logout 300 | end 301 | ``` 302 | 303 | We are now using a new controller `AuthController`. 304 | Let's create it. 305 | Inside `lib/app_web/controllers`, 306 | create a new file called `auth_controller.ex` 307 | and paste the following. 308 | 309 | ```elixir 310 | defmodule AppWeb.AuthController do 311 | use AppWeb, :controller 312 | 313 | def login(conn, _params) do 314 | redirect(conn, external: AuthPlug.get_auth_url(conn, ~p"/")) 315 | end 316 | 317 | def logout(conn, _params) do 318 | conn 319 | |> AuthPlug.logout() 320 | |> put_status(302) 321 | |> redirect(to: ~p"/") 322 | end 323 | end 324 | ``` 325 | 326 | We just now need to change the view 327 | to redirect to these URLs (`login` and `logout`). 328 | Head over to `lib/app_web/controllers/page_html/home.html.heex`, 329 | locate the line. 330 | 331 | ```html 332 |
333 | ``` 334 | 335 | From this line to the end of the file, 336 | change the contents with the next lines: 337 | 338 | ```html 339 |
340 |
341 | 347 |

348 | Phoenix Framework 349 | 350 | with Stripe 351 | 352 |

353 |

354 | An example integrating 355 | Stripe 356 | with 357 | Phoenix 358 |

359 |

360 | This is a small project showcasing how to integrate Stripe in a Phoenix project. 361 | The workflow is simple: a logged in user has to pay to have access to

/dashboard
362 |

363 |

364 | Otherwise, they are redirected to a buying page to purchase so they can access it. 365 |

366 | 367 | <%= if Map.has_key?(@conn.assigns, :person) do %> 368 |
369 | 370 |

371 | Hey there, <%= @conn.assigns.person.username %>! 372 |

373 |

374 | Thanks for logging in! 375 | It seems that you haven't purchased our sweet dashboard. 376 | You have to "purchase" to proceed. 377 |

378 | 379 |
380 |
381 | 382 | 387 | <.link 388 | navigate={~p"/logout"} 389 | class="text-purple-700 font-medium rounded-lg text-sm px-5 py-2.5 text-center mr-2 mb-2" 390 | > 391 | Logout 392 | 393 |
394 |
395 | 396 |
397 | <% else %> 398 |
399 |
400 |
401 | <.link 402 | navigate={~p"/login"} 403 | class="text-white bg-gradient-to-br from-pink-500 to-orange-400 404 | hover:bg-gradient-to-bl 405 | focus:ring-4 focus:outline-none focus:ring-pink-200 dark:focus:ring-pink-800 406 | font-medium rounded-lg text-sm px-5 py-2.5 text-center mr-2 mb-2" 407 | > 408 | Login 409 | 410 |
411 |
412 |
413 | <% end %> 414 |
415 |
416 | ``` 417 | 418 | And you should be done! 419 | In our main page we are checking if any user is logged in or not. 420 | If there's a user authenticated, 421 | we show his username and a button in which he can press 422 | to purchase our sweet dashboard. 423 | 424 | On the other hand, if there isn't any user authenticated, 425 | a `login` button is shown. 426 | 427 | The app should look like the following. 428 | 429 | ![with_auth](https://user-images.githubusercontent.com/17494745/209171533-2a51572b-2d0f-4789-a28b-7d77771caaa5.gif) 430 | 431 | Now that we got that out of the way, 432 | we can now *focus on integrating `Stripe`* 433 | in our Phoenix application. 434 | 435 | This is going to be fun, let's do this! 👏 436 | 437 | ## 3. Stripe integration 438 | 439 | Let's start by installing the package 440 | that will allows us to communicate with **Stripe**. 441 | We are going to be using 442 | [`stripity-stripe`](https://github.com/beam-community/stripity-stripe). 443 | This library will allow us to easily integrate Stripe in our Phoenix application. 444 | 445 | The library is maintained by the `BEAM Community`, 446 | has 150+ contributors and is considered stable/reliable: 447 | 448 | ![stripity-stripe](https://user-images.githubusercontent.com/194400/212060290-9044031a-4d6a-4127-b049-278c0543297f.png) 449 | 450 | 451 | Go to your `mix.exs` file 452 | and add this inside your dependency section. 453 | 454 | ```elixir 455 | {:stripity_stripe, "~> 2.17"} 456 | ``` 457 | 458 | and run `mix deps.get` to fetch this new dependency. 459 | Following [their documentation](https://github.com/beam-community/stripity-stripe#configuration), 460 | we need to add the next configuration inside `config.ex`. 461 | 462 | ```elixir 463 | config :stripity_stripe, api_key: System.get_env("STRIPE_API_KEY") 464 | ``` 465 | 466 | As you can see, we are using an environment variable 467 | to serve the `STRIKE_API_KEY` so the library can use it to make requests. 468 | We need to add these keys to our `.env` file. 469 | To check your own API keys, 470 | go to https://dashboard.stripe.com/test/apikey 471 | 472 | keys 473 | 474 | and paste the keys in your `.env` file. 475 | 476 | ``` 477 | export STRIPE_API_KEY= secret key 478 | export STRIPE_PUBLIC= publishable key 479 | ``` 480 | 481 | After inputting these keys, 482 | you can stop the server and, 483 | in the same terminal session, run `source .env`, 484 | so you can load these environment variables 485 | and so they are available when you run `mix phx.server` again. 486 | 487 | Awesome job! 🎉 488 | We can now start using it! 489 | 490 | ### 3.1 Creating a **Stripe Checkout Session** 491 | 492 | To make this tutorial simple, 493 | we are going to be using a [`Stripe Checkout`](https://stripe.com/docs/payments/checkout). 494 | This makes it easy to make a payment 495 | because the user is redirected to a page hosted by `Stripe` 496 | with information about the product being purchased. 497 | Therefore, we don't need to create the page ourselves. 498 | We *could* but this is quicker 😉. 499 | 500 | With this in mind, 501 | we want to create a [`checkout session`](https://stripe.com/docs/api/checkout/sessions). 502 | The user will either be successful with his payment 503 | or fail. 504 | We will be building a page for both. 505 | 506 | Let's implement this. 507 | 508 | Head on to to `lib/app_web/router.ex` 509 | and add the following scope. 510 | 511 | ```elixir 512 | scope "/purchase", AppWeb do 513 | pipe_through :browser 514 | pipe_through :auth 515 | 516 | resources "/checkout-session", CheckoutSessionController, only: [:create] 517 | 518 | get "/success", PageController, :success 519 | get "/cancel", PageController, :cancel 520 | end 521 | ``` 522 | 523 | We want **only authenticated users** to go to 524 | `/purchase/checkout-session` to purchase our product. 525 | When they visit this URL, they will be redirected to our `Stripe Checkout` page, 526 | with information about our product. 527 | They payment will either *succeed* 528 | (users will be redirected to `/purchase/success`) 529 | or *fail* 530 | (users will be redirected to `/purchase/cancel`). 531 | 532 | While the `success` and `cancel` pages 533 | are being handled by the already existent `PageController`, 534 | redirecting the users to the `Stripe Checkout` page 535 | is being handled by `CheckoutSessionController`. 536 | We need to create it! 537 | 538 | Inside `lib/app_web/controllers/`, 539 | create a file called `checkout_session_controller.ex` 540 | and paste the code: 541 | 542 | ```elixir 543 | defmodule AppWeb.CheckoutSessionController do 544 | use AppWeb, :controller 545 | 546 | def create(conn, _params) do 547 | url = AppWeb.Endpoint.url() 548 | 549 | params = %{ 550 | line_items: [ 551 | %{ 552 | price: System.get_env("PRODUCT_PRICE_ID"), 553 | quantity: 1 554 | } 555 | ], 556 | mode: "payment", 557 | # https://stripe.com/docs/payments/checkout/custom-success-page 558 | success_url: url <> ~p"/purchase/success?session_id={CHECKOUT_SESSION_ID}", 559 | cancel_url: url <> ~p"/purchase/cancel?session_id={CHECKOUT_SESSION_ID}", 560 | # user_email: conn.assigns.person.email, 561 | automatic_tax: %{enabled: true} 562 | } 563 | 564 | {:ok, session} = Stripe.Checkout.Session.create(params) 565 | 566 | conn 567 | |> put_status(303) 568 | |> redirect(external: session.url) 569 | end 570 | end 571 | ``` 572 | 573 | Let's analyze the code. 574 | 575 | We are using `Stripe.Checkout.Session.create/1` 576 | to create a [`Session`](https://stripe.com/docs/api/checkout/sessions) 577 | in Stripe. 578 | We need to pass a few required parameters. 579 | We are following [their API specification](https://stripe.com/docs/api/checkout/sessions/object), 580 | so you can always check their documentation if you're lost 581 | on what fields you need to pass. 582 | - the `**mode**` field pertains to the mode of the checkout session. 583 | We can setup subscription or one-time payments. 584 | We are doing the latter. 585 | - the `**success_url**` and `**cancel_url**` refer to the redirection URLs 586 | after the user either completes or cancels the checkout session. 587 | We are adding a `{CHECKOUT_SESSION_ID}` template in the URL. 588 | This tells Stripe to pass the `Checkout Session ID` to the client-side. 589 | [This will allow us to customize the order information 590 | upon completion or cancellation.](https://stripe.com/docs/payments/checkout/custom-success-page) 591 | - we set `**automatic_tax**` to be enabled so it makes tax conversions automatically. 592 | - the `**line_items**` array refer to the list of items the customer is purchasing. 593 | We are passing the item `id` and the `quantity`. 594 | - you can optionally pass a `customer_email`, 595 | so the field is already defined within the checkout session from the get-go. 596 | 597 | We are then creating a session 598 | and redirecting the user to the `Checkout` page. 599 | 600 | As you might have noticed, 601 | we are using an environment variable `PRODUCT_PRICE_ID` 602 | to set `price` field of the product. 603 | 604 | We *haven't created this yet*. 605 | So let's do it! 606 | 607 | ### 3.2 Creating a `Product` to sell 608 | 609 | As it stands, we have no products for customers to purchase. 610 | Let's create one! 😎 611 | 612 | Go to https://dashboard.stripe.com/test/products/create 613 | and fill up the information about your product. 614 | 615 | 616 | product_information 617 | 618 | > This should be all you need to do. 619 | However, sometimes you may experience an error 620 | while creating a `checkout session` 621 | when doing the request in Phoenix. 622 | > 623 | > The error is probably 624 | `Stripe Tax has not been activated on your account. 625 | Please visit https://dashboard.stripe.com/settings/tax/activate to get started.` 626 | Don't be alarmed. 627 | If you follow the link, enable `Stripe Tax` 628 | and just create a tax rate on whatever country you want, 629 | You can check your tax rates in 630 | https://dashboard.stripe.com/test/tax-rates. 631 | > 632 | > image 633 | 634 | After creating your product, 635 | you will be redirected to the page of the product created. 636 | You can always check the products you created 637 | in https://dashboard.stripe.com/test/products. 638 | 639 | The page of the product created should look like this. 640 | 641 | product page 642 | 643 | We are going to be using the `API ID` 644 | in the Pricing section. 645 | Copy it and paste it in your `.env` file. 646 | 647 | ``` 648 | export PRODUCT_PRICE_ID= price id 649 | ``` 650 | 651 | In the same terminal session, kill the server, 652 | run `source .env` and you should be good to go! 653 | 654 | ### 3.3 Success and failure after `Checkout Session` 655 | 656 | As we've stated before, 657 | the users are redirected to a `success` or `cancel` page 658 | depending on the outcome of the `Checkout Session`. 659 | 660 | Since we defined these endpoints 661 | be controlled by `PageController`, 662 | we need to add these handlers. 663 | 664 | Go to `lib/app_web/controllers/page_controller.ex` 665 | and add the following piece of code: 666 | 667 | ```elixir 668 | def success(conn, %{"session_id" => session_id}) do 669 | case Stripe.Checkout.Session.retrieve(session_id) do 670 | {:ok, _session} -> 671 | render(conn, :success, layout: false) 672 | 673 | {:error, _error} -> 674 | conn 675 | |> put_status(303) 676 | |> redirect(to: ~p"/") 677 | end 678 | end 679 | 680 | def success(conn, _params) do 681 | conn 682 | |> put_status(303) 683 | |> redirect(to: ~p"/") 684 | end 685 | 686 | def cancel(conn, %{"session_id" => session_id}) do 687 | 688 | case Stripe.Checkout.Session.retrieve(session_id) do 689 | {:ok, _session} -> 690 | render(conn, :cancel, layout: false) 691 | 692 | {:error, _error} -> 693 | conn 694 | |> put_status(303) 695 | |> redirect(to: ~p"/") 696 | end 697 | end 698 | 699 | def cancel(conn, _params) do 700 | conn 701 | |> put_status(303) 702 | |> redirect(to: ~p"/") 703 | end 704 | ``` 705 | 706 | Let's break it down. 707 | 708 | When creating a session, 709 | we are requesting Stripe to redirect the user 710 | *back* to us with a query parameter `session_id` 711 | with the session ID. 712 | 713 | This `session_id` will allow us to 714 | conditionally render these pages according to the outcome 715 | of the process. 716 | 717 | Both `success` and `cancel` workflows have the same workflow. 718 | 719 | When the customer successfully pays for the product, 720 | we check if the `session_id` is valid 721 | by retrieving it from `Stripe`. 722 | 723 | ```elixir 724 | Stripe.Checkout.Session.retrieve(session_id) 725 | ``` 726 | 727 | If it is successful, we render a page confirming the payment. 728 | If it is not, we simply redirect the user to the homepage. 729 | If the user tries to directly access `/purchase/success`, 730 | he is redirected to the homepage as well. 731 | 732 | The same procedure happens in the `cancel` scenario. 733 | 734 | We now need to create these pages! 735 | Inside `lib/app_web/controllers/page_html`, 736 | create two files. 737 | Create `success.html.heex` 738 | and use this code: 739 | 740 | ```html 741 |
742 |
743 | 744 | 746 | 747 | 748 |
749 |

Payment Done!

750 |

Thank you for completing your secure online payment.

751 |

Have a great day!

752 |
753 | <.link 754 | href={~p"/dashboard"} 755 | class="px-12 bg-indigo-600 hover:bg-indigo-500 text-white font-semibold py-3" 756 | > 757 | Go to dashboard 758 | 759 |
760 |
761 |
762 |
763 | ``` 764 | 765 | In this page, we are thanking the user 766 | for completing the payment 767 | and giving him the option to access 768 | the ever so glorious ✨**dashboard**✨. 769 | 770 | In the same directory, 771 | create the `cancel.html.heex` file: 772 | 773 | ```elixir 774 |
775 |
776 |
777 |

778 | Oh no... 779 |

780 |

looks like something went wrong.

781 |

Perhaps you cancelled your order? Internet went down? We can help you get back on track!

782 | <.link 783 | href={~p"/"} 784 | class="px-8 py-3 font-semibold rounded text-white bg-violet-400" 785 | > 786 | Back to homepage 787 | 788 |
789 |
790 |
791 | ``` 792 | 793 | In this page, 794 | we state that something went wrong 795 | and show a button to return to the homepage. 796 | 797 | ### 3.4 Making our "`dashboard`" cool 798 | 799 | We don't really have a `dashboard` to show. 800 | In fact, we don't need to, it's out of the scope of this tutorial. 801 | So let's just show a Nyan Cat! 🐈 802 | 803 | Inside `lib/app_web/controllers/app_html/app.html.heex`, 804 | change the code to the following: 805 | 806 | ```elixir 807 |
808 | Nyan cat 812 |
813 |

Nyan Cat!

814 |

Yup, it's a nyan cat. It's not a dashboard.

815 |

sorry.

816 | 817 | <.link 818 | navigate={~p"/"} 819 | class="text-white bg-gradient-to-r from-blue-500 via-blue-600 to-blue-700 hover:bg-gradient-to-br 820 | focus:ring-4 focus:outline-none focus:ring-blue-300 dark:focus:ring-blue-800 shadow-lg 821 | shadow-blue-500/50 dark:shadow-lg dark:shadow-blue-800/80 font-medium rounded-lg text-sm px-5 py-2.5 text-center mr-2 mb-2 " 822 | > 823 | go back 824 | 825 |
826 |
827 | ``` 828 | 829 | All that's left is for the `Purchase` button 830 | in our homepage to redirect the user 831 | to the `/purchase/checkout-session` 832 | for the customer to pay for the product. 833 | 834 | Inside `lib/app_web/controllers/page_html/home.html.heex`, 835 | locate the line: 836 | 837 | ```html 838 | 839 | Purchase 840 | 841 | ``` 842 | 843 | And replace it with: 844 | 845 | ```html 846 | <.link 847 | href={~p"/purchase/checkout-session"} 848 | method="post" 849 | class="relative px-5 py-2.5 transition-all ease-in duration-75 bg-white dark:bg-gray-900 rounded-md group-hover:bg-opacity-0" 850 | > 851 | Purchase 852 | 853 | ``` 854 | 855 | And you should be done! 🎉 856 | 857 | Let's see what we've done so far. 858 | Run `mix phx.server` 859 | (make sure you loaded the env variables 860 | with `source .env`) 861 | and visit `localhost:4000`. 862 | You should see the following! 👏 863 | 864 | ![stripe_integrated](https://user-images.githubusercontent.com/17494745/209217397-4b1c1cb4-6777-4ca0-998e-b860030642a8.gif) 865 | 866 | > You can complete the payment 867 | using test data with the credit card. 868 | You can fill whatever e-mail address (real or not) 869 | or name to pay. 870 | The expiry date and CVC can also be random. 871 | > 872 | > - Payment succeeds: `4242 4242 4242 4242` 873 | > - Payment requires authentication: `4000 0025 0000 3155` 874 | > - Payment is declined: `4000 0000 0000 9995` 875 | 876 | You can check the customers info in 877 | https://dashboard.stripe.com/test/customers. 878 | You may delete the customers, if you want. 879 | 880 | users 881 | 882 | ## 4. Blocking unpaid users from `dashboard` 883 | 884 | We now got a decent workflow going. 885 | But authenticated can still access `/dashboard` 886 | while just being authenticated. 887 | We want them to be logged in 888 | *and also have paid to access it*. 889 | 890 | We are currently not tracking our customers 891 | who have paid and should be granted access. 892 | We are going to be needing to create 893 | a way to save `User`s object 894 | with the referring `stripe_id` 895 | and a boolean field referring to the payment status. 896 | 897 | We could use a PostgresSQL engine for this, 898 | but for this use case it's a bit overkill. 899 | Instead, to simplify deployment, 900 | we will be using 901 | [`Erland Term Storage (ETS)`](https://elixirschool.com/en/lessons/storage/ets). 902 | This is a in-memory storage engine built into OTP 903 | and can be employed to store large amounts of data 904 | *with constant time data access*. 905 | Do note that since this engine is in-memory, 906 | **all data is lost when the process ends/server is shutdown**. 907 | 908 | There is an Elixir wrapper that makes it easy to use `ETS` and `DETS`, 909 | where the latter is a disk-based variant of the former, 910 | where data is persisted on disk. 911 | We'll be using 912 | [`pockets`](https://github.com/fireproofsocks/pockets). 913 | 914 | Let's install it. 915 | 916 | Add it to the `deps` section inside `mix.exs`. 917 | 918 | ```elixir 919 | def deps do 920 | [ 921 | {:pockets, "~> 0.1.0"} 922 | ] 923 | end 924 | ``` 925 | 926 | With that installed, 927 | we are going to be creating a module 928 | that will *manage the users table*. 929 | We are going to create it on startup, 930 | and create users/edit them. 931 | 932 | For that, create a file in `lib/app/users.ex`. 933 | 934 | ```elixir 935 | defmodule UsersTable do 936 | 937 | alias Pockets 938 | 939 | @table :users_table 940 | @filepath "cache.dets" 941 | 942 | def init do 943 | case Pockets.new(@table, @filepath) do 944 | {:ok, set} -> {:ok, set} 945 | {:error, _} -> 946 | Pockets.open(@table, @filepath) 947 | end 948 | end 949 | 950 | def create_user(%{:stripe_id => stripe_id, :person_id => person_id, :status => status}) do 951 | Pockets.put(@table, person_id, %{stripe_id: stripe_id, status: status}) 952 | end 953 | 954 | def fetch_user(person_id) do 955 | Pockets.get(@table, person_id) 956 | end 957 | 958 | end 959 | ``` 960 | 961 | Let's go over what we have done. 962 | We are going to be saving our users 963 | with a tuple containing the `**stripe_id**`, 964 | the `person_id` of the logged in user 965 | and a `**status**` boolean field, 966 | referring to whether the user has paid or not. 967 | 968 | All the functions being used are used 969 | according to the [`ets` wrapper documentation](https://github.com/TheFirstAvenger/ets). 970 | - the `init/0` function creates the table to store our users. 971 | If the file already exists, we open the file. 972 | - `create_user/3` receives a `stripe_id`, `person_id` and `status` 973 | (pertaining to whether the payment has been made or not) 974 | and creates a user object. 975 | - `fetch_user/1` retrieves the persisted user 976 | according to the given` person_id`. 977 | 978 | Let's make use of some of these functions. 979 | We want to setup the `DETS` table on the process startup. 980 | For this, we are going to initiate the table 981 | on the `start/1` function inside `lib/app/application.ex`. 982 | This function is executed when the process is created, 983 | so it fits right our needs! 984 | Add the following code below the `children` array variable 985 | inside `start/1`. 986 | 987 | ```elixir 988 | UsersTable.init() 989 | ``` 990 | 991 | We now need to **create the user** every time 992 | a Stripe session is successful. 993 | We can do this inside `lib/app_web/controllers/page_controller.ex` 994 | on the `success/2` callback. 995 | Change the handler, so it looks like the following: 996 | 997 | ```elixir 998 | def success(conn, %{"session_id" => session_id}) do 999 | 1000 | case Stripe.Checkout.Session.retrieve(session_id) do 1001 | {:ok, session} -> 1002 | 1003 | person_id = conn.assigns.person.id 1004 | UsersTable.create_user(%{person_id: person_id, stripe_id: session.customer, status: true}) 1005 | 1006 | render(conn, :success, layout: false) 1007 | 1008 | {:error, _error} -> 1009 | conn 1010 | |> put_status(303) 1011 | |> redirect(to: ~p"/") 1012 | end 1013 | end 1014 | ``` 1015 | 1016 | Now, inside `lib/app_web/controllers/app_+controller.ex`, 1017 | we will render our "dashboard" 1018 | **only** if a user is found in our `Users` table. 1019 | If there isn't a user found, we redirect the user to the homepage. 1020 | Change the `home/2` function so it looks like the following. 1021 | 1022 | ```elixir 1023 | def home(conn, _params) do 1024 | 1025 | person_id = conn.assigns.person.id 1026 | case UsersTable.fetch_user(person_id) do 1027 | nil -> 1028 | conn |> redirect(to: ~p"/") 1029 | 1030 | _ -> 1031 | render(conn, :app, layout: false) 1032 | end 1033 | end 1034 | ``` 1035 | 1036 | All there's left to do 1037 | is to change the `Purchase` button to dynamically change. 1038 | We want paid users to access the dashboard directly, 1039 | and non-paid users to purchase it. 1040 | 1041 | To do this, add this function 1042 | in `lib/app_web/controllers/page_html.ex`. 1043 | 1044 | ```elixir 1045 | def check_user_has_paid(person_id) do 1046 | user = UsersTable.fetch_user(person_id) 1047 | 1048 | if user == nil, do: false, else: user.status 1049 | end 1050 | ``` 1051 | 1052 | Inside `lib/app_web/controllers/page_html/home.html.heex`, 1053 | change the `Purchase` button so it changes according to the user's payment status. 1054 | 1055 | ```html 1056 | <%= if check_user_has_paid(@conn.assigns.person.id) do %> 1057 | <.link 1058 | href={~p"/dashboard"} 1059 | class="relative px-5 py-2.5 transition-all ease-in duration-75 bg-white dark:bg-gray-900 rounded-md group-hover:bg-opacity-0" 1060 | > 1061 | Enter 1062 | 1063 | <% else %> 1064 | <.link 1065 | href={~p"/purchase/checkout-session"} 1066 | method="post" 1067 | class="relative px-5 py-2.5 transition-all ease-in duration-75 bg-white dark:bg-gray-900 rounded-md group-hover:bg-opacity-0" 1068 | > 1069 | Purchase 1070 | 1071 | <% end %> 1072 | ``` 1073 | 1074 | ## 5. All done! 🎉 1075 | 1076 | And you should be done! 1077 | The user can now pay for our product. 1078 | We are restricting user access for only users that have paid. 1079 | Users that *have* made the payment **will have access**. 1080 | 1081 | ![final](https://user-images.githubusercontent.com/17494745/209818299-f9a1a197-6ef8-4e2e-b1be-960b6df88d1b.gif) 1082 | 1083 | 1084 | # Thanks! 1085 | 1086 | Thanks for learning about payment processing with us! 1087 | If you have any questions, please ask!! 1088 | Please ⭐ this repo to help spread the word! 1089 | 1090 | If you are using environment variables in a way not mentioned in this guide, 1091 | or have a better way of managing them 1092 | or any other ideas or suggestions for improvements 1093 | please tell us!! 1094 | 1095 | [![HitCount](https://hits.dwyl.com/dwyl/learn-payment-processing-example.svg?style=flat-square)](https://hits.dwyl.com/dwyl/learn-payment-processing) 1096 | -------------------------------------------------------------------------------- /lib/app.ex: -------------------------------------------------------------------------------- 1 | defmodule App do 2 | @moduledoc """ 3 | App keeps the contexts that define your domain 4 | and business logic. 5 | 6 | Contexts are also responsible for managing your data, regardless 7 | if it comes from the database, an external API or others. 8 | """ 9 | end 10 | -------------------------------------------------------------------------------- /lib/app/application.ex: -------------------------------------------------------------------------------- 1 | defmodule App.Application do 2 | # See https://hexdocs.pm/elixir/Application.html 3 | # for more information on OTP Applications 4 | @moduledoc false 5 | 6 | use Application 7 | 8 | @impl true 9 | def start(_type, _args) do 10 | children = [ 11 | # Start the Telemetry supervisor 12 | AppWeb.Telemetry, 13 | # Start the PubSub system 14 | {Phoenix.PubSub, name: App.PubSub}, 15 | # Start Finch 16 | {Finch, name: App.Finch}, 17 | # Start the Endpoint (http/https) 18 | AppWeb.Endpoint 19 | # Start a worker by calling: App.Worker.start_link(arg) 20 | # {App.Worker, arg} 21 | ] 22 | 23 | # Creating DETS user table 24 | UsersTable.init() 25 | 26 | # See https://hexdocs.pm/elixir/Supervisor.html 27 | # for other strategies and supported options 28 | opts = [strategy: :one_for_one, name: App.Supervisor] 29 | Supervisor.start_link(children, opts) 30 | end 31 | 32 | # Tell Phoenix to update the endpoint configuration 33 | # whenever the application is updated. 34 | @impl true 35 | def config_change(changed, _new, removed) do 36 | AppWeb.Endpoint.config_change(changed, removed) 37 | :ok 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/app/users.ex: -------------------------------------------------------------------------------- 1 | defmodule UsersTable do 2 | alias Pockets 3 | 4 | @table (if Mix.env() == :prod do 5 | :users_table 6 | else 7 | :users_test_table 8 | end) 9 | @filepath (if Mix.env() == :prod do 10 | "cache.dets" 11 | else 12 | "cache_test.dets" 13 | end) 14 | 15 | def init() do 16 | case Pockets.new(@table, @filepath) do 17 | # Not testing creating a table because when testing, it loads a sample table. 18 | # coveralls-ignore-start 19 | {:ok, set} -> 20 | {:ok, set} 21 | 22 | # coveralls-ignore-end 23 | {:error, _} -> 24 | Pockets.open(@table, @filepath) 25 | end 26 | end 27 | 28 | def create_user(%{ 29 | :stripe_id => stripe_id, 30 | :person_id => person_id, 31 | :status => status 32 | }) do 33 | Pockets.put(@table, person_id, %{stripe_id: stripe_id, status: status}) 34 | end 35 | 36 | def fetch_user(person_id) do 37 | Pockets.get(@table, person_id) 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/app_web.ex: -------------------------------------------------------------------------------- 1 | defmodule AppWeb do 2 | @moduledoc """ 3 | The entrypoint for defining your web interface, such 4 | as controllers, components, channels, and so on. 5 | 6 | This can be used in your application as: 7 | 8 | use AppWeb, :controller 9 | use AppWeb, :html 10 | 11 | The definitions below will be executed for every controller, 12 | component, etc, so keep them short and clean, focused 13 | on imports, uses and aliases. 14 | 15 | Do NOT define functions inside the quoted expressions 16 | below. Instead, define additional modules and import 17 | those modules here. 18 | """ 19 | 20 | def static_paths, do: ~w(assets fonts images favicon.ico robots.txt) 21 | 22 | def router do 23 | quote do 24 | use Phoenix.Router, helpers: false 25 | 26 | # Import common connection and controller functions to use in pipelines 27 | import Plug.Conn 28 | import Phoenix.Controller 29 | import Phoenix.LiveView.Router 30 | end 31 | end 32 | 33 | def channel do 34 | quote do 35 | use Phoenix.Channel 36 | end 37 | end 38 | 39 | def controller do 40 | quote do 41 | use Phoenix.Controller, 42 | namespace: AppWeb, 43 | formats: [:html, :json], 44 | layouts: [html: AppWeb.Layouts] 45 | 46 | import Plug.Conn 47 | import AppWeb.Gettext 48 | 49 | unquote(verified_routes()) 50 | end 51 | end 52 | 53 | def live_view do 54 | quote do 55 | use Phoenix.LiveView, 56 | layout: {AppWeb.Layouts, :app} 57 | 58 | unquote(html_helpers()) 59 | end 60 | end 61 | 62 | def live_component do 63 | quote do 64 | use Phoenix.LiveComponent 65 | 66 | unquote(html_helpers()) 67 | end 68 | end 69 | 70 | def html do 71 | quote do 72 | use Phoenix.Component 73 | 74 | # Import convenience functions from controllers 75 | import Phoenix.Controller, 76 | only: [get_csrf_token: 0, view_module: 1, view_template: 1] 77 | 78 | # Include general helpers for rendering HTML 79 | unquote(html_helpers()) 80 | end 81 | end 82 | 83 | defp html_helpers do 84 | quote do 85 | # HTML escaping functionality 86 | import Phoenix.HTML 87 | # Core UI components and translation 88 | import AppWeb.CoreComponents 89 | import AppWeb.Gettext 90 | 91 | # Shortcut for generating JS commands 92 | alias Phoenix.LiveView.JS 93 | 94 | # Routes generation with the ~p sigil 95 | unquote(verified_routes()) 96 | end 97 | end 98 | 99 | def verified_routes do 100 | quote do 101 | use Phoenix.VerifiedRoutes, 102 | endpoint: AppWeb.Endpoint, 103 | router: AppWeb.Router, 104 | statics: AppWeb.static_paths() 105 | end 106 | end 107 | 108 | @doc """ 109 | When used, dispatch to the appropriate controller/view/etc. 110 | """ 111 | defmacro __using__(which) when is_atom(which) do 112 | apply(__MODULE__, which, []) 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /lib/app_web/components/core_components.ex: -------------------------------------------------------------------------------- 1 | defmodule AppWeb.CoreComponents do 2 | @moduledoc """ 3 | Provides core UI components. 4 | 5 | The components in this module use Tailwind CSS, a utility-first CSS framework. 6 | See the [Tailwind CSS documentation](https://tailwindcss.com) to learn how to 7 | customize the generated components in this module. 8 | 9 | Icons are provided by [heroicons](https://heroicons.com), using the 10 | [heroicons_elixir](https://github.com/mveytsman/heroicons_elixir) project. 11 | """ 12 | use Phoenix.Component 13 | 14 | alias Phoenix.LiveView.JS 15 | import AppWeb.Gettext 16 | 17 | @doc """ 18 | Renders a modal. 19 | 20 | ## Examples 21 | 22 | <.modal id="confirm-modal"> 23 | Are you sure? 24 | <:confirm>OK 25 | <:cancel>Cancel 26 | 27 | 28 | JS commands may be passed to the `:on_cancel` and `on_confirm` attributes 29 | for the caller to react to each button press, for example: 30 | 31 | <.modal id="confirm" on_confirm={JS.push("delete")} on_cancel={JS.navigate(~p"/posts")}> 32 | Are you sure you? 33 | <:confirm>OK 34 | <:cancel>Cancel 35 | 36 | """ 37 | attr :id, :string, required: true 38 | attr :show, :boolean, default: false 39 | attr :on_cancel, JS, default: %JS{} 40 | attr :on_confirm, JS, default: %JS{} 41 | 42 | slot :inner_block, required: true 43 | slot :title 44 | slot :subtitle 45 | slot :confirm 46 | slot :cancel 47 | 48 | def modal(assigns) do 49 | ~H""" 50 |