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 ๐ณ
+
+
+[](https://codecov.io/github/dwyl/phoenix-chat-example?branch=main)
+[](https://github.com/dwyl/phoenix-chat-example/issues)
+[](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 ...
+
+[](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? ๐คทโโ๏ธ
+
+
+
+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`:
+
+
+
+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!
+
+
+
+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)**.
+
+
+
+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:
+
+
+
+
+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 @@
+
+
+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.
+
+
+
+After inputting the required `Email`, `Full name` and `Password`
+you will receive an email requiring you to **`Verify your email`**:
+
+
+
+Once you click the button,
+you will see a screen similar to this:
+
+
+
+Click on **`skip for now`** to proceed to the main dashboard:
+
+
+
+If you type "**API**" in the search box
+and choose **`Developers > API Keys`** ...
+
+
+
+Or navigate directly to it:
+[dashboard.stripe.com/test/**apikeys**](https://dashboard.stripe.com/test/apikeys)
+
+
+
+> **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.
+
+
+
+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`.
+
+
+
+After logging in,
+the user has access to the URL!
+
+
+
+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 %>
+
+```
+
+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.
+
+
+
+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:
+
+
+
+
+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
+
+
+
+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.
+
+
+
+
+> 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.
+>
+>
+
+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.
+
+
+
+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
+
+```
+
+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! ๐
+
+
+
+> 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.
+
+
+
+## 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**.
+
+
+
+
+# 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!!
+
+[](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"""
+
+ """
+ end
+
+ ## JS Commands
+
+ def show(js \\ %JS{}, selector) do
+ JS.show(js,
+ to: selector,
+ transition:
+ {"transition-all transform ease-out duration-300",
+ "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
+ "opacity-100 translate-y-0 sm:scale-100"}
+ )
+ end
+
+ def hide(js \\ %JS{}, selector) do
+ JS.hide(js,
+ to: selector,
+ time: 200,
+ transition:
+ {"transition-all transform ease-in duration-200",
+ "opacity-100 translate-y-0 sm:scale-100",
+ "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
+ )
+ end
+
+ def show_modal(js \\ %JS{}, id) when is_binary(id) do
+ js
+ |> JS.show(to: "##{id}")
+ |> JS.show(
+ to: "##{id}-bg",
+ transition:
+ {"transition-all transform ease-out duration-300", "opacity-0",
+ "opacity-100"}
+ )
+ |> show("##{id}-container")
+ |> JS.focus_first(to: "##{id}-content")
+ end
+
+ def hide_modal(js \\ %JS{}, id) do
+ js
+ |> JS.hide(
+ to: "##{id}-bg",
+ transition:
+ {"transition-all transform ease-in duration-200", "opacity-100",
+ "opacity-0"}
+ )
+ |> hide("##{id}-container")
+ |> JS.hide(to: "##{id}", transition: {"block", "block", "hidden"})
+ |> JS.pop_focus()
+ end
+
+ @doc """
+ Translates an error message using gettext.
+ """
+ def translate_error({msg, opts}) do
+ # When using gettext, we typically pass the strings we want
+ # to translate as a static argument:
+ #
+ # # Translate "is invalid" in the "errors" domain
+ # dgettext("errors", "is invalid")
+ #
+ # # Translate the number of files with plural rules
+ # dngettext("errors", "1 file", "%{count} files", count)
+ #
+ # Because the error messages we show in our forms and APIs
+ # are defined inside Ecto, we need to translate them dynamically.
+ # This requires us to call the Gettext module passing our gettext
+ # backend as first argument.
+ #
+ # Note we use the "errors" domain, which means translations
+ # should be written to the errors.po file. The :count option is
+ # set by Ecto and indicates we should also apply plural rules.
+ if count = opts[:count] do
+ Gettext.dngettext(AppWeb.Gettext, "errors", msg, msg, count, opts)
+ else
+ Gettext.dgettext(AppWeb.Gettext, "errors", msg, opts)
+ end
+ end
+
+ @doc """
+ Translates the errors for a field from a keyword list of errors.
+ """
+ def translate_errors(errors, field) when is_list(errors) do
+ for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
+ end
+
+ defp input_equals?(val1, val2) do
+ Phoenix.HTML.html_escape(val1) == Phoenix.HTML.html_escape(val2)
+ end
+end
diff --git a/lib/app_web/components/layouts.ex b/lib/app_web/components/layouts.ex
new file mode 100644
index 0000000..2ce6721
--- /dev/null
+++ b/lib/app_web/components/layouts.ex
@@ -0,0 +1,5 @@
+defmodule AppWeb.Layouts do
+ use AppWeb, :html
+
+ embed_templates "layouts/*"
+end
diff --git a/lib/app_web/components/layouts/app.html.heex b/lib/app_web/components/layouts/app.html.heex
new file mode 100644
index 0000000..0294537
--- /dev/null
+++ b/lib/app_web/components/layouts/app.html.heex
@@ -0,0 +1,56 @@
+
+
diff --git a/lib/app_web/controllers/auth_controller.ex b/lib/app_web/controllers/auth_controller.ex
new file mode 100644
index 0000000..e36b140
--- /dev/null
+++ b/lib/app_web/controllers/auth_controller.ex
@@ -0,0 +1,14 @@
+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
diff --git a/lib/app_web/controllers/checkout_session_controller.ex b/lib/app_web/controllers/checkout_session_controller.ex
new file mode 100644
index 0000000..494b38e
--- /dev/null
+++ b/lib/app_web/controllers/checkout_session_controller.ex
@@ -0,0 +1,30 @@
+defmodule AppWeb.CheckoutSessionController do
+ use AppWeb, :controller
+
+ def create(conn, _params) do
+ url = AppWeb.Endpoint.url()
+
+ params = %{
+ line_items: [
+ %{
+ # Provide the exact Price ID (e.g. pr_1234) of the product you want to sell
+ 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
diff --git a/lib/app_web/controllers/error_html.ex b/lib/app_web/controllers/error_html.ex
new file mode 100644
index 0000000..406f0b8
--- /dev/null
+++ b/lib/app_web/controllers/error_html.ex
@@ -0,0 +1,19 @@
+defmodule AppWeb.ErrorHTML do
+ use AppWeb, :html
+
+ # If you want to customize your error pages,
+ # uncomment the embed_templates/1 call below
+ # and add pages to the error directory:
+ #
+ # * lib/app_web/controllers/error/404.html.heex
+ # * lib/app_web/controllers/error/500.html.heex
+ #
+ # embed_templates "error/*"
+
+ # The default is to render a plain text page based on
+ # the template name. For example, "404.html" becomes
+ # "Not Found".
+ def render(template, _assigns) do
+ Phoenix.Controller.status_message_from_template(template)
+ end
+end
diff --git a/lib/app_web/controllers/error_json.ex b/lib/app_web/controllers/error_json.ex
new file mode 100644
index 0000000..d1868a5
--- /dev/null
+++ b/lib/app_web/controllers/error_json.ex
@@ -0,0 +1,19 @@
+defmodule AppWeb.ErrorJSON do
+ # If you want to customize a particular status code,
+ # you may add your own clauses, such as:
+ #
+ # def render("500.json", _assigns) do
+ # %{errors: %{detail: "Internal Server Error"}}
+ # end
+
+ # By default, Phoenix returns the status message from
+ # the template name. For example, "404.json" becomes
+ # "Not Found".
+ def render(template, _assigns) do
+ %{
+ errors: %{
+ detail: Phoenix.Controller.status_message_from_template(template)
+ }
+ }
+ end
+end
diff --git a/lib/app_web/controllers/page_controller.ex b/lib/app_web/controllers/page_controller.ex
new file mode 100644
index 0000000..247efd0
--- /dev/null
+++ b/lib/app_web/controllers/page_controller.ex
@@ -0,0 +1,51 @@
+defmodule AppWeb.PageController do
+ use AppWeb, :controller
+
+ def home(conn, _params) do
+ render(conn, :home, layout: false)
+ end
+
+ 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
+
+ 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
+end
diff --git a/lib/app_web/controllers/page_html.ex b/lib/app_web/controllers/page_html.ex
new file mode 100644
index 0000000..58861ad
--- /dev/null
+++ b/lib/app_web/controllers/page_html.ex
@@ -0,0 +1,11 @@
+defmodule AppWeb.PageHTML do
+ use AppWeb, :html
+
+ embed_templates "page_html/*"
+
+ def check_user_has_paid(person_id) do
+ user = UsersTable.fetch_user(person_id)
+
+ if user == nil, do: false, else: user.status
+ end
+end
diff --git a/lib/app_web/controllers/page_html/cancel.html.heex b/lib/app_web/controllers/page_html/cancel.html.heex
new file mode 100644
index 0000000..1787e2f
--- /dev/null
+++ b/lib/app_web/controllers/page_html/cancel.html.heex
@@ -0,0 +1,21 @@
+
+
+
+
+ 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
+
+
+
+
diff --git a/lib/app_web/controllers/page_html/home.html.heex b/lib/app_web/controllers/page_html/home.html.heex
new file mode 100644
index 0000000..614a2a8
--- /dev/null
+++ b/lib/app_web/controllers/page_html/home.html.heex
@@ -0,0 +1,124 @@
+
+
+
+
+
+
+
+ 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 %>
+