diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..2da3269 --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,11 @@ +[ + import_deps: [:phoenix], + plugins: [Phoenix.LiveView.HTMLFormatter], + inputs: [ + "*.{heex,ex,exs}", + "priv/*/seeds.exs", + "{config,lib,test}/**/*.{heex,ex,exs}" + ], + subdirectories: ["priv/*/migrations"], + line_length: 80 +] diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..00472fc --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,8 @@ +version: 2 +updates: +- package-ecosystem: mix + directory: "/" + schedule: + interval: daily + time: "07:00" + timezone: Europe/London \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b8219ec --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,43 @@ +name: build + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build: + name: Build and test + runs-on: ubuntu-latest + strategy: + matrix: + otp: ['24.3.4'] + elixir: ['1.14.1'] + steps: + - uses: actions/checkout@v2 + - name: Set up Elixir + uses: erlef/setup-beam@v1 + with: + otp-version: ${{ matrix.otp }} + elixir-version: ${{ matrix.elixir }} + - name: Restore deps and _build cache + uses: actions/cache@v3 + with: + path: | + deps + _build + key: deps-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }} + restore-keys: | + deps-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }} + - name: Install dependencies + run: mix deps.get + - name: Check code is formatted + run: mix format --check-formatted + - name: Run Tests + run: mix coveralls.json + env: + MIX_ENV: test + AUTH_API_KEY: ${{ secrets.AUTH_API_KEY }} + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v1 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5148e52..7a0781f 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,46 @@ jspm_packages # Optional REPL history .node_repl_history + + +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where 3rd-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +app-*.tar + +# Ignore assets that are produced by build tools. +/priv/static/assets/ + +# Ignore digested assets cache. +/priv/static/cache_manifest.json + +# In case you use Node.js/npm, you want to ignore these. +npm-debug.log +/assets/node_modules/ + +# Env files +.env +cache.dets +cache_test.dets + +# Noise +.DS_Store \ No newline at end of file diff --git a/README.md b/README.md index 13d33d9..224d0ff 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,429 @@ -# learn-payment-processing -[WiP] Learn how to process online payments in your web application! :money: +
+ +# Learn Payment Processing ๐Ÿ’ณ + +![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/dwyl/learn-payment-processing/ci.yml?label=build&style=flat-square&branch=main) +[![codecov.io](https://img.shields.io/codecov/c/github/dwyl/learn-payment-processing/main.svg?style=flat-square)](https://codecov.io/github/dwyl/phoenix-chat-example?branch=main) +[![contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat-square)](https://github.com/dwyl/phoenix-chat-example/issues) +[![HitCount](https://hits.dwyl.com/dwyl/learn-payment-processing.svg?style=flat-square)](http://hits.dwyl.com/dwyl/learn-payment-processing) + +Learn what payment processing is +and how you can add it to your App +to get _paid_! ๐ŸŽ‰ + +
+ +# Why? ๐Ÿฅ• + +Sadly, not all applications can be "free"; +the **`people`** that build them need to be **paid** +and the underlying infrastructure costs money too. +Many App/Websites cover their costs through advertising. +This is effectively selling [out] the "users" data, +which we are +[not fans](https://github.com/dwyl/learn-react/issues/23#issuecomment-552212087) +of ... + +[![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) + +> "_When something online is free, +> you're not the customer, +> you're the product._" +> ~ [Jonathan Zittrain](https://blogs.harvard.edu/futureoftheinternet/2012/03/21/meme-patrol-when-something-online-is-free-youre-not-the-customer-youre-the-product/) + +We prefer to charge an **affordable fee** - +enough to **cover** all our **costs** +and continue building our +[roadmap](https://github.com/dwyl/product-roadmap) - +and **_fiercely_ guard** the **privacy** +of the **`people`** using the App. + +The goal of this guide is to cover +both the theory and _practice_ +of payment processing +_and_ to showcase payment processing +in a standalone web app. + +# Who? ๐Ÿค“ + +This guide is meant as both +an **_internal_ reference** for us **`@dwyl`**
+and a **_fully_ Open Source resource** +that _anyone_ can read and learn from. + +As always, if you find it helpful/useful please star the repo on GitHub โญ ๐Ÿ™ Thanks! + +If you get stuck or have any questions/suggestions, +please [open an issue](https://github.com/dwyl/learn-payment-processing/issues). + + +# What ๐Ÿ’ญ + +Some Apps are sold via **one time purchase** +others are **subscription-based** (recurring payments). +Regardless of the type of payment, +they share one thing in common: +**payment processing gateways +are used to collect funds**. + +But what _is_ "payment processing"...? + + +## Payment Processor or Gateway? ๐Ÿคทโ€โ™€๏ธ + +![payment-processor-gateway](https://user-images.githubusercontent.com/17494745/208946952-4da1600e-9936-4491-9a6e-a5a7e09c023c.png) + +A **payment processor** functions as an intermediary +between the customer's bank (or digital wallet) +and the merchant +(and their bank). +It is the entity responsible for communicating +between both parties in the transaction. + +A **payment gateway** +is a virtual +[*point of sale*](https://en.wikipedia.org/wiki/Point_of_sale) +for online payments. +Similar to when a customer swipes/taps their card +on a **_physical_ credit card terminal**, +online stores need a gateway to securely collect +the customer's card details. +A payment gateway +acts like is a _virtual_ credit card terminal. + +The whole process of online payment +usually assumes the merchant +has a +[**merchant account**](https://www.investopedia.com/terms/m/merchant-account.asp). +A merchant account is simply +a type of business bank account +that *allows a business +to receive credit card +and other electronic funds transfers*. + +> **Note**: the terms +> `payment processor` +> and `payment gateway` +> usually fall under the same term; +`payment processor`. +This is because they work together to handle payment processing. +So if you see platforms like `Stripe` +being mentioned as a "payment processor", +it's because it offers both `payment gateway` +and `payment processor` +bundled together +alongside a myriad of other features +such as fraud prevention. + +## Which Payment Processing Provider? ๐Ÿค” + +There are several Payment processing providers, +the most recognizable are: +[`PayPal`](https://developer.paypal.com/api/rest/), +[`Stripe`](https://stripe.com) +or +[`Square`](https://developer.squareup.com/us/en). + +We have used each of these in the past +and they are fairly straightforward to integrate. + +Let's do a quick rundown of the various payment providers +we want to support in our `App`: + +### `PayPal` - the _Original_ Payment Processor ๐Ÿ’ต + +Started in 1998, +`PayPal` is one of the _original_ +and most successful +general purpose online payment processors. + +If you've done much online shopping, +you have probably seen a payment interface +allowing purchases through `Paypal`: + +![paypal](https://user-images.githubusercontent.com/17494745/208951049-421e123a-e082-433e-8b08-60c7da8c8a57.png) + +If you want to alow `people` to purchase +an through `PayPal`, +you'd have to setup a `PayPal` account +and use one of their SDKs: +[developer.paypal.com](https://developer.paypal.com/home) + +This uses the +`PayPal` E-commerce platform +[paypal.com/us/business/platforms](https://www.paypal.com/us/business/platforms-and-marketplaces) +to setup a payment gateway and processor +for `people` to pay with Paypal on your site. + +The biggest advantage of `PayPal` +is that has been a "Digital Wallet" from the early days. +Which means people _store_ funds in their `PayPal` account +as if it were a Bank. +An individual can _sell_ something online +e.g. on +[`eBay`](https://www.ebay.com/help/selling/listings/choosing-get-paid/accepting-other-payment-methods?id=4184) +or +[`etsy`](https://help.etsy.com/hc/en-us/articles/360000104828-How-to-Pay-With-PayPal?segment=shopping) +and collect payment with `PayPal`. +These funds stay in their `PayPal` account unless they extract them +to a Bank Account. Many people hold balances in their `PayPal` for future online shopping. +People consider this to be their "spending money". +We want to help those people _invest_ the money wisely in themselves! + +The history/evolution of `PayPal` is a fascinating success story, +[wikipedia.org/wiki/PayPal#Early_history](https://en.wikipedia.org/wiki/PayPal). +We are only concerned about the _present_; +`PayPal` has over **`400 million people` _actively_ using their platform** +and can be used in more than 200 countries/regions. +A _significant_ proportion of `people` buying things online +_already_ use and _trust_ `PayPal`. +We want to offer the ability to checkout with a `PayPal` +account as one of the lowest friction payment methods. +For people who are signed into their `PayPal` account, +checkout can be **2 clicks/taps**. ๐Ÿš€ + + +### `Apple Pay` ๐ŸŽ + +There are **`1.2 Billion` _active_ `iPhone`** users worldwide. +`iPhone` recently surpassed +[**`50%` Smart Phone Market Share**](https://github.com/dwyl/learn-flutter/pull/69#issuecomment-1319811970) +in the **US** +and in some wealthier countries like Norway, +it's as high as +[**`68%`**](https://github.com/dwyl/learn-flutter/pull/69#issuecomment-1319826473). +Many of the `people` using `iPhone` +have a payment card associated with their Apple Account. +[**`Apple Pay`**](https://www.apple.com/apple-pay/) +has **`500 million`** registered users worldwide: +[fortunly.com/apple-pay-statistics](https://fortunly.com/statistics/apple-pay-statistics/) + +To use `Apple Pay` _directly_ we would need +to create an account with `Apple` +and use their `SDK`: +[developer.apple.com/apple-pay/implementation/](https://developer.apple.com/apple-pay/implementation/) + +`while` we don't expect _many_ of the people using our `App` to pay with `Apple Pay` +(_and we certainly won't encourage them as `Apple` takes a **massive `30%` cut** ..._) + +### `Google Pay` ๐Ÿค– + +[`Google Pay`](https://pay.google.com/about/business/implementation/), +originally called `Google Checkout`, +then `Google Wallet` +then rebranded to `Android Pay` +and now back to `Google Pay/Wallet` (...๐Ÿ™„) +is a payment service available +to all `people` with a `Google` account - +including everyone with an `Android` device - +who have added a credit/debit card to their account. +It's difficult to know exactly how many people +have and _use_ `Google Pay` +because `Google` does not make the info `public` +and the _vast_ majority of `people` with an `Android` +device either don't _have_ a Credit Card +(think children and the +[unbanked](https://en.wikipedia.org/wiki/Unbanked)) ... +But the most recent estimates are +**`200 million` people** +in +[**47 countries**](https://support.google.com/googlepay/answer/12429287?hl=en#zippy=%2Cpay-in-store). +see: wikipedia.org/wiki/Google_Pay_(payment_method) + +To add `Google Pay` +as a payment method +you have to create an business account +and use their **`SDK`**: +[developers.google.com/pay/api](https://developers.google.com/pay/api) +and integrate it in your App/Website. + +### Credit/Debit Cards ๐Ÿ’ณ + +Last but not least are **`credit/debit cards`**, +by _far_ the most prevalent payment method +both in the _real_ world and online. + +All the bran-specific payment providers +such as `Apple Pay`, `Google Pay`, `Amazon Pay` etc. +_assume_ you have a `credit/debit card`, +so why _bother_ with the others? +Simple: most friction and _perceived_ security. +Many people are _reluctant_ to use their credit/debit card +because they fear fraud or identity theft. + +These fears are _mostly_ resolved in 2022 +as the credit/debit card companies have +sophisticated fraud detection/prevention systems. +But we [@dwyl] still don't want to be _storing_ +any card details ourselves, we want a trusted +[PCI DSS compliant](https://en.wikipedia.org/wiki/Payment_Card_Industry_Data_Security_Standard) +payment processor to handle the parts we don't have time to be experts in +so that we can focus on the UI/UX of our `App`. + +
+ +## Do We Need a Merchant Account? ๐Ÿ›’ + +On top of managing accounts with each of the payment providers, +we would need to create our own merchant account +so each one of these services can connect to it +and process the transactions. + +Setting up and maintaining a Merchant Account +is a heap of effort +and can easily require +a _dedicated_ person or _team_ of people +just for running the checkout process in your App/company. +This is where **payment platforms** +like `Stripe` come into play. + + + +## Payment Platforms ๐Ÿ˜ + +Payment platforms **_simplify_ the process of connecting +to multiple third-parties**. +It offers more than a payment service +so that merchants only have to liaise with _one_ company +rather than multiple ones. + +This has a great impact on how an application +is *designed* and *implemented*, +and allows to for a better management +and +[decoupling](https://en.wikipedia.org/wiki/Single-responsibility_principle) +of responsibilities. + +Instead of our own API having to manage different providers, +we can use a platform like `Stripe` to do the work for us. +This is how the application should be laid out! + +![design](https://user-images.githubusercontent.com/17494745/208956397-cda6d895-8034-45b0-bc91-61befb012fb3.png) + +As you can see, it is much simpler! +By offering a bundle of essential payment technologies, +these companies are reducing the merchant's work +of having to manage each of them separately. +In addition to this, there are a number of other advantages, +such as security, data monitoring and reporting. + +For example, `Stripe` is like having a multiple +**payment processors**, **payment gateways** and **merchant account** +bundled into one, +along with a [myriad of other features](https://stripe.com/en-pt). + +### `Stripe` ๐Ÿš€ + +`Stripe` is considered by many to be +the [*de facto*](https://trends.builtwith.com/payment/Stripe) +way of accepting credit cards +and electronic payments on the web. +Beyond collecting card payments +it has a number of additional features, +including: +[smart retries](https://stripe.com/docs/billing/revenue-recovery/smart-retries), +[automatic card updater](https://stripe.com/docs/saving-cards), +[fraud tooling](https://stripe.com/en-gb-pt/radar), +and other +[add-ons](https://stripe.com/partners/directory). + +Until we starting to research this in-depth, +we were considering using `Stripe` +because we've used in it previous projects. +But then we discovered ***`Paddle`***! + +### `Paddle` ๐Ÿ˜ฎ + +`Paddle` is a new class of payment processor +that includes all additional services +in their simple fee structure. +Their slogan is: +"_The better way to sell software_". +Which immediately got our attention +as that is what we are selling! + +While `Stripe` can be compared to a payment gateway +that deals with multiple channels, +`Paddle` offers similar features +but acts a *reseller service* - +**Merchant of Record (MoR)**. + +![mor](https://user-images.githubusercontent.com/194400/210158068-92f2ec93-c108-4789-bc03-fd9b34c472bf.png) + +A MoR is a term to describe the legal entity +selling goods or services to an end customer: +[paddle.com/blog/what-is-merchant-of-record](https://www.paddle.com/blog/what-is-merchant-of-record)
+It's who the end customer owes payment for their purchase, +and it is who handles payments and tax liability for each transaction. +This is great for *tax handling*, +which is especially relevant in Europe: +[outseta.com/posts/startup-payment-processing](https://www.outseta.com/posts/startup-payment-processing) +and one of the reasons people +consider `Paddle` in lieu of `Stripe`. +`Stripe` is making strides in also having +better tax compliance: [stripe.com/newsroom/news/taxjar](https://stripe.com/newsroom/news/taxjar) +but it's not quite there, +at the moment of writing. + + +So businesses can choose to be their own merchant of record +and setup an infrastructure and processes needed to manage +payments with `Stripe` and deal with liabilities +and tax handling themselves. +*Or* they can use MoR service providers like `Paddle` +who take the burden of all of payment processing +and legal compliance away. Of course, these usually incur higher fees than `Stripe`. + +## I'm confused ... Which one should I choose? ๐Ÿคทโ€โ™€๏ธ + +That's a great question that has come up before: +[indiehackers.com/post/**stripe-vs-paddle**-89161b0d5c](https://www.indiehackers.com/post/stripe-vs-paddle-89161b0d5c) +`Paddle` themselves have a good comparison: +[paddle.com/**compare/stripe**](https://www.paddle.com/compare/stripe) + +Several others have reached the same conclusion, e.g: +[splitbee.io/blog/why-we-moved-from-stripe-to-paddle](https://splitbee.io/blog/why-we-moved-from-stripe-to-paddle) +and +[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) + +It's still early days for `Paddle` +whereas `Stripe` has several years of head-start: +https://stackshare.io/stackups/paddle-vs-stripe +At present very few companies use `Paddle`, +but those who do are quite vocal about it: + +![paddle-tailwind-ui](https://user-images.githubusercontent.com/194400/210031861-3a5a0d76-e406-4b4b-8bea-2fa7b04e0f82.png) + + +Depending on the use-case or your choice, +each product provides different pricing plans, fees and features +and you should make this decision +based on the requirements of your project +and how much you are willing to spend. + +What's important here is you know +*how online payments work*, +what parties are involved +and how you can **leverage these platforms** +to make this process easier. + +Remember, we are dealing with **sensitive information**. +Credit card info should be handled with *extreme caution* +amd these platforms makes it easier for us to do just that. + +But implementation-wise, +when designing and implementing your application, +you should notice that +the process is similar between providers. +Your application will make use of their SDKs +to integrate different channels +and payment alternatives to process transactions. + +And guess what, +we are going to be doing that in the next section! + +# How? ๐Ÿ’ป + +The demo/example has quite a few steps, +so we split it out into it's own doc: +[`example.md`](https://github.com/dwyl/learn-payment-processing/blob/main/example.md) diff --git a/assets/css/app.css b/assets/css/app.css new file mode 100644 index 0000000..378c8f9 --- /dev/null +++ b/assets/css/app.css @@ -0,0 +1,5 @@ +@import "tailwindcss/base"; +@import "tailwindcss/components"; +@import "tailwindcss/utilities"; + +/* This file is for your main application CSS */ diff --git a/assets/js/app.js b/assets/js/app.js new file mode 100644 index 0000000..44a8122 --- /dev/null +++ b/assets/js/app.js @@ -0,0 +1,41 @@ +// If you want to use Phoenix channels, run `mix help phx.gen.channel` +// to get started and then uncomment the line below. +// import "./user_socket.js" + +// You can include dependencies in two ways. +// +// The simplest option is to put them in assets/vendor and +// import them using relative paths: +// +// import "../vendor/some-package.js" +// +// Alternatively, you can `npm install some-package --prefix assets` and import +// them using a path starting with the package name: +// +// import "some-package" +// + +// Include phoenix_html to handle method=PUT/DELETE in forms and buttons. +import "phoenix_html" +// Establish Phoenix Socket and LiveView configuration. +import {Socket} from "phoenix" +import {LiveSocket} from "phoenix_live_view" +import topbar from "../vendor/topbar" + +let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") +let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}}) + +// Show progress bar on live navigation and form submits +topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) +window.addEventListener("phx:page-loading-start", info => topbar.delayedShow(200)) +window.addEventListener("phx:page-loading-stop", info => topbar.hide()) + +// connect if there are any LiveViews on the page +liveSocket.connect() + +// expose liveSocket on window for web console debug logs and latency simulation: +// >> liveSocket.enableDebug() +// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session +// >> liveSocket.disableLatencySim() +window.liveSocket = liveSocket + diff --git a/assets/tailwind.config.js b/assets/tailwind.config.js new file mode 100644 index 0000000..b611701 --- /dev/null +++ b/assets/tailwind.config.js @@ -0,0 +1,26 @@ +// See the Tailwind configuration guide for advanced usage +// https://tailwindcss.com/docs/configuration + +const plugin = require("tailwindcss/plugin") + +module.exports = { + content: [ + "./js/**/*.js", + "../lib/*_web.ex", + "../lib/*_web/**/*.*ex" + ], + theme: { + extend: { + colors: { + brand: "#FD4F00", + } + }, + }, + plugins: [ + require("@tailwindcss/forms"), + plugin(({addVariant}) => addVariant("phx-no-feedback", [".phx-no-feedback&", ".phx-no-feedback &"])), + plugin(({addVariant}) => addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"])), + plugin(({addVariant}) => addVariant("phx-submit-loading", [".phx-submit-loading&", ".phx-submit-loading &"])), + plugin(({addVariant}) => addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"])) + ] +} \ No newline at end of file diff --git a/assets/vendor/topbar.js b/assets/vendor/topbar.js new file mode 100644 index 0000000..4176ede --- /dev/null +++ b/assets/vendor/topbar.js @@ -0,0 +1,167 @@ +/** + * @license MIT + * topbar 1.0.0, 2021-01-06 + * Modifications: + * - add delayedShow(time) (2022-09-21) + * http://buunguyen.github.io/topbar + * Copyright (c) 2021 Buu Nguyen + */ +(function (window, document) { + "use strict"; + + // https://gist.github.com/paulirish/1579671 + (function () { + var lastTime = 0; + var vendors = ["ms", "moz", "webkit", "o"]; + for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) { + window.requestAnimationFrame = + window[vendors[x] + "RequestAnimationFrame"]; + window.cancelAnimationFrame = + window[vendors[x] + "CancelAnimationFrame"] || + window[vendors[x] + "CancelRequestAnimationFrame"]; + } + if (!window.requestAnimationFrame) + window.requestAnimationFrame = function (callback, element) { + var currTime = new Date().getTime(); + var timeToCall = Math.max(0, 16 - (currTime - lastTime)); + var id = window.setTimeout(function () { + callback(currTime + timeToCall); + }, timeToCall); + lastTime = currTime + timeToCall; + return id; + }; + if (!window.cancelAnimationFrame) + window.cancelAnimationFrame = function (id) { + clearTimeout(id); + }; + })(); + + var canvas, + currentProgress, + showing, + progressTimerId = null, + fadeTimerId = null, + delayTimerId = null, + addEvent = function (elem, type, handler) { + if (elem.addEventListener) elem.addEventListener(type, handler, false); + else if (elem.attachEvent) elem.attachEvent("on" + type, handler); + else elem["on" + type] = handler; + }, + options = { + autoRun: true, + barThickness: 3, + barColors: { + 0: "rgba(26, 188, 156, .9)", + ".25": "rgba(52, 152, 219, .9)", + ".50": "rgba(241, 196, 15, .9)", + ".75": "rgba(230, 126, 34, .9)", + "1.0": "rgba(211, 84, 0, .9)", + }, + shadowBlur: 10, + shadowColor: "rgba(0, 0, 0, .6)", + className: null, + }, + repaint = function () { + canvas.width = window.innerWidth; + canvas.height = options.barThickness * 5; // need space for shadow + + var ctx = canvas.getContext("2d"); + ctx.shadowBlur = options.shadowBlur; + ctx.shadowColor = options.shadowColor; + + var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0); + for (var stop in options.barColors) + lineGradient.addColorStop(stop, options.barColors[stop]); + ctx.lineWidth = options.barThickness; + ctx.beginPath(); + ctx.moveTo(0, options.barThickness / 2); + ctx.lineTo( + Math.ceil(currentProgress * canvas.width), + options.barThickness / 2 + ); + ctx.strokeStyle = lineGradient; + ctx.stroke(); + }, + createCanvas = function () { + canvas = document.createElement("canvas"); + var style = canvas.style; + style.position = "fixed"; + style.top = style.left = style.right = style.margin = style.padding = 0; + style.zIndex = 100001; + style.display = "none"; + if (options.className) canvas.classList.add(options.className); + document.body.appendChild(canvas); + addEvent(window, "resize", repaint); + }, + topbar = { + config: function (opts) { + for (var key in opts) + if (options.hasOwnProperty(key)) options[key] = opts[key]; + }, + delayedShow: function(time) { + if (showing) return; + if (delayTimerId) return; + delayTimerId = setTimeout(() => topbar.show(), time); + }, + show: function () { + if (showing) return; + showing = true; + if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId); + if (!canvas) createCanvas(); + canvas.style.opacity = 1; + canvas.style.display = "block"; + topbar.progress(0); + if (options.autoRun) { + (function loop() { + progressTimerId = window.requestAnimationFrame(loop); + topbar.progress( + "+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2) + ); + })(); + } + }, + progress: function (to) { + if (typeof to === "undefined") return currentProgress; + if (typeof to === "string") { + to = + (to.indexOf("+") >= 0 || to.indexOf("-") >= 0 + ? currentProgress + : 0) + parseFloat(to); + } + currentProgress = to > 1 ? 1 : to; + repaint(); + return currentProgress; + }, + hide: function () { + clearTimeout(delayTimerId); + delayTimerId = null; + if (!showing) return; + showing = false; + if (progressTimerId != null) { + window.cancelAnimationFrame(progressTimerId); + progressTimerId = null; + } + (function loop() { + if (topbar.progress("+.1") >= 1) { + canvas.style.opacity -= 0.05; + if (canvas.style.opacity <= 0.05) { + canvas.style.display = "none"; + fadeTimerId = null; + return; + } + } + fadeTimerId = window.requestAnimationFrame(loop); + })(); + }, + }; + + if (typeof module === "object" && typeof module.exports === "object") { + module.exports = topbar; + } else if (typeof define === "function" && define.amd) { + define(function () { + return topbar; + }); + } else { + this.topbar = topbar; + } +}.call(this, window, document)); diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..353a0e3 --- /dev/null +++ b/config/config.exs @@ -0,0 +1,55 @@ +# This file is responsible for configuring your application +# and its dependencies with the aid of the Config module. +# +# This configuration file is loaded before any dependency and +# is restricted to this project. + +# General application configuration +import Config + +# Configures the endpoint +config :app, AppWeb.Endpoint, + url: [host: "localhost"], + render_errors: [ + formats: [html: AppWeb.ErrorHTML, json: AppWeb.ErrorJSON], + layout: false + ], + pubsub_server: App.PubSub, + live_view: [signing_salt: "u48tsthN"] + +# Configure esbuild (the version is required) +config :esbuild, + version: "0.14.41", + default: [ + args: + ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), + cd: Path.expand("../assets", __DIR__), + env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} + ] + +# Configure tailwind (the version is required) +config :tailwind, + version: "3.1.8", + default: [ + args: ~w( + --config=tailwind.config.js + --input=css/app.css + --output=../priv/static/assets/app.css + ), + cd: Path.expand("../assets", __DIR__) + ] + +# Configures Elixir's Logger +config :logger, :console, + format: "$time $metadata[$level] $message\n", + metadata: [:request_id] + +# Use Jason for JSON parsing in Phoenix +config :phoenix, :json_library, Jason + +# Import environment specific config. This must remain at the bottom +# of this file so it overrides the configuration defined above. +import_config "#{config_env()}.exs" + +# Stripe +config :stripity_stripe, api_key: System.get_env("STRIPE_API_KEY") diff --git a/config/dev.exs b/config/dev.exs new file mode 100644 index 0000000..36267ad --- /dev/null +++ b/config/dev.exs @@ -0,0 +1,69 @@ +import Config + +# For development, we disable any cache and enable +# debugging and code reloading. +# +# The watchers configuration can be used to run external +# watchers to your application. For example, we use it +# with esbuild to bundle .js and .css sources. +config :app, AppWeb.Endpoint, + # Binding to loopback ipv4 address prevents access from other machines. + # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. + http: [ip: {127, 0, 0, 1}, port: 4000], + check_origin: false, + code_reloader: true, + debug_errors: true, + secret_key_base: + "bv1heeBXJGvlEsc6SI66xUox+004UlT+aRAH+UlGgMxGuGMXCbEK32pVx0QNlJxN", + watchers: [ + esbuild: + {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]}, + tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]} + ] + +# ## SSL Support +# +# In order to use HTTPS in development, a self-signed +# certificate can be generated by running the following +# Mix task: +# +# mix phx.gen.cert +# +# Run `mix help phx.gen.cert` for more information. +# +# The `http:` config above can be replaced with: +# +# https: [ +# port: 4001, +# cipher_suite: :strong, +# keyfile: "priv/cert/selfsigned_key.pem", +# certfile: "priv/cert/selfsigned.pem" +# ], +# +# If desired, both `http:` and `https:` keys can be +# configured to run both http and https servers on +# different ports. + +# Watch static and templates for browser reloading. +config :app, AppWeb.Endpoint, + live_reload: [ + patterns: [ + ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$", + ~r"priv/gettext/.*(po)$", + ~r"lib/app_web/(live|views)/.*(ex)$", + ~r"lib/app_web/templates/.*(eex)$" + ] + ] + +# Enable dev routes for dashboard and mailbox +config :app, dev_routes: true + +# Do not include metadata nor timestamps in development logs +config :logger, :console, format: "[$level] $message\n" + +# Set a higher stacktrace during development. Avoid configuring such +# in production as building large stacktraces may be expensive. +config :phoenix, :stacktrace_depth, 20 + +# Initialize plugs at runtime for faster development compilation +config :phoenix, :plug_init_mode, :runtime diff --git a/config/prod.exs b/config/prod.exs new file mode 100644 index 0000000..31b5cab --- /dev/null +++ b/config/prod.exs @@ -0,0 +1,19 @@ +import Config + +# For production, don't forget to configure the url host +# to something meaningful, Phoenix uses this information +# when generating URLs. + +# Note we also include the path to a cache manifest +# containing the digested version of static files. This +# manifest is generated by the `mix phx.digest` task, +# which you should run after static files are built and +# before starting your production server. +config :app, AppWeb.Endpoint, + cache_static_manifest: "priv/static/cache_manifest.json" + +# Do not print debug messages in production +config :logger, level: :info + +# Runtime production configuration, including reading +# of environment variables, is done on config/runtime.exs. diff --git a/config/runtime.exs b/config/runtime.exs new file mode 100644 index 0000000..68f482e --- /dev/null +++ b/config/runtime.exs @@ -0,0 +1,100 @@ +import Config + +# config/runtime.exs is executed for all environments, including +# during releases. It is executed after compilation and before the +# system starts, so it is typically used to load production configuration +# and secrets from environment variables or elsewhere. Do not define +# any compile-time configuration in here, as it won't be applied. +# The block below contains prod specific runtime configuration. + +# ## Using releases +# +# If you use `mix release`, you need to explicitly enable the server +# by passing the PHX_SERVER=true when you start it: +# +# PHX_SERVER=true bin/app start +# +# Alternatively, you can use `mix phx.gen.release` to generate a `bin/server` +# script that automatically sets the env var above. +if System.get_env("PHX_SERVER") do + config :app, AppWeb.Endpoint, server: true +end + +if config_env() == :prod do + # The secret key base is used to sign/encrypt cookies and other secrets. + # A default value is used in config/dev.exs and config/test.exs but you + # want to use a different value for prod and you most likely don't want + # to check this value into version control, so we use an environment + # variable instead. + secret_key_base = + System.get_env("SECRET_KEY_BASE") || + raise """ + environment variable SECRET_KEY_BASE is missing. + You can generate one by calling: mix phx.gen.secret + """ + + host = System.get_env("PHX_HOST") || "example.com" + port = String.to_integer(System.get_env("PORT") || "4000") + + config :app, AppWeb.Endpoint, + url: [host: host, port: 443, scheme: "https"], + http: [ + # Enable IPv6 and bind on all interfaces. + # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. + # See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html + # for details about using IPv6 vs IPv4 and loopback vs public addresses. + ip: {0, 0, 0, 0, 0, 0, 0, 0}, + port: port + ], + secret_key_base: secret_key_base + + # ## SSL Support + # + # To get SSL working, you will need to add the `https` key + # to your endpoint configuration: + # + # config :app, AppWeb.Endpoint, + # https: [ + # ..., + # port: 443, + # cipher_suite: :strong, + # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), + # certfile: System.get_env("SOME_APP_SSL_CERT_PATH") + # ] + # + # The `cipher_suite` is set to `:strong` to support only the + # latest and more secure SSL ciphers. This means old browsers + # and clients may not be supported. You can set it to + # `:compatible` for wider support. + # + # `:keyfile` and `:certfile` expect an absolute path to the key + # and cert in disk or a relative path inside priv, for example + # "priv/ssl/server.key". For all supported SSL configuration + # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1 + # + # We also recommend setting `force_ssl` in your endpoint, ensuring + # no data is ever sent via http, always redirecting to https: + # + # config :app, AppWeb.Endpoint, + # force_ssl: [hsts: true] + # + # Check `Plug.SSL` for all available options in `force_ssl`. + + # ## Configuring the mailer + # + # In production you need to configure the mailer to use a different adapter. + # Also, you may need to configure the Swoosh API client of your choice if you + # are not using SMTP. Here is an example of the configuration: + # + # config :app, App.Mailer, + # adapter: Swoosh.Adapters.Mailgun, + # api_key: System.get_env("MAILGUN_API_KEY"), + # domain: System.get_env("MAILGUN_DOMAIN") + # + # For this example you need include a HTTP client required by Swoosh API client. + # Swoosh supports Hackney and Finch out of the box: + # + # config :swoosh, :api_client, Swoosh.ApiClient.Hackney + # + # See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details. +end diff --git a/config/test.exs b/config/test.exs new file mode 100644 index 0000000..2fceb3e --- /dev/null +++ b/config/test.exs @@ -0,0 +1,15 @@ +import Config + +# We don't run a server during test. If one is required, +# you can enable the server option below. +config :app, AppWeb.Endpoint, + http: [ip: {127, 0, 0, 1}, port: 4002], + secret_key_base: + "lrn6ftfPZu7KSTQ54v4foc/gq2FgYfB9/ckQw+Hhi/NNcUM7nf/mUlTqZWJOXAoK", + server: false + +# Print only warnings and errors during test +config :logger, level: :warning + +# Initialize plugs at runtime for faster test compilation +config :phoenix, :plug_init_mode, :runtime diff --git a/coveralls.json b/coveralls.json new file mode 100644 index 0000000..7e90f07 --- /dev/null +++ b/coveralls.json @@ -0,0 +1,14 @@ +{ + "coverage_options": { + "minimum_coverage": 100 + }, + "skip_files": [ + "test/", + "lib/app/application.ex", + "lib/app/release.ex", + "lib/app_web.ex", + "lib/app_web/gettext.ex", + "lib/app_web/components/", + "lib/app_web/telemetry.ex" + ] +} \ No newline at end of file diff --git a/example.md b/example.md new file mode 100644 index 0000000..08b6bd5 --- /dev/null +++ b/example.md @@ -0,0 +1,1095 @@ +
+ +# 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) + + +
+ +In this section, +we are going to implement an example application +using `Stripe` with `Elixir`. + + +> If you are new to `Elixir`, +we recommend reading +[`learn-elixir`](https://github.com/dwyl/learn-elixir) +and [`learn-phoenix-framework`](https://github.com/dwyl/learn-phoenix-framework) +***`before`*** you start this walk-through, +as focus on payment processing +and not on the basics of a `Phoenix` project. + +> **Note**: The reason we went with `Stripe` +and not any other alternative like `Paddle` +is because `Stripe` allows us to create a Developer account +without having to fill business-related information, +KYC and a company website.
+> We are going to see how far we can get with `Stripe` +and document the process. +If we decide we _need_ the features/simplicity +offered by `Paddle` we will switch and document that too. + +## Pre-requisites ๐Ÿ“ + +For this tutorial, +you will need to create a `Stripe` account. +Visit: +[stripe.com/register](https://dashboard.stripe.com/register) +to create an account. + +![stripe-register](https://user-images.githubusercontent.com/194400/211686517-6ccd95d0-1006-46d0-a4aa-6cadf01025a9.png) + +After inputting the required `Email`, `Full name` and `Password` +you will receive an email requiring you to **`Verify your email`**: + +![stripe-verify-email](https://user-images.githubusercontent.com/194400/211687211-fbfd50c9-9df3-44fd-bd09-62e1c3982430.png) + +Once you click the button, +you will see a screen similar to this: + +![activate-payments-skip](https://user-images.githubusercontent.com/194400/211686999-5a90827f-e2be-4155-9051-21b513cf9084.png) + +Click on **`skip for now`** to proceed to the main dashboard: + +![stripe-dashboard](https://user-images.githubusercontent.com/194400/211688125-eb3aabfa-8142-49f7-b043-2b0ab4b3ab70.png) + +If you type "**API**" in the search box +and choose **`Developers > API Keys`** ... + +![search-api-keys](https://user-images.githubusercontent.com/194400/211688294-a16d1c2e-ec2e-46f0-b89a-5c2481a77442.png) + +Or navigate directly to it: +[dashboard.stripe.com/test/**apikeys**](https://dashboard.stripe.com/test/apikeys) + +![stripe-test-api-keys](https://user-images.githubusercontent.com/194400/211688826-429d2905-49f9-4974-8654-08b3607b43f5.png) + +> **Note**: don't worry, these `API Keys` aren't valid. +> This is just for illustration purposes. + +The **_Test_ API keys** will be used later. +Save them and don't share them with anyone. +We are going to be using these +as +[environment variables](https://github.com/dwyl/learn-environment-variables). + + +## 1. Create a Phoenix project + +Let's start by creating a Phoenix project. +Run: +```sh +mix phx.new app --no-ecto --no-mailer --no-dashboard --no-gettext +``` +and when prompted, type `y` to accept downloading the dependencies. + +After this, if you run +`mix phx.server` +and visit `localhost:4000`, +you will be able to see the default landing page. + +![default](https://user-images.githubusercontent.com/17494745/209141154-a9d88988-6a36-4faa-8bbf-f1cf09684bf5.png) + +We want the `person` using the `App` to be able to log in. +We will check if they have *paid* or not +for using the `App`. +If they haven't, they are redirected to a `buy` page. +If they have, they will have access to it! + +It's a simple `App`, for sure. +But it's still important +to know ***how*** to properly implement it. + +## 2. Add `auth_plug` for user login + +We will be using +[`auth_plug`](https://github.com/dwyl/auth_plug) +so `people` are able to login. + +Let's install it. +Add the following to the `deps` section in `mix.exs`: + +```elixir +def deps do + [ + {:auth_plug, "~> 1.5.1"}, + ] +end +``` + +Once you've saved the file, +run: +``` +mix deps.get +``` + +Follow the +[instructions](https://github.com/dwyl/auth_plug#2-get-your-auth_api_key-) +and get your `AUTH_API_KEY`. + +Next create a file called: `.env` +and paste the `AUTH_API_KEY`, e.g: + +```sh +export AUTH_API_KEY=YOURKEYHERE +``` + +> **Note**: **never `commit` the `.env` file** to `GitHub` +as it contains sensitive information you don't want others to see. + +Open: +`lib/app_web/router.ex` +and add the following lines of code: + +```elixir + pipeline :auth, do: plug(AuthPlug) + + scope "/dashboard", AppWeb do + pipe_through :browser + pipe_through :auth + + get "/", AppController, :home + end +``` + +The `/dashboard` protected endpoint will only be accessible +for logged in users because we are using the +`:auth` pipeline. + +We are using `AppController`, +which is not yet created. +Create the following 4 files: + +1. `lib/app_web/controllers/app_controller.ex` +2. `lib/app_web/controllers/app_html.ex` +3. `lib/app_web/controllers/app_html/app.html.heex` +4. `test/app_web/controllers/auth_controller_test.exs` + + +In `app_controller.ex`, add the following code: + +```elixir +defmodule AppWeb.AppController do + use AppWeb, :controller + + def home(conn, _params) do + render(conn, :app, layout: false) + end +end +``` + +In `app_html.ex`, +add the following code: + +```elixir +defmodule AppWeb.AppHTML do + use AppWeb, :html + + embed_templates "app_html/*" +end +``` + +In `app_html/app.html.heex`: + +```html +
+ logged in +
+``` + +In `test/app_web/controllers/auth_controller_test.exs`: + +```elixir +defmodule AppWeb.AuthControllerTest do + use AppWeb.ConnCase, async: true + + test "Logout link displayed when loggedin", %{conn: conn} do + data = %{ + username: "test_username", + email: "test@email.com", + givenName: "John Doe", + picture: "this", + auth_provider: "GitHub", + id: 1 + } + + jwt = AuthPlug.Token.generate_jwt!(data) + + conn = get(conn, "/?jwt=#{jwt}") + assert html_response(conn, 200) =~ "logout" + end + + test "get /logout with valid JWT", %{conn: conn} do + data = %{ + email: "al@dwyl.com", + givenName: "Al", + picture: "this", + auth_provider: "GitHub", + id: 1 + } + + jwt = AuthPlug.Token.generate_jwt!(data) + + conn = + conn + |> put_req_header("authorization", jwt) + |> get("/logout") + + assert "/" = redirected_to(conn, 302) + end + + test "test login link redirect to authdemo.fly.dev", %{conn: conn} do + conn = get(conn, "/login") + assert redirected_to(conn, 302) =~ "authdemo.fly.dev" + end +end +``` + + +If you run: +```sh +source .env +``` + +and restart your server with: +`mix phx.server` +and access `/dashboard` directly, +you will be redirected to a page +where you can SSO using `Google` or `Github`. + +![redirect-to-auth](https://user-images.githubusercontent.com/194400/212059730-91137e3a-9a34-43e1-9cbe-9732511adcbd.png) + +After logging in, +the user has access to the URL! + +successful_login + +Congratulations, you just added basic authenticated to our application! +However, that's not quite what we want. +We want the user to be logged in +and *also* being a paying costumer so they have access to `/dashboard`. + +We can make it prettier, though. +Let's add two endpoints: +- one to login (`/login`) +- one to logout (`/logout`) + +From the landing page, +we are going to be adding a button that will log the user in, +and redirect to the `/login` URL. +After logging in, he will be redirected back to the home page. +We will need to conditionally render the home page (`/`) +according to the user being logged in or not. + +For this, we are going to be using +an [`optional auth pipeline`](https://github.com/dwyl/auth_plug#optional-auth). +It will add allow us show custom actions on people who are authenticated or not. +This pipeline will add the logged in to the `conn.assigns`. + +Head on to `lib/app_web/router.ex` +and add/change the following piece of code. + +```elixir + pipeline :authoptional, do: plug(AuthPlugOptional, %{}) + + scope "/", AppWeb do + pipe_through :browser + pipe_through :authoptional + + get "/", PageController, :home + get "/login", AuthController, :login + get "/logout", AuthController, :logout + end +``` + +We are now using a new controller `AuthController`. +Let's create it. +Inside `lib/app_web/controllers`, +create a new file called `auth_controller.ex` +and paste the following. + +```elixir +defmodule AppWeb.AuthController do + use AppWeb, :controller + + def login(conn, _params) do + redirect(conn, external: AuthPlug.get_auth_url(conn, ~p"/")) + end + + def logout(conn, _params) do + conn + |> AuthPlug.logout() + |> put_status(302) + |> redirect(to: ~p"/") + end +end +``` + +We just now need to change the view +to redirect to these URLs (`login` and `logout`). +Head over to `lib/app_web/controllers/page_html/home.html.heex`, +locate the line. + +```html +
+``` + +From this line to the end of the file, +change the contents with the next lines: + +```html +
+
+ +

+ Phoenix Framework + + with Stripe + +

+

+ An example integrating + Stripe + with + Phoenix +

+

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

/dashboard
+

+

+ Otherwise, they are redirected to a buying page to purchase so they can access it. +

+ + <%= if Map.has_key?(@conn.assigns, :person) do %> +
+ +

+ Hey there, <%= @conn.assigns.person.username %>! +

+

+ Thanks for logging in! + It seems that you haven't purchased our sweet dashboard. + You have to "purchase" to proceed. +

+ +
+
+ + + <.link + navigate={~p"/logout"} + class="text-purple-700 font-medium rounded-lg text-sm px-5 py-2.5 text-center mr-2 mb-2" + > + Logout + +
+
+ +
+ <% else %> +
+
+
+ <.link + navigate={~p"/login"} + class="text-white bg-gradient-to-br from-pink-500 to-orange-400 + hover:bg-gradient-to-bl + focus:ring-4 focus:outline-none focus:ring-pink-200 dark:focus:ring-pink-800 + font-medium rounded-lg text-sm px-5 py-2.5 text-center mr-2 mb-2" + > + Login + +
+
+
+ <% end %> +
+
+``` + +And you should be done! +In our main page we are checking if any user is logged in or not. +If there's a user authenticated, +we show his username and a button in which he can press +to purchase our sweet dashboard. + +On the other hand, if there isn't any user authenticated, +a `login` button is shown. + +The app should look like the following. + +![with_auth](https://user-images.githubusercontent.com/17494745/209171533-2a51572b-2d0f-4789-a28b-7d77771caaa5.gif) + +Now that we got that out of the way, +we can now *focus on integrating `Stripe`* +in our Phoenix application. + +This is going to be fun, let's do this! ๐Ÿ‘ + +## 3. Stripe integration + +Let's start by installing the package +that will allows us to communicate with **Stripe**. +We are going to be using +[`stripity-stripe`](https://github.com/beam-community/stripity-stripe). +This library will allow us to easily integrate Stripe in our Phoenix application. + +The library is maintained by the `BEAM Community`, +has 150+ contributors and is considered stable/reliable: + +![stripity-stripe](https://user-images.githubusercontent.com/194400/212060290-9044031a-4d6a-4127-b049-278c0543297f.png) + + +Go to your `mix.exs` file +and add this inside your dependency section. + +```elixir +{:stripity_stripe, "~> 2.17"} +``` + +and run `mix deps.get` to fetch this new dependency. +Following [their documentation](https://github.com/beam-community/stripity-stripe#configuration), +we need to add the next configuration inside `config.ex`. + +```elixir +config :stripity_stripe, api_key: System.get_env("STRIPE_API_KEY") +``` + +As you can see, we are using an environment variable +to serve the `STRIKE_API_KEY` so the library can use it to make requests. +We need to add these keys to our `.env` file. +To check your own API keys, +go to https://dashboard.stripe.com/test/apikey + +keys + +and paste the keys in your `.env` file. + +``` +export STRIPE_API_KEY= secret key +export STRIPE_PUBLIC= publishable key +``` + +After inputting these keys, +you can stop the server and, +in the same terminal session, run `source .env`, +so you can load these environment variables +and so they are available when you run `mix phx.server` again. + +Awesome job! ๐ŸŽ‰ +We can now start using it! + +### 3.1 Creating a **Stripe Checkout Session** + +To make this tutorial simple, +we are going to be using a [`Stripe Checkout`](https://stripe.com/docs/payments/checkout). +This makes it easy to make a payment +because the user is redirected to a page hosted by `Stripe` +with information about the product being purchased. +Therefore, we don't need to create the page ourselves. +We *could* but this is quicker ๐Ÿ˜‰. + +With this in mind, +we want to create a [`checkout session`](https://stripe.com/docs/api/checkout/sessions). +The user will either be successful with his payment +or fail. +We will be building a page for both. + +Let's implement this. + +Head on to to `lib/app_web/router.ex` +and add the following scope. + +```elixir + scope "/purchase", AppWeb do + pipe_through :browser + pipe_through :auth + + resources "/checkout-session", CheckoutSessionController, only: [:create] + + get "/success", PageController, :success + get "/cancel", PageController, :cancel + end +``` + +We want **only authenticated users** to go to +`/purchase/checkout-session` to purchase our product. +When they visit this URL, they will be redirected to our `Stripe Checkout` page, +with information about our product. +They payment will either *succeed* +(users will be redirected to `/purchase/success`) +or *fail* +(users will be redirected to `/purchase/cancel`). + +While the `success` and `cancel` pages +are being handled by the already existent `PageController`, +redirecting the users to the `Stripe Checkout` page +is being handled by `CheckoutSessionController`. +We need to create it! + +Inside `lib/app_web/controllers/`, +create a file called `checkout_session_controller.ex` +and paste the code: + +```elixir +defmodule AppWeb.CheckoutSessionController do + use AppWeb, :controller + + def create(conn, _params) do + url = AppWeb.Endpoint.url() + + params = %{ + line_items: [ + %{ + price: System.get_env("PRODUCT_PRICE_ID"), + quantity: 1 + } + ], + mode: "payment", + # https://stripe.com/docs/payments/checkout/custom-success-page + success_url: url <> ~p"/purchase/success?session_id={CHECKOUT_SESSION_ID}", + cancel_url: url <> ~p"/purchase/cancel?session_id={CHECKOUT_SESSION_ID}", + # user_email: conn.assigns.person.email, + automatic_tax: %{enabled: true} + } + + {:ok, session} = Stripe.Session.create(params) + + conn + |> put_status(303) + |> redirect(external: session.url) + end +end +``` + +Let's analyze the code. + +We are using `Stripe.session.create/1` +to create a [`Session`](https://stripe.com/docs/api/checkout/sessions) +in Stripe. +We need to pass a few required parameters. +We are following [their API specification](https://stripe.com/docs/api/checkout/sessions/object), +so you can always check their documentation if you're lost +on what fields you need to pass. +- the `**mode**` field pertains to the mode of the checkout session. +We can setup subscription or one-time payments. +We are doing the latter. +- the `**success_url**` and `**cancel_url**` refer to the redirection URLs +after the user either completes or cancels the checkout session. +We are adding a `{CHECKOUT_SESSION_ID}` template in the URL. +This tells Stripe to pass the `Checkout Session ID` to the client-side. +[This will allow us to customize the order information +upon completion or cancellation.](https://stripe.com/docs/payments/checkout/custom-success-page) +- we set `**automatic_tax**` to be enabled so it makes tax conversions automatically. +- the `**line_items**` array refer to the list of items the customer is purchasing. +We are passing the item `id` and the `quantity`. +- you can optionally pass a `customer_email`, +so the field is already defined within the checkout session from the get-go. + +We are then creating a session +and redirecting the user to the `Checkout` page. + +As you might have noticed, +we are using an environment variable `PRODUCT_PRICE_ID` +to set `price` field of the product. + +We *haven't created this yet*. +So let's do it! + +### 3.2 Creating a `Product` to sell + +As it stands, we have no products for customers to purchase. +Let's create one! ๐Ÿ˜Ž + +Go to https://dashboard.stripe.com/test/products/create +and fill up the information about your product. + + +product_information + +> This should be all you need to do. +However, sometimes you may experience an error +while creating a `checkout session` +when doing the request in Phoenix. +> +> The error is probably +`Stripe Tax has not been activated on your account. +Please visit https://dashboard.stripe.com/settings/tax/activate to get started.` +Don't be alarmed. +If you follow the link, enable `Stripe Tax` +and just create a tax rate on whatever country you want, +You can check your tax rates in +https://dashboard.stripe.com/test/tax-rates. +> +> image + +After creating your product, +you will be redirected to the page of the product created. +You can always check the products you created +in https://dashboard.stripe.com/test/products. + +The page of the product created should look like this. + +product page + +We are going to be using the `API ID` +in the Pricing section. +Copy it and paste it in your `.env` file. + +``` +export PRODUCT_PRICE_ID= price id +``` + +In the same terminal session, kill the server, +run `source .env` and you should be good to go! + +### 3.3 Success and failure after `Checkout Session` + +As we've stated before, +the users are redirected to a `success` or `cancel` page +depending on the outcome of the `Checkout Session`. + +Since we defined these endpoints +be controlled by `PageController`, +we need to add these handlers. + +Go to `lib/app_web/controllers/page_controller.ex` +and add the following piece of code: + +```elixir + def success(conn, %{"session_id" => session_id}) do + case Stripe.Session.retrieve(session_id) do + {:ok, _session} -> + render(conn, :success, layout: false) + + {:error, _error} -> + conn + |> put_status(303) + |> redirect(to: ~p"/") + end + end + + def success(conn, _params) do + conn + |> put_status(303) + |> redirect(to: ~p"/") + end + + def cancel(conn, %{"session_id" => session_id}) do + + case Stripe.Session.retrieve(session_id) do + {:ok, _session} -> + render(conn, :cancel, layout: false) + + {:error, _error} -> + conn + |> put_status(303) + |> redirect(to: ~p"/") + end + end + + def cancel(conn, _params) do + conn + |> put_status(303) + |> redirect(to: ~p"/") + end +``` + +Let's break it down. + +When creating a session, +we are requesting Stripe to redirect the user +*back* to us with a query parameter `session_id` +with the session ID. + +This `session_id` will allow us to +conditionally render these pages according to the outcome +of the process. + +Both `success` and `cancel` workflows have the same workflow. + +When the customer successfully pays for the product, +we check if the `session_id` is valid +by retrieving it from `Stripe`. + +```elixir +Stripe.Session.retrieve(session_id) +``` + +If it is successful, we render a page confirming the payment. +If it is not, we simply redirect the user to the homepage. +If the user tries to directly access `/purchase/success`, +he is redirected to the homepage as well. + +The same procedure happens in the `cancel` scenario. + +We now need to create these pages! +Inside `lib/app_web/controllers/page_html`, +create two files. +Create `success.html.heex` +and use this code: + +```html +
+
+ + + + +
+

Payment Done!

+

Thank you for completing your secure online payment.

+

Have a great day!

+
+ <.link + href={~p"/dashboard"} + class="px-12 bg-indigo-600 hover:bg-indigo-500 text-white font-semibold py-3" + > + Go to dashboard + +
+
+
+
+``` + +In this page, we are thanking the user +for completing the payment +and giving him the option to access +the ever so glorious โœจ**dashboard**โœจ. + +In the same directory, +create the `cancel.html.heex` file: + +```elixir +
+
+
+

+ Oh no... +

+

looks like something went wrong.

+

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

+ <.link + href={~p"/"} + class="px-8 py-3 font-semibold rounded text-white bg-violet-400" + > + Back to homepage + +
+
+
+``` + +In this page, +we state that something went wrong +and show a button to return to the homepage. + +### 3.4 Making our "`dashboard`" cool + +We don't really have a `dashboard` to show. +In fact, we don't need to, it's out of the scope of this tutorial. +So let's just show a Nyan Cat! ๐Ÿˆ + +Inside `lib/app_web/controllers/app_html/app.html.heex`, +change the code to the following: + +```elixir +
+ Nyan cat +
+

Nyan Cat!

+

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

+

sorry.

+ + <.link + navigate={~p"/"} + class="text-white bg-gradient-to-r from-blue-500 via-blue-600 to-blue-700 hover:bg-gradient-to-br + focus:ring-4 focus:outline-none focus:ring-blue-300 dark:focus:ring-blue-800 shadow-lg + 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 " + > + go back + +
+
+``` + +All that's left is for the `Purchase` button +in our homepage to redirect the user +to the `/purchase/checkout-session` +for the customer to pay for the product. + +Inside `lib/app_web/controllers/page_html/home.html.heex`, +locate the line: + +```html + + Purchase + +``` + +And replace it with: + +```html +<.link + href={~p"/purchase/checkout-session"} + method="post" + 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" +> + Purchase + +``` + +And you should be done! ๐ŸŽ‰ + +Let's see what we've done so far. +Run `mix phx.server` +(make sure you loaded the env variables +with `source .env`) +and visit `localhost:4000`. +You should see the following! ๐Ÿ‘ + +![stripe_integrated](https://user-images.githubusercontent.com/17494745/209217397-4b1c1cb4-6777-4ca0-998e-b860030642a8.gif) + +> You can complete the payment +using test data with the credit card. +You can fill whatever e-mail address (real or not) +or name to pay. +The expiry date and CVC can also be random. +> +> - Payment succeeds: `4242 4242 4242 4242` +> - Payment requires authentication: `4000 0025 0000 3155` +> - Payment is declined: `4000 0000 0000 9995` + +You can check the customers info in +https://dashboard.stripe.com/test/customers. +You may delete the customers, if you want. + +users + +## 4. Blocking unpaid users from `dashboard` + +We now got a decent workflow going. +But authenticated can still access `/dashboard` +while just being authenticated. +We want them to be logged in +*and also have paid to access it*. + +We are currently not tracking our customers +who have paid and should be granted access. +We are going to be needing to create +a way to save `User`s object +with the referring `stripe_id` +and a boolean field referring to the payment status. + +We could use a PostgresSQL engine for this, +but for this use case it's a bit overkill. +Instead, to simplify deployment, +we will be using +[`Erland Term Storage (ETS)`](https://elixirschool.com/en/lessons/storage/ets). +This is a in-memory storage engine built into OTP +and can be employed to store large amounts of data +*with constant time data access*. +Do note that since this engine is in-memory, +**all data is lost when the process ends/server is shutdown**. + +There is an Elixir wrapper that makes it easy to use `ETS` and `DETS`, +where the latter is a disk-based variant of the former, +where data is persisted on disk. +We'll be using +[`pockets`](https://github.com/fireproofsocks/pockets). + +Let's install it. + +Add it to the `deps` section inside `mix.exs`. + +```elixir +def deps do + [ + {:pockets, "~> 0.1.0"} + ] +end +``` + +With that installed, +we are going to be creating a module +that will *manage the users table*. +We are going to create it on startup, +and create users/edit them. + +For that, create a file in `lib/app/users.ex`. + +```elixir +defmodule UsersTable do + + alias Pockets + + @table :users_table + @filepath "cache.dets" + + def init do + case Pockets.new(@table, @filepath) do + {:ok, set} -> {:ok, set} + {:error, _} -> + Pockets.open(@table, @filepath) + end + end + + def create_user(%{:stripe_id => stripe_id, :person_id => person_id, :status => status}) do + Pockets.put(@table, person_id, %{stripe_id: stripe_id, status: status}) + end + + def fetch_user(person_id) do + Pockets.get(@table, person_id) + end + +end +``` + +Let's go over what we have done. +We are going to be saving our users +with a tuple containing the `**stripe_id**`, +the `person_id` of the logged in user +and a `**status**` boolean field, +referring to whether the user has paid or not. + +All the functions being used are used +according to the [`ets` wrapper documentation](https://github.com/TheFirstAvenger/ets). +- the `init/0` function creates the table to store our users. +If the file already exists, we open the file. +- `create_user/3` receives a `stripe_id`, `person_id` and `status` +(pertaining to whether the payment has been made or not) +and creates a user object. +- `fetch_user/1` retrieves the persisted user +according to the given` person_id`. + +Let's make use of some of these functions. +We want to setup the `DETS` table on the process startup. +For this, we are going to initiate the table +on the `start/1` function inside `lib/app/application.ex`. +This function is executed when the process is created, +so it fits right our needs! +Add the following code below the `children` array variable +inside `start/1`. + +```elixir +UsersTable.init() +``` + +We now need to **create the user** every time +a Stripe session is successful. +We can do this inside `lib/app_web/controllers/page_controller.ex` +on the `success/2` callback. +Change the handler, so it looks like the following: + +```elixir +def success(conn, %{"session_id" => session_id}) do + + case Stripe.Session.retrieve(session_id) do + {:ok, session} -> + + person_id = conn.assigns.person.id + UsersTable.create_user(%{person_id: person_id, stripe_id: session.customer, status: true}) + + render(conn, :success, layout: false) + + {:error, _error} -> + conn + |> put_status(303) + |> redirect(to: ~p"/") + end +end +``` + +Now, inside `lib/app_web/controllers/app_+controller.ex`, +we will render our "dashboard" +**only** if a user is found in our `Users` table. +If there isn't a user found, we redirect the user to the homepage. +Change the `home/2` function so it looks like the following. + +```elixir +def home(conn, _params) do + + person_id = conn.assigns.person.id + case UsersTable.fetch_user(person_id) do + nil -> + conn |> redirect(to: ~p"/") + + _ -> + render(conn, :app, layout: false) + end +end +``` + +All there's left to do +is to change the `Purchase` button to dynamically change. +We want paid users to access the dashboard directly, +and non-paid users to purchase it. + +To do this, add this function +in `lib/app_web/controllers/page_html.ex`. + +```elixir + def check_user_has_paid(person_id) do + user = UsersTable.fetch_user(person_id) + + if user == nil, do: false, else: user.status + end +``` + +Inside `lib/app_web/controllers/page_html/home.html.heex`, +change the `Purchase` button so it changes according to the user's payment status. + +```html +<%= if check_user_has_paid(@conn.assigns.person.id) do %> + <.link + href={~p"/dashboard"} + 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" + > + Enter + + <% else %> + <.link + href={~p"/purchase/checkout-session"} + method="post" + 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" + > + Purchase + + <% end %> +``` + +## 5. All done! ๐ŸŽ‰ + +And you should be done! +The user can now pay for our product. +We are restricting user access for only users that have paid. +Users that *have* made the payment **will have access**. + +![final](https://user-images.githubusercontent.com/17494745/209818299-f9a1a197-6ef8-4e2e-b1be-960b6df88d1b.gif) + + +# Thanks! + +Thanks for learning about payment processing with us! +If you have any questions, please ask!! +Please โญ this repo to help spread the word! + +If you are using environment variables in a way not mentioned in this guide, +or have a better way of managing them +or any other ideas or suggestions for improvements +please tell us!! + +[![HitCount](https://hits.dwyl.com/dwyl/learn-payment-processing-example.svg?style=flat-square)](http://hits.dwyl.com/dwyl/learn-payment-processing) diff --git a/lib/app.ex b/lib/app.ex new file mode 100644 index 0000000..a10dc06 --- /dev/null +++ b/lib/app.ex @@ -0,0 +1,9 @@ +defmodule App do + @moduledoc """ + App keeps the contexts that define your domain + and business logic. + + Contexts are also responsible for managing your data, regardless + if it comes from the database, an external API or others. + """ +end diff --git a/lib/app/application.ex b/lib/app/application.ex new file mode 100644 index 0000000..a46d1ba --- /dev/null +++ b/lib/app/application.ex @@ -0,0 +1,39 @@ +defmodule App.Application do + # See https://hexdocs.pm/elixir/Application.html + # for more information on OTP Applications + @moduledoc false + + use Application + + @impl true + def start(_type, _args) do + children = [ + # Start the Telemetry supervisor + AppWeb.Telemetry, + # Start the PubSub system + {Phoenix.PubSub, name: App.PubSub}, + # Start Finch + {Finch, name: App.Finch}, + # Start the Endpoint (http/https) + AppWeb.Endpoint + # Start a worker by calling: App.Worker.start_link(arg) + # {App.Worker, arg} + ] + + # Creating DETS user table + UsersTable.init() + + # See https://hexdocs.pm/elixir/Supervisor.html + # for other strategies and supported options + opts = [strategy: :one_for_one, name: App.Supervisor] + Supervisor.start_link(children, opts) + end + + # Tell Phoenix to update the endpoint configuration + # whenever the application is updated. + @impl true + def config_change(changed, _new, removed) do + AppWeb.Endpoint.config_change(changed, removed) + :ok + end +end diff --git a/lib/app/users.ex b/lib/app/users.ex new file mode 100644 index 0000000..aaac0e1 --- /dev/null +++ b/lib/app/users.ex @@ -0,0 +1,39 @@ +defmodule UsersTable do + alias Pockets + + @table (if Mix.env() == :prod do + :users_table + else + :users_test_table + end) + @filepath (if Mix.env() == :prod do + "cache.dets" + else + "cache_test.dets" + end) + + def init() do + case Pockets.new(@table, @filepath) do + # Not testing creating a table because when testing, it loads a sample table. + # coveralls-ignore-start + {:ok, set} -> + {:ok, set} + + # coveralls-ignore-end + {:error, _} -> + Pockets.open(@table, @filepath) + end + end + + def create_user(%{ + :stripe_id => stripe_id, + :person_id => person_id, + :status => status + }) do + Pockets.put(@table, person_id, %{stripe_id: stripe_id, status: status}) + end + + def fetch_user(person_id) do + Pockets.get(@table, person_id) + end +end diff --git a/lib/app_web.ex b/lib/app_web.ex new file mode 100644 index 0000000..bf82b78 --- /dev/null +++ b/lib/app_web.ex @@ -0,0 +1,114 @@ +defmodule AppWeb do + @moduledoc """ + The entrypoint for defining your web interface, such + as controllers, components, channels, and so on. + + This can be used in your application as: + + use AppWeb, :controller + use AppWeb, :html + + The definitions below will be executed for every controller, + component, etc, so keep them short and clean, focused + on imports, uses and aliases. + + Do NOT define functions inside the quoted expressions + below. Instead, define additional modules and import + those modules here. + """ + + def static_paths, do: ~w(assets fonts images favicon.ico robots.txt) + + def router do + quote do + use Phoenix.Router, helpers: false + + # Import common connection and controller functions to use in pipelines + import Plug.Conn + import Phoenix.Controller + import Phoenix.LiveView.Router + end + end + + def channel do + quote do + use Phoenix.Channel + end + end + + def controller do + quote do + use Phoenix.Controller, + namespace: AppWeb, + formats: [:html, :json], + layouts: [html: AppWeb.Layouts] + + import Plug.Conn + import AppWeb.Gettext + + unquote(verified_routes()) + end + end + + def live_view do + quote do + use Phoenix.LiveView, + layout: {AppWeb.Layouts, :app} + + unquote(html_helpers()) + end + end + + def live_component do + quote do + use Phoenix.LiveComponent + + unquote(html_helpers()) + end + end + + def html do + quote do + use Phoenix.Component + + # Import convenience functions from controllers + import Phoenix.Controller, + only: [get_csrf_token: 0, view_module: 1, view_template: 1] + + # Include general helpers for rendering HTML + unquote(html_helpers()) + end + end + + defp html_helpers do + quote do + # HTML escaping functionality + import Phoenix.HTML + # Core UI components and translation + import AppWeb.CoreComponents + import AppWeb.Gettext + + # Shortcut for generating JS commands + alias Phoenix.LiveView.JS + + # Routes generation with the ~p sigil + unquote(verified_routes()) + end + end + + def verified_routes do + quote do + use Phoenix.VerifiedRoutes, + endpoint: AppWeb.Endpoint, + router: AppWeb.Router, + statics: AppWeb.static_paths() + end + end + + @doc """ + When used, dispatch to the appropriate controller/view/etc. + """ + defmacro __using__(which) when is_atom(which) do + apply(__MODULE__, which, []) + end +end diff --git a/lib/app_web/components/core_components.ex b/lib/app_web/components/core_components.ex new file mode 100644 index 0000000..dff9ee6 --- /dev/null +++ b/lib/app_web/components/core_components.ex @@ -0,0 +1,700 @@ +defmodule AppWeb.CoreComponents do + @moduledoc """ + Provides core UI components. + + The components in this module use Tailwind CSS, a utility-first CSS framework. + See the [Tailwind CSS documentation](https://tailwindcss.com) to learn how to + customize the generated components in this module. + + Icons are provided by [heroicons](https://heroicons.com), using the + [heroicons_elixir](https://github.com/mveytsman/heroicons_elixir) project. + """ + use Phoenix.Component + + alias Phoenix.LiveView.JS + import AppWeb.Gettext + + @doc """ + Renders a modal. + + ## Examples + + <.modal id="confirm-modal"> + Are you sure? + <:confirm>OK + <:cancel>Cancel + + + JS commands may be passed to the `:on_cancel` and `on_confirm` attributes + for the caller to react to each button press, for example: + + <.modal id="confirm" on_confirm={JS.push("delete")} on_cancel={JS.navigate(~p"/posts")}> + Are you sure you? + <:confirm>OK + <:cancel>Cancel + + """ + attr :id, :string, required: true + attr :show, :boolean, default: false + attr :on_cancel, JS, default: %JS{} + attr :on_confirm, JS, default: %JS{} + + slot :inner_block, required: true + slot :title + slot :subtitle + slot :confirm + slot :cancel + + def modal(assigns) do + ~H""" +