2 |
3 | # Learn Payment Processing 💳
4 |
5 | 
6 | [](https://codecov.io/github/dwyl/learn-payment-processing?branch=main)
7 | [](https://hits.dwyl.com/dwyl/learn-payment-processing)
8 | [](https://github.com/dwyl/phoenix-chat-example/issues)
9 |
10 | Learn what payment processing is
11 | and how you can add it to your App
12 | to get _paid_! 🎉
13 |
14 |
15 |
16 | # Why? 🥕
17 |
18 | Sadly, not all applications can be "free";
19 | the **`people`** that build them need to be **paid**
20 | and the underlying infrastructure costs money too.
21 | Many App/Websites cover their costs through advertising.
22 | This is effectively selling [out] the "users" data,
23 | which we are
24 | [not fans](https://github.com/dwyl/learn-react/issues/23#issuecomment-552212087)
25 | of ...
26 |
27 | [](https://slate.com/technology/2018/04/are-you-really-facebooks-product-the-history-of-a-dangerous-idea.html)
28 |
29 | > "_When something online is free,
30 | > you're not the customer,
31 | > you're the product._"
32 | > ~ [Jonathan Zittrain](https://blogs.harvard.edu/futureoftheinternet/2012/03/21/meme-patrol-when-something-online-is-free-youre-not-the-customer-youre-the-product/)
33 |
34 | We prefer to charge an **affordable fee** -
35 | enough to **cover** all our **costs**
36 | and continue building our
37 | [roadmap](https://github.com/dwyl/product-roadmap) -
38 | and **_fiercely_ guard** the **privacy**
39 | of the **`people`** using the App.
40 |
41 | The goal of this guide is to cover
42 | both the theory and _practice_
43 | of payment processing
44 | _and_ to showcase payment processing
45 | in a standalone web app.
46 |
47 | # Who? 🤓
48 |
49 | This guide is meant as both
50 | an **_internal_ reference** for us **`@dwyl`**
51 | and a **_fully_ Open Source resource**
52 | that _anyone_ can read and learn from.
53 |
54 | As always, if you find it helpful/useful please star the repo on GitHub ⭐ 🙏 Thanks!
55 |
56 | If you get stuck or have any questions/suggestions,
57 | please [open an issue](https://github.com/dwyl/learn-payment-processing/issues).
58 |
59 |
60 | # What 💭
61 |
62 | Some Apps are sold via **one time purchase**
63 | others are **subscription-based** (recurring payments).
64 | Regardless of the type of payment,
65 | they share one thing in common:
66 | **payment processing gateways
67 | are used to collect funds**.
68 |
69 | But what _is_ "payment processing"...?
70 |
71 |
72 | ## Payment Processor or Gateway? 🤷♀️
73 |
74 | 
75 |
76 | A **payment processor** functions as an intermediary
77 | between the customer's bank (or digital wallet)
78 | and the merchant
79 | (and their bank).
80 | It is the entity responsible for communicating
81 | between both parties in the transaction.
82 |
83 | A **payment gateway**
84 | is a virtual
85 | [*point of sale*](https://en.wikipedia.org/wiki/Point_of_sale)
86 | for online payments.
87 | Similar to when a customer swipes/taps their card
88 | on a **_physical_ credit card terminal**,
89 | online stores need a gateway to securely collect
90 | the customer's card details.
91 | A payment gateway
92 | acts like is a _virtual_ credit card terminal.
93 |
94 | The whole process of online payment
95 | usually assumes the merchant
96 | has a
97 | [**merchant account**](https://www.investopedia.com/terms/m/merchant-account.asp).
98 | A merchant account is simply
99 | a type of business bank account
100 | that *allows a business
101 | to receive credit card
102 | and other electronic funds transfers*.
103 |
104 | > **Note**: the terms
105 | > `payment processor`
106 | > and `payment gateway`
107 | > usually fall under the same term;
108 | `payment processor`.
109 | This is because they work together to handle payment processing.
110 | So if you see platforms like `Stripe`
111 | being mentioned as a "payment processor",
112 | it's because it offers both `payment gateway`
113 | and `payment processor`
114 | bundled together
115 | alongside a myriad of other features
116 | such as fraud prevention.
117 |
118 | ## Which Payment Processing Provider? 🤔
119 |
120 | There are several Payment processing providers,
121 | the most recognizable are:
122 | [`PayPal`](https://developer.paypal.com/api/rest/),
123 | [`Stripe`](https://stripe.com)
124 | or
125 | [`Square`](https://developer.squareup.com/us/en).
126 |
127 | We have used each of these in the past
128 | and they are fairly straightforward to integrate.
129 |
130 | Let's do a quick rundown of the various payment providers
131 | we want to support in our `App`:
132 |
133 | ### `PayPal` - the _Original_ Payment Processor 💵
134 |
135 | Started in 1998,
136 | `PayPal` is one of the _original_
137 | and most successful
138 | general purpose online payment processors.
139 |
140 | If you've done much online shopping,
141 | you have probably seen a payment interface
142 | allowing purchases through `Paypal`:
143 |
144 | 
145 |
146 | If you want to alow `people` to purchase
147 | an through `PayPal`,
148 | you'd have to setup a `PayPal` account
149 | and use one of their SDKs:
150 | [developer.paypal.com](https://developer.paypal.com/home)
151 |
152 | This uses the
153 | `PayPal` E-commerce platform
154 | [paypal.com/us/business/platforms](https://www.paypal.com/us/business/platforms-and-marketplaces)
155 | to setup a payment gateway and processor
156 | for `people` to pay with Paypal on your site.
157 |
158 | The biggest advantage of `PayPal`
159 | is that has been a "Digital Wallet" from the early days.
160 | Which means people _store_ funds in their `PayPal` account
161 | as if it were a Bank.
162 | An individual can _sell_ something online
163 | e.g. on
164 | [`eBay`](https://www.ebay.com/help/selling/listings/choosing-get-paid/accepting-other-payment-methods?id=4184)
165 | or
166 | [`etsy`](https://help.etsy.com/hc/en-us/articles/360000104828-How-to-Pay-With-PayPal?segment=shopping)
167 | and collect payment with `PayPal`.
168 | These funds stay in their `PayPal` account unless they extract them
169 | to a Bank Account. Many people hold balances in their `PayPal` for future online shopping.
170 | People consider this to be their "spending money".
171 | We want to help those people _invest_ the money wisely in themselves!
172 |
173 | The history/evolution of `PayPal` is a fascinating success story,
174 | [wikipedia.org/wiki/PayPal#Early_history](https://en.wikipedia.org/wiki/PayPal).
175 | We are only concerned about the _present_;
176 | `PayPal` has over **`400 million people` _actively_ using their platform**
177 | and can be used in more than 200 countries/regions.
178 | A _significant_ proportion of `people` buying things online
179 | _already_ use and _trust_ `PayPal`.
180 | We want to offer the ability to checkout with a `PayPal`
181 | account as one of the lowest friction payment methods.
182 | For people who are signed into their `PayPal` account,
183 | checkout can be **2 clicks/taps**. 🚀
184 |
185 |
186 | ### `Apple Pay` 🍎
187 |
188 | There are **`1.2 Billion` _active_ `iPhone`** users worldwide.
189 | `iPhone` recently surpassed
190 | [**`50%` Smart Phone Market Share**](https://github.com/dwyl/learn-flutter/pull/69#issuecomment-1319811970)
191 | in the **US**
192 | and in some wealthier countries like Norway,
193 | it's as high as
194 | [**`68%`**](https://github.com/dwyl/learn-flutter/pull/69#issuecomment-1319826473).
195 | Many of the `people` using `iPhone`
196 | have a payment card associated with their Apple Account.
197 | [**`Apple Pay`**](https://www.apple.com/apple-pay/)
198 | has **`500 million`** registered users worldwide:
199 | [fortunly.com/apple-pay-statistics](https://fortunly.com/statistics/apple-pay-statistics/)
200 |
201 | To use `Apple Pay` _directly_ we would need
202 | to create an account with `Apple`
203 | and use their `SDK`:
204 | [developer.apple.com/apple-pay/implementation/](https://developer.apple.com/apple-pay/implementation/)
205 |
206 | `while` we don't expect _many_ of the people using our `App` to pay with `Apple Pay`
207 | (_and we certainly won't encourage them as `Apple` takes a **massive `30%` cut** ..._)
208 |
209 | ### `Google Pay` 🤖
210 |
211 | [`Google Pay`](https://pay.google.com/about/business/implementation/),
212 | originally called `Google Checkout`,
213 | then `Google Wallet`
214 | then rebranded to `Android Pay`
215 | and now back to `Google Pay/Wallet` (...🙄)
216 | is a payment service available
217 | to all `people` with a `Google` account -
218 | including everyone with an `Android` device -
219 | who have added a credit/debit card to their account.
220 | It's difficult to know exactly how many people
221 | have and _use_ `Google Pay`
222 | because `Google` does not make the info `public`
223 | and the _vast_ majority of `people` with an `Android`
224 | device either don't _have_ a Credit Card
225 | (think children and the
226 | [unbanked](https://en.wikipedia.org/wiki/Unbanked)) ...
227 | But the most recent estimates are
228 | **`200 million` people**
229 | in
230 | [**47 countries**](https://support.google.com/googlepay/answer/12429287?hl=en#zippy=%2Cpay-in-store).
231 | see: wikipedia.org/wiki/Google_Pay_(payment_method)
232 |
233 | To add `Google Pay`
234 | as a payment method
235 | you have to create an business account
236 | and use their **`SDK`**:
237 | [developers.google.com/pay/api](https://developers.google.com/pay/api)
238 | and integrate it in your App/Website.
239 |
240 | ### Credit/Debit Cards 💳
241 |
242 | Last but not least are **`credit/debit cards`**,
243 | by _far_ the most prevalent payment method
244 | both in the _real_ world and online.
245 |
246 | All the bran-specific payment providers
247 | such as `Apple Pay`, `Google Pay`, `Amazon Pay` etc.
248 | _assume_ you have a `credit/debit card`,
249 | so why _bother_ with the others?
250 | Simple: most friction and _perceived_ security.
251 | Many people are _reluctant_ to use their credit/debit card
252 | because they fear fraud or identity theft.
253 |
254 | These fears are _mostly_ resolved in 2022
255 | as the credit/debit card companies have
256 | sophisticated fraud detection/prevention systems.
257 | But we [@dwyl] still don't want to be _storing_
258 | any card details ourselves, we want a trusted
259 | [PCI DSS compliant](https://en.wikipedia.org/wiki/Payment_Card_Industry_Data_Security_Standard)
260 | payment processor to handle the parts we don't have time to be experts in
261 | so that we can focus on the UI/UX of our `App`.
262 |
263 |
264 |
265 | ## Do We Need a Merchant Account? 🛒
266 |
267 | On top of managing accounts with each of the payment providers,
268 | we would need to create our own merchant account
269 | so each one of these services can connect to it
270 | and process the transactions.
271 |
272 | Setting up and maintaining a Merchant Account
273 | is a heap of effort
274 | and can easily require
275 | a _dedicated_ person or _team_ of people
276 | just for running the checkout process in your App/company.
277 | This is where **payment platforms**
278 | like `Stripe` come into play.
279 |
280 |
281 |
282 | ## Payment Platforms 😍
283 |
284 | Payment platforms **_simplify_ the process of connecting
285 | to multiple third-parties**.
286 | It offers more than a payment service
287 | so that merchants only have to liaise with _one_ company
288 | rather than multiple ones.
289 |
290 | This has a great impact on how an application
291 | is *designed* and *implemented*,
292 | and allows to for a better management
293 | and
294 | [decoupling](https://en.wikipedia.org/wiki/Single-responsibility_principle)
295 | of responsibilities.
296 |
297 | Instead of our own API having to manage different providers,
298 | we can use a platform like `Stripe` to do the work for us.
299 | This is how the application should be laid out!
300 |
301 | 
302 |
303 | As you can see, it is much simpler!
304 | By offering a bundle of essential payment technologies,
305 | these companies are reducing the merchant's work
306 | of having to manage each of them separately.
307 | In addition to this, there are a number of other advantages,
308 | such as security, data monitoring and reporting.
309 |
310 | For example, `Stripe` is like having a multiple
311 | **payment processors**, **payment gateways** and **merchant account**
312 | bundled into one,
313 | along with a [myriad of other features](https://stripe.com/en-pt).
314 |
315 | ### `Stripe` 🚀
316 |
317 | `Stripe` is considered by many to be
318 | the [*de facto*](https://trends.builtwith.com/payment/Stripe)
319 | way of accepting credit cards
320 | and electronic payments on the web.
321 | Beyond collecting card payments
322 | it has a number of additional features,
323 | including:
324 | [smart retries](https://stripe.com/docs/billing/revenue-recovery/smart-retries),
325 | [automatic card updater](https://stripe.com/docs/saving-cards),
326 | [fraud tooling](https://stripe.com/en-gb-pt/radar),
327 | and other
328 | [add-ons](https://stripe.com/partners/directory).
329 |
330 | Until we starting to research this in-depth,
331 | we were considering using `Stripe`
332 | because we've used in it previous projects.
333 | But then we discovered ***`Paddle`***!
334 |
335 | ### `Paddle` 😮
336 |
337 | `Paddle` is a new class of payment processor
338 | that includes all additional services
339 | in their simple fee structure.
340 | Their slogan is:
341 | "_The better way to sell software_".
342 | Which immediately got our attention
343 | as that is what we are selling!
344 |
345 | While `Stripe` can be compared to a payment gateway
346 | that deals with multiple channels,
347 | `Paddle` offers similar features
348 | but acts a *reseller service* -
349 | **Merchant of Record (MoR)**.
350 |
351 | 
352 |
353 | A MoR is a term to describe the legal entity
354 | selling goods or services to an end customer:
355 | [paddle.com/blog/what-is-merchant-of-record](https://www.paddle.com/blog/what-is-merchant-of-record)
356 | It's who the end customer owes payment for their purchase,
357 | and it is who handles payments and tax liability for each transaction.
358 | This is great for *tax handling*,
359 | which is especially relevant in Europe:
360 | [outseta.com/posts/startup-payment-processing](https://www.outseta.com/posts/startup-payment-processing)
361 | and one of the reasons people
362 | consider `Paddle` in lieu of `Stripe`.
363 | `Stripe` is making strides in also having
364 | better tax compliance: [stripe.com/newsroom/news/taxjar](https://stripe.com/newsroom/news/taxjar)
365 | but it's not quite there,
366 | at the moment of writing.
367 |
368 |
369 | So businesses can choose to be their own merchant of record
370 | and setup an infrastructure and processes needed to manage
371 | payments with `Stripe` and deal with liabilities
372 | and tax handling themselves.
373 | *Or* they can use MoR service providers like `Paddle`
374 | who take the burden of all of payment processing
375 | and legal compliance away. Of course, these usually incur higher fees than `Stripe`.
376 |
377 | ## I'm confused ... Which one should I choose? 🤷♀️
378 |
379 | That's a great question that has come up before:
380 | [indiehackers.com/post/**stripe-vs-paddle**-89161b0d5c](https://www.indiehackers.com/post/stripe-vs-paddle-89161b0d5c)
381 | `Paddle` themselves have a good comparison:
382 | [paddle.com/**compare/stripe**](https://www.paddle.com/compare/stripe)
383 |
384 | Several others have reached the same conclusion, e.g:
385 | [splitbee.io/blog/why-we-moved-from-stripe-to-paddle](https://splitbee.io/blog/why-we-moved-from-stripe-to-paddle)
386 | and
387 | [reddit.com/r/SaaS/comments/q3kao9/paddle_vs_chargebee_vs_stripe_any_recommendations](https://www.reddit.com/r/SaaS/comments/q3kao9/paddle_vs_chargebee_vs_stripe_any_recommendations)
388 |
389 | It's still early days for `Paddle`
390 | whereas `Stripe` has several years of head-start:
391 | https://stackshare.io/stackups/paddle-vs-stripe
392 | At present very few companies use `Paddle`,
393 | but those who do are quite vocal about it:
394 |
395 | 
396 |
397 |
398 | Depending on the use-case or your choice,
399 | each product provides different pricing plans, fees and features
400 | and you should make this decision
401 | based on the requirements of your project
402 | and how much you are willing to spend.
403 |
404 | What's important here is you know
405 | *how online payments work*,
406 | what parties are involved
407 | and how you can **leverage these platforms**
408 | to make this process easier.
409 |
410 | Remember, we are dealing with **sensitive information**.
411 | Credit card info should be handled with *extreme caution*
412 | amd these platforms makes it easier for us to do just that.
413 |
414 | But implementation-wise,
415 | when designing and implementing your application,
416 | you should notice that
417 | the process is similar between providers.
418 | Your application will make use of their SDKs
419 | to integrate different channels
420 | and payment alternatives to process transactions.
421 |
422 | And guess what,
423 | we are going to be doing that in the next section!
424 |
425 | # How? 💻
426 |
427 | The demo/example has quite a few steps,
428 | so we split it out into it's own doc:
429 | [`example.md`](https://github.com/dwyl/learn-payment-processing/blob/main/example.md)
430 |
--------------------------------------------------------------------------------
/assets/css/app.css:
--------------------------------------------------------------------------------
1 | @import "tailwindcss/base";
2 | @import "tailwindcss/components";
3 | @import "tailwindcss/utilities";
4 |
5 | /* This file is for your main application CSS */
6 |
--------------------------------------------------------------------------------
/assets/js/app.js:
--------------------------------------------------------------------------------
1 | // If you want to use Phoenix channels, run `mix help phx.gen.channel`
2 | // to get started and then uncomment the line below.
3 | // import "./user_socket.js"
4 |
5 | // You can include dependencies in two ways.
6 | //
7 | // The simplest option is to put them in assets/vendor and
8 | // import them using relative paths:
9 | //
10 | // import "../vendor/some-package.js"
11 | //
12 | // Alternatively, you can `npm install some-package --prefix assets` and import
13 | // them using a path starting with the package name:
14 | //
15 | // import "some-package"
16 | //
17 |
18 | // Include phoenix_html to handle method=PUT/DELETE in forms and buttons.
19 | import "phoenix_html"
20 | // Establish Phoenix Socket and LiveView configuration.
21 | import {Socket} from "phoenix"
22 | import {LiveSocket} from "phoenix_live_view"
23 | import topbar from "../vendor/topbar"
24 |
25 | let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
26 | let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}})
27 |
28 | // Show progress bar on live navigation and form submits
29 | topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"})
30 | window.addEventListener("phx:page-loading-start", info => topbar.delayedShow(200))
31 | window.addEventListener("phx:page-loading-stop", info => topbar.hide())
32 |
33 | // connect if there are any LiveViews on the page
34 | liveSocket.connect()
35 |
36 | // expose liveSocket on window for web console debug logs and latency simulation:
37 | // >> liveSocket.enableDebug()
38 | // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session
39 | // >> liveSocket.disableLatencySim()
40 | window.liveSocket = liveSocket
41 |
42 |
--------------------------------------------------------------------------------
/assets/tailwind.config.js:
--------------------------------------------------------------------------------
1 | // See the Tailwind configuration guide for advanced usage
2 | // https://tailwindcss.com/docs/configuration
3 |
4 | const plugin = require("tailwindcss/plugin")
5 |
6 | module.exports = {
7 | content: [
8 | "./js/**/*.js",
9 | "../lib/*_web.ex",
10 | "../lib/*_web/**/*.*ex"
11 | ],
12 | theme: {
13 | extend: {
14 | colors: {
15 | brand: "#FD4F00",
16 | }
17 | },
18 | },
19 | plugins: [
20 | require("@tailwindcss/forms"),
21 | plugin(({addVariant}) => addVariant("phx-no-feedback", [".phx-no-feedback&", ".phx-no-feedback &"])),
22 | plugin(({addVariant}) => addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"])),
23 | plugin(({addVariant}) => addVariant("phx-submit-loading", [".phx-submit-loading&", ".phx-submit-loading &"])),
24 | plugin(({addVariant}) => addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"]))
25 | ]
26 | }
--------------------------------------------------------------------------------
/assets/vendor/topbar.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @license MIT
3 | * topbar 1.0.0, 2021-01-06
4 | * Modifications:
5 | * - add delayedShow(time) (2022-09-21)
6 | * https://buunguyen.github.io/topbar
7 | * Copyright (c) 2021 Buu Nguyen
8 | */
9 | (function (window, document) {
10 | "use strict";
11 |
12 | // https://gist.github.com/paulirish/1579671
13 | (function () {
14 | var lastTime = 0;
15 | var vendors = ["ms", "moz", "webkit", "o"];
16 | for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) {
17 | window.requestAnimationFrame =
18 | window[vendors[x] + "RequestAnimationFrame"];
19 | window.cancelAnimationFrame =
20 | window[vendors[x] + "CancelAnimationFrame"] ||
21 | window[vendors[x] + "CancelRequestAnimationFrame"];
22 | }
23 | if (!window.requestAnimationFrame)
24 | window.requestAnimationFrame = function (callback, element) {
25 | var currTime = new Date().getTime();
26 | var timeToCall = Math.max(0, 16 - (currTime - lastTime));
27 | var id = window.setTimeout(function () {
28 | callback(currTime + timeToCall);
29 | }, timeToCall);
30 | lastTime = currTime + timeToCall;
31 | return id;
32 | };
33 | if (!window.cancelAnimationFrame)
34 | window.cancelAnimationFrame = function (id) {
35 | clearTimeout(id);
36 | };
37 | })();
38 |
39 | var canvas,
40 | currentProgress,
41 | showing,
42 | progressTimerId = null,
43 | fadeTimerId = null,
44 | delayTimerId = null,
45 | addEvent = function (elem, type, handler) {
46 | if (elem.addEventListener) elem.addEventListener(type, handler, false);
47 | else if (elem.attachEvent) elem.attachEvent("on" + type, handler);
48 | else elem["on" + type] = handler;
49 | },
50 | options = {
51 | autoRun: true,
52 | barThickness: 3,
53 | barColors: {
54 | 0: "rgba(26, 188, 156, .9)",
55 | ".25": "rgba(52, 152, 219, .9)",
56 | ".50": "rgba(241, 196, 15, .9)",
57 | ".75": "rgba(230, 126, 34, .9)",
58 | "1.0": "rgba(211, 84, 0, .9)",
59 | },
60 | shadowBlur: 10,
61 | shadowColor: "rgba(0, 0, 0, .6)",
62 | className: null,
63 | },
64 | repaint = function () {
65 | canvas.width = window.innerWidth;
66 | canvas.height = options.barThickness * 5; // need space for shadow
67 |
68 | var ctx = canvas.getContext("2d");
69 | ctx.shadowBlur = options.shadowBlur;
70 | ctx.shadowColor = options.shadowColor;
71 |
72 | var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0);
73 | for (var stop in options.barColors)
74 | lineGradient.addColorStop(stop, options.barColors[stop]);
75 | ctx.lineWidth = options.barThickness;
76 | ctx.beginPath();
77 | ctx.moveTo(0, options.barThickness / 2);
78 | ctx.lineTo(
79 | Math.ceil(currentProgress * canvas.width),
80 | options.barThickness / 2
81 | );
82 | ctx.strokeStyle = lineGradient;
83 | ctx.stroke();
84 | },
85 | createCanvas = function () {
86 | canvas = document.createElement("canvas");
87 | var style = canvas.style;
88 | style.position = "fixed";
89 | style.top = style.left = style.right = style.margin = style.padding = 0;
90 | style.zIndex = 100001;
91 | style.display = "none";
92 | if (options.className) canvas.classList.add(options.className);
93 | document.body.appendChild(canvas);
94 | addEvent(window, "resize", repaint);
95 | },
96 | topbar = {
97 | config: function (opts) {
98 | for (var key in opts)
99 | if (options.hasOwnProperty(key)) options[key] = opts[key];
100 | },
101 | delayedShow: function(time) {
102 | if (showing) return;
103 | if (delayTimerId) return;
104 | delayTimerId = setTimeout(() => topbar.show(), time);
105 | },
106 | show: function () {
107 | if (showing) return;
108 | showing = true;
109 | if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId);
110 | if (!canvas) createCanvas();
111 | canvas.style.opacity = 1;
112 | canvas.style.display = "block";
113 | topbar.progress(0);
114 | if (options.autoRun) {
115 | (function loop() {
116 | progressTimerId = window.requestAnimationFrame(loop);
117 | topbar.progress(
118 | "+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2)
119 | );
120 | })();
121 | }
122 | },
123 | progress: function (to) {
124 | if (typeof to === "undefined") return currentProgress;
125 | if (typeof to === "string") {
126 | to =
127 | (to.indexOf("+") >= 0 || to.indexOf("-") >= 0
128 | ? currentProgress
129 | : 0) + parseFloat(to);
130 | }
131 | currentProgress = to > 1 ? 1 : to;
132 | repaint();
133 | return currentProgress;
134 | },
135 | hide: function () {
136 | clearTimeout(delayTimerId);
137 | delayTimerId = null;
138 | if (!showing) return;
139 | showing = false;
140 | if (progressTimerId != null) {
141 | window.cancelAnimationFrame(progressTimerId);
142 | progressTimerId = null;
143 | }
144 | (function loop() {
145 | if (topbar.progress("+.1") >= 1) {
146 | canvas.style.opacity -= 0.05;
147 | if (canvas.style.opacity <= 0.05) {
148 | canvas.style.display = "none";
149 | fadeTimerId = null;
150 | return;
151 | }
152 | }
153 | fadeTimerId = window.requestAnimationFrame(loop);
154 | })();
155 | },
156 | };
157 |
158 | if (typeof module === "object" && typeof module.exports === "object") {
159 | module.exports = topbar;
160 | } else if (typeof define === "function" && define.amd) {
161 | define(function () {
162 | return topbar;
163 | });
164 | } else {
165 | this.topbar = topbar;
166 | }
167 | }.call(this, window, document));
168 |
--------------------------------------------------------------------------------
/config/config.exs:
--------------------------------------------------------------------------------
1 | # This file is responsible for configuring your application
2 | # and its dependencies with the aid of the Config module.
3 | #
4 | # This configuration file is loaded before any dependency and
5 | # is restricted to this project.
6 |
7 | # General application configuration
8 | import Config
9 |
10 | # Configures the endpoint
11 | config :app, AppWeb.Endpoint,
12 | url: [host: "localhost"],
13 | render_errors: [
14 | formats: [html: AppWeb.ErrorHTML, json: AppWeb.ErrorJSON],
15 | layout: false
16 | ],
17 | pubsub_server: App.PubSub,
18 | live_view: [signing_salt: "u48tsthN"]
19 |
20 | # Configure esbuild (the version is required)
21 | config :esbuild,
22 | version: "0.14.41",
23 | default: [
24 | args:
25 | ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
26 | cd: Path.expand("../assets", __DIR__),
27 | env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
28 | ]
29 |
30 | # Configure tailwind (the version is required)
31 | config :tailwind,
32 | version: "3.1.8",
33 | default: [
34 | args: ~w(
35 | --config=tailwind.config.js
36 | --input=css/app.css
37 | --output=../priv/static/assets/app.css
38 | ),
39 | cd: Path.expand("../assets", __DIR__)
40 | ]
41 |
42 | # Configures Elixir's Logger
43 | config :logger, :console,
44 | format: "$time $metadata[$level] $message\n",
45 | metadata: [:request_id]
46 |
47 | # Use Jason for JSON parsing in Phoenix
48 | config :phoenix, :json_library, Jason
49 |
50 | # Import environment specific config. This must remain at the bottom
51 | # of this file so it overrides the configuration defined above.
52 | import_config "#{config_env()}.exs"
53 |
54 | # Stripe
55 | config :stripity_stripe, api_key: System.get_env("STRIPE_API_KEY")
56 |
--------------------------------------------------------------------------------
/config/dev.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | # For development, we disable any cache and enable
4 | # debugging and code reloading.
5 | #
6 | # The watchers configuration can be used to run external
7 | # watchers to your application. For example, we use it
8 | # with esbuild to bundle .js and .css sources.
9 | config :app, AppWeb.Endpoint,
10 | # Binding to loopback ipv4 address prevents access from other machines.
11 | # Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
12 | http: [ip: {127, 0, 0, 1}, port: 4000],
13 | check_origin: false,
14 | code_reloader: true,
15 | debug_errors: true,
16 | secret_key_base:
17 | "bv1heeBXJGvlEsc6SI66xUox+004UlT+aRAH+UlGgMxGuGMXCbEK32pVx0QNlJxN",
18 | watchers: [
19 | esbuild:
20 | {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]},
21 | tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]}
22 | ]
23 |
24 | # ## SSL Support
25 | #
26 | # In order to use HTTPS in development, a self-signed
27 | # certificate can be generated by running the following
28 | # Mix task:
29 | #
30 | # mix phx.gen.cert
31 | #
32 | # Run `mix help phx.gen.cert` for more information.
33 | #
34 | # The `http:` config above can be replaced with:
35 | #
36 | # https: [
37 | # port: 4001,
38 | # cipher_suite: :strong,
39 | # keyfile: "priv/cert/selfsigned_key.pem",
40 | # certfile: "priv/cert/selfsigned.pem"
41 | # ],
42 | #
43 | # If desired, both `http:` and `https:` keys can be
44 | # configured to run both http and https servers on
45 | # different ports.
46 |
47 | # Watch static and templates for browser reloading.
48 | config :app, AppWeb.Endpoint,
49 | live_reload: [
50 | patterns: [
51 | ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$",
52 | ~r"priv/gettext/.*(po)$",
53 | ~r"lib/app_web/(live|views)/.*(ex)$",
54 | ~r"lib/app_web/templates/.*(eex)$"
55 | ]
56 | ]
57 |
58 | # Enable dev routes for dashboard and mailbox
59 | config :app, dev_routes: true
60 |
61 | # Do not include metadata nor timestamps in development logs
62 | config :logger, :console, format: "[$level] $message\n"
63 |
64 | # Set a higher stacktrace during development. Avoid configuring such
65 | # in production as building large stacktraces may be expensive.
66 | config :phoenix, :stacktrace_depth, 20
67 |
68 | # Initialize plugs at runtime for faster development compilation
69 | config :phoenix, :plug_init_mode, :runtime
70 |
--------------------------------------------------------------------------------
/config/prod.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | # For production, don't forget to configure the url host
4 | # to something meaningful, Phoenix uses this information
5 | # when generating URLs.
6 |
7 | # Note we also include the path to a cache manifest
8 | # containing the digested version of static files. This
9 | # manifest is generated by the `mix phx.digest` task,
10 | # which you should run after static files are built and
11 | # before starting your production server.
12 | config :app, AppWeb.Endpoint,
13 | cache_static_manifest: "priv/static/cache_manifest.json"
14 |
15 | # Do not print debug messages in production
16 | config :logger, level: :info
17 |
18 | # Runtime production configuration, including reading
19 | # of environment variables, is done on config/runtime.exs.
20 |
--------------------------------------------------------------------------------
/config/runtime.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | # config/runtime.exs is executed for all environments, including
4 | # during releases. It is executed after compilation and before the
5 | # system starts, so it is typically used to load production configuration
6 | # and secrets from environment variables or elsewhere. Do not define
7 | # any compile-time configuration in here, as it won't be applied.
8 | # The block below contains prod specific runtime configuration.
9 |
10 | # ## Using releases
11 | #
12 | # If you use `mix release`, you need to explicitly enable the server
13 | # by passing the PHX_SERVER=true when you start it:
14 | #
15 | # PHX_SERVER=true bin/app start
16 | #
17 | # Alternatively, you can use `mix phx.gen.release` to generate a `bin/server`
18 | # script that automatically sets the env var above.
19 | if System.get_env("PHX_SERVER") do
20 | config :app, AppWeb.Endpoint, server: true
21 | end
22 |
23 | if config_env() == :prod do
24 | # The secret key base is used to sign/encrypt cookies and other secrets.
25 | # A default value is used in config/dev.exs and config/test.exs but you
26 | # want to use a different value for prod and you most likely don't want
27 | # to check this value into version control, so we use an environment
28 | # variable instead.
29 | secret_key_base =
30 | System.get_env("SECRET_KEY_BASE") ||
31 | raise """
32 | environment variable SECRET_KEY_BASE is missing.
33 | You can generate one by calling: mix phx.gen.secret
34 | """
35 |
36 | host = System.get_env("PHX_HOST") || "example.com"
37 | port = String.to_integer(System.get_env("PORT") || "4000")
38 |
39 | config :app, AppWeb.Endpoint,
40 | url: [host: host, port: 443, scheme: "https"],
41 | http: [
42 | # Enable IPv6 and bind on all interfaces.
43 | # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
44 | # See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html
45 | # for details about using IPv6 vs IPv4 and loopback vs public addresses.
46 | ip: {0, 0, 0, 0, 0, 0, 0, 0},
47 | port: port
48 | ],
49 | secret_key_base: secret_key_base
50 |
51 | # ## SSL Support
52 | #
53 | # To get SSL working, you will need to add the `https` key
54 | # to your endpoint configuration:
55 | #
56 | # config :app, AppWeb.Endpoint,
57 | # https: [
58 | # ...,
59 | # port: 443,
60 | # cipher_suite: :strong,
61 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
62 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH")
63 | # ]
64 | #
65 | # The `cipher_suite` is set to `:strong` to support only the
66 | # latest and more secure SSL ciphers. This means old browsers
67 | # and clients may not be supported. You can set it to
68 | # `:compatible` for wider support.
69 | #
70 | # `:keyfile` and `:certfile` expect an absolute path to the key
71 | # and cert in disk or a relative path inside priv, for example
72 | # "priv/ssl/server.key". For all supported SSL configuration
73 | # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1
74 | #
75 | # We also recommend setting `force_ssl` in your endpoint, ensuring
76 | # no data is ever sent via http, always redirecting to https:
77 | #
78 | # config :app, AppWeb.Endpoint,
79 | # force_ssl: [hsts: true]
80 | #
81 | # Check `Plug.SSL` for all available options in `force_ssl`.
82 |
83 | # ## Configuring the mailer
84 | #
85 | # In production you need to configure the mailer to use a different adapter.
86 | # Also, you may need to configure the Swoosh API client of your choice if you
87 | # are not using SMTP. Here is an example of the configuration:
88 | #
89 | # config :app, App.Mailer,
90 | # adapter: Swoosh.Adapters.Mailgun,
91 | # api_key: System.get_env("MAILGUN_API_KEY"),
92 | # domain: System.get_env("MAILGUN_DOMAIN")
93 | #
94 | # For this example you need include a HTTP client required by Swoosh API client.
95 | # Swoosh supports Hackney and Finch out of the box:
96 | #
97 | # config :swoosh, :api_client, Swoosh.ApiClient.Hackney
98 | #
99 | # See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details.
100 | end
101 |
--------------------------------------------------------------------------------
/config/test.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | # We don't run a server during test. If one is required,
4 | # you can enable the server option below.
5 | config :app, AppWeb.Endpoint,
6 | http: [ip: {127, 0, 0, 1}, port: 4002],
7 | secret_key_base:
8 | "lrn6ftfPZu7KSTQ54v4foc/gq2FgYfB9/ckQw+Hhi/NNcUM7nf/mUlTqZWJOXAoK",
9 | server: false
10 |
11 | # Print only warnings and errors during test
12 | config :logger, level: :warning
13 |
14 | # Initialize plugs at runtime for faster test compilation
15 | config :phoenix, :plug_init_mode, :runtime
16 |
17 | # Don't worry, this AUTH_API_KEY is NOT valid.
18 | # It's just for ensuring it passes on GitHub CI
19 | # see: github.com/dwyl/mvp/issues/258
20 | config :auth_plug,
21 | api_key:
22 | "2PzB7PPnpuLsbWmWtXpGyI+kfSQSQ1zUW2Atz/+8PdZuSEJzHgzGnJWV35nTKRwx/authdemo.fly.dev"
23 |
--------------------------------------------------------------------------------
/coveralls.json:
--------------------------------------------------------------------------------
1 | {
2 | "coverage_options": {
3 | "minimum_coverage": 100
4 | },
5 | "skip_files": [
6 | "test/",
7 | "lib/app/application.ex",
8 | "lib/app/release.ex",
9 | "lib/app_web.ex",
10 | "lib/app_web/gettext.ex",
11 | "lib/app_web/components/",
12 | "lib/app_web/telemetry.ex"
13 | ]
14 | }
--------------------------------------------------------------------------------
/example.md:
--------------------------------------------------------------------------------
1 |
7 |
8 | In this section,
9 | we are going to implement an example application
10 | using `Stripe` with `Elixir`.
11 |
12 |
13 | > If you are new to `Elixir`,
14 | we recommend reading
15 | [`learn-elixir`](https://github.com/dwyl/learn-elixir)
16 | and [`learn-phoenix-framework`](https://github.com/dwyl/learn-phoenix-framework)
17 | ***`before`*** you start this walk-through,
18 | as focus on payment processing
19 | and not on the basics of a `Phoenix` project.
20 |
21 | > **Note**: The reason we went with `Stripe`
22 | and not any other alternative like `Paddle`
23 | is because `Stripe` allows us to create a Developer account
24 | without having to fill business-related information,
25 | KYC and a company website.
26 | > We are going to see how far we can get with `Stripe`
27 | and document the process.
28 | If we decide we _need_ the features/simplicity
29 | offered by `Paddle` we will switch and document that too.
30 |
31 | ## Pre-requisites 📝
32 |
33 | For this tutorial,
34 | you will need to create a `Stripe` account.
35 | Visit:
36 | [stripe.com/register](https://dashboard.stripe.com/register)
37 | to create an account.
38 |
39 | 
40 |
41 | After inputting the required `Email`, `Full name` and `Password`
42 | you will receive an email requiring you to **`Verify your email`**:
43 |
44 | 
45 |
46 | Once you click the button,
47 | you will see a screen similar to this:
48 |
49 | 
50 |
51 | Click on **`skip for now`** to proceed to the main dashboard:
52 |
53 | 
54 |
55 | If you type "**API**" in the search box
56 | and choose **`Developers > API Keys`** ...
57 |
58 | 
59 |
60 | Or navigate directly to it:
61 | [dashboard.stripe.com/test/**apikeys**](https://dashboard.stripe.com/test/apikeys)
62 |
63 | 
64 |
65 | > **Note**: don't worry, these `API Keys` aren't valid.
66 | > This is just for illustration purposes.
67 |
68 | The **_Test_ API keys** will be used later.
69 | Save them and don't share them with anyone.
70 | We are going to be using these
71 | as
72 | [environment variables](https://github.com/dwyl/learn-environment-variables).
73 |
74 |
75 | ## 1. Create a Phoenix project
76 |
77 | Let's start by creating a Phoenix project.
78 | Run:
79 | ```sh
80 | mix phx.new app --no-ecto --no-mailer --no-dashboard --no-gettext
81 | ```
82 | and when prompted, type `y` to accept downloading the dependencies.
83 |
84 | After this, if you run
85 | `mix phx.server`
86 | and visit `localhost:4000`,
87 | you will be able to see the default landing page.
88 |
89 | 
90 |
91 | We want the `person` using the `App` to be able to log in.
92 | We will check if they have *paid* or not
93 | for using the `App`.
94 | If they haven't, they are redirected to a `buy` page.
95 | If they have, they will have access to it!
96 |
97 | It's a simple `App`, for sure.
98 | But it's still important
99 | to know ***how*** to properly implement it.
100 |
101 | ## 2. Add `auth_plug` for user login
102 |
103 | We will be using
104 | [`auth_plug`](https://github.com/dwyl/auth_plug)
105 | so `people` are able to login.
106 |
107 | Let's install it.
108 | Add the following to the `deps` section in `mix.exs`:
109 |
110 | ```elixir
111 | def deps do
112 | [
113 | {:auth_plug, "~> 1.5.1"},
114 | ]
115 | end
116 | ```
117 |
118 | Once you've saved the file,
119 | run:
120 | ```
121 | mix deps.get
122 | ```
123 |
124 | Follow the
125 | [instructions](https://github.com/dwyl/auth_plug#2-get-your-auth_api_key-)
126 | and get your `AUTH_API_KEY`.
127 |
128 | Next create a file called: `.env`
129 | and paste the `AUTH_API_KEY`, e.g:
130 |
131 | ```sh
132 | export AUTH_API_KEY=YOURKEYHERE
133 | ```
134 |
135 | > **Note**: **never `commit` the `.env` file** to `GitHub`
136 | as it contains sensitive information you don't want others to see.
137 |
138 | Open:
139 | `lib/app_web/router.ex`
140 | and add the following lines of code:
141 |
142 | ```elixir
143 | pipeline :auth, do: plug(AuthPlug)
144 |
145 | scope "/dashboard", AppWeb do
146 | pipe_through :browser
147 | pipe_through :auth
148 |
149 | get "/", AppController, :home
150 | end
151 | ```
152 |
153 | The `/dashboard` protected endpoint will only be accessible
154 | for logged in users because we are using the
155 | `:auth` pipeline.
156 |
157 | We are using `AppController`,
158 | which is not yet created.
159 | Create the following 4 files:
160 |
161 | 1. `lib/app_web/controllers/app_controller.ex`
162 | 2. `lib/app_web/controllers/app_html.ex`
163 | 3. `lib/app_web/controllers/app_html/app.html.heex`
164 | 4. `test/app_web/controllers/auth_controller_test.exs`
165 |
166 |
167 | In `app_controller.ex`, add the following code:
168 |
169 | ```elixir
170 | defmodule AppWeb.AppController do
171 | use AppWeb, :controller
172 |
173 | def home(conn, _params) do
174 | render(conn, :app, layout: false)
175 | end
176 | end
177 | ```
178 |
179 | In `app_html.ex`,
180 | add the following code:
181 |
182 | ```elixir
183 | defmodule AppWeb.AppHTML do
184 | use AppWeb, :html
185 |
186 | embed_templates "app_html/*"
187 | end
188 | ```
189 |
190 | In `app_html/app.html.heex`:
191 |
192 | ```html
193 |
194 | logged in
195 |
196 | ```
197 |
198 | In `test/app_web/controllers/auth_controller_test.exs`:
199 |
200 | ```elixir
201 | defmodule AppWeb.AuthControllerTest do
202 | use AppWeb.ConnCase, async: true
203 |
204 | test "Logout link displayed when loggedin", %{conn: conn} do
205 | data = %{
206 | username: "test_username",
207 | email: "test@email.com",
208 | givenName: "John Doe",
209 | picture: "this",
210 | auth_provider: "GitHub",
211 | id: 1
212 | }
213 |
214 | jwt = AuthPlug.Token.generate_jwt!(data)
215 |
216 | conn = get(conn, "/?jwt=#{jwt}")
217 | assert html_response(conn, 200) =~ "logout"
218 | end
219 |
220 | test "get /logout with valid JWT", %{conn: conn} do
221 | data = %{
222 | email: "al@dwyl.com",
223 | givenName: "Al",
224 | picture: "this",
225 | auth_provider: "GitHub",
226 | id: 1
227 | }
228 |
229 | jwt = AuthPlug.Token.generate_jwt!(data)
230 |
231 | conn =
232 | conn
233 | |> put_req_header("authorization", jwt)
234 | |> get("/logout")
235 |
236 | assert "/" = redirected_to(conn, 302)
237 | end
238 |
239 | test "test login link redirect to authdemo.fly.dev", %{conn: conn} do
240 | conn = get(conn, "/login")
241 | assert redirected_to(conn, 302) =~ "authdemo.fly.dev"
242 | end
243 | end
244 | ```
245 |
246 |
247 | If you run:
248 | ```sh
249 | source .env
250 | ```
251 |
252 | and restart your server with:
253 | `mix phx.server`
254 | and access `/dashboard` directly,
255 | you will be redirected to a page
256 | where you can SSO using `Google` or `Github`.
257 |
258 | 
259 |
260 | After logging in,
261 | the user has access to the URL!
262 |
263 |
264 |
265 | Congratulations, you just added basic authenticated to our application!
266 | However, that's not quite what we want.
267 | We want the user to be logged in
268 | and *also* being a paying costumer so they have access to `/dashboard`.
269 |
270 | We can make it prettier, though.
271 | Let's add two endpoints:
272 | - one to login (`/login`)
273 | - one to logout (`/logout`)
274 |
275 | From the landing page,
276 | we are going to be adding a button that will log the user in,
277 | and redirect to the `/login` URL.
278 | After logging in, he will be redirected back to the home page.
279 | We will need to conditionally render the home page (`/`)
280 | according to the user being logged in or not.
281 |
282 | For this, we are going to be using
283 | an [`optional auth pipeline`](https://github.com/dwyl/auth_plug#optional-auth).
284 | It will add allow us show custom actions on people who are authenticated or not.
285 | This pipeline will add the logged in to the `conn.assigns`.
286 |
287 | Head on to `lib/app_web/router.ex`
288 | and add/change the following piece of code.
289 |
290 | ```elixir
291 | pipeline :authoptional, do: plug(AuthPlugOptional, %{})
292 |
293 | scope "/", AppWeb do
294 | pipe_through :browser
295 | pipe_through :authoptional
296 |
297 | get "/", PageController, :home
298 | get "/login", AuthController, :login
299 | get "/logout", AuthController, :logout
300 | end
301 | ```
302 |
303 | We are now using a new controller `AuthController`.
304 | Let's create it.
305 | Inside `lib/app_web/controllers`,
306 | create a new file called `auth_controller.ex`
307 | and paste the following.
308 |
309 | ```elixir
310 | defmodule AppWeb.AuthController do
311 | use AppWeb, :controller
312 |
313 | def login(conn, _params) do
314 | redirect(conn, external: AuthPlug.get_auth_url(conn, ~p"/"))
315 | end
316 |
317 | def logout(conn, _params) do
318 | conn
319 | |> AuthPlug.logout()
320 | |> put_status(302)
321 | |> redirect(to: ~p"/")
322 | end
323 | end
324 | ```
325 |
326 | We just now need to change the view
327 | to redirect to these URLs (`login` and `logout`).
328 | Head over to `lib/app_web/controllers/page_html/home.html.heex`,
329 | locate the line.
330 |
331 | ```html
332 |
333 | ```
334 |
335 | From this line to the end of the file,
336 | change the contents with the next lines:
337 |
338 | ```html
339 |
354 | An example integrating
355 | Stripe
356 | with
357 | Phoenix
358 |
359 |
360 | This is a small project showcasing how to integrate Stripe in a Phoenix project.
361 | The workflow is simple: a logged in user has to pay to have access to
/dashboard
362 |
363 |
364 | Otherwise, they are redirected to a buying page to purchase so they can access it.
365 |
366 |
367 | <%= if Map.has_key?(@conn.assigns, :person) do %>
368 |
416 | ```
417 |
418 | And you should be done!
419 | In our main page we are checking if any user is logged in or not.
420 | If there's a user authenticated,
421 | we show his username and a button in which he can press
422 | to purchase our sweet dashboard.
423 |
424 | On the other hand, if there isn't any user authenticated,
425 | a `login` button is shown.
426 |
427 | The app should look like the following.
428 |
429 | 
430 |
431 | Now that we got that out of the way,
432 | we can now *focus on integrating `Stripe`*
433 | in our Phoenix application.
434 |
435 | This is going to be fun, let's do this! 👏
436 |
437 | ## 3. Stripe integration
438 |
439 | Let's start by installing the package
440 | that will allows us to communicate with **Stripe**.
441 | We are going to be using
442 | [`stripity-stripe`](https://github.com/beam-community/stripity-stripe).
443 | This library will allow us to easily integrate Stripe in our Phoenix application.
444 |
445 | The library is maintained by the `BEAM Community`,
446 | has 150+ contributors and is considered stable/reliable:
447 |
448 | 
449 |
450 |
451 | Go to your `mix.exs` file
452 | and add this inside your dependency section.
453 |
454 | ```elixir
455 | {:stripity_stripe, "~> 2.17"}
456 | ```
457 |
458 | and run `mix deps.get` to fetch this new dependency.
459 | Following [their documentation](https://github.com/beam-community/stripity-stripe#configuration),
460 | we need to add the next configuration inside `config.ex`.
461 |
462 | ```elixir
463 | config :stripity_stripe, api_key: System.get_env("STRIPE_API_KEY")
464 | ```
465 |
466 | As you can see, we are using an environment variable
467 | to serve the `STRIKE_API_KEY` so the library can use it to make requests.
468 | We need to add these keys to our `.env` file.
469 | To check your own API keys,
470 | go to https://dashboard.stripe.com/test/apikey
471 |
472 |
473 |
474 | and paste the keys in your `.env` file.
475 |
476 | ```
477 | export STRIPE_API_KEY= secret key
478 | export STRIPE_PUBLIC= publishable key
479 | ```
480 |
481 | After inputting these keys,
482 | you can stop the server and,
483 | in the same terminal session, run `source .env`,
484 | so you can load these environment variables
485 | and so they are available when you run `mix phx.server` again.
486 |
487 | Awesome job! 🎉
488 | We can now start using it!
489 |
490 | ### 3.1 Creating a **Stripe Checkout Session**
491 |
492 | To make this tutorial simple,
493 | we are going to be using a [`Stripe Checkout`](https://stripe.com/docs/payments/checkout).
494 | This makes it easy to make a payment
495 | because the user is redirected to a page hosted by `Stripe`
496 | with information about the product being purchased.
497 | Therefore, we don't need to create the page ourselves.
498 | We *could* but this is quicker 😉.
499 |
500 | With this in mind,
501 | we want to create a [`checkout session`](https://stripe.com/docs/api/checkout/sessions).
502 | The user will either be successful with his payment
503 | or fail.
504 | We will be building a page for both.
505 |
506 | Let's implement this.
507 |
508 | Head on to to `lib/app_web/router.ex`
509 | and add the following scope.
510 |
511 | ```elixir
512 | scope "/purchase", AppWeb do
513 | pipe_through :browser
514 | pipe_through :auth
515 |
516 | resources "/checkout-session", CheckoutSessionController, only: [:create]
517 |
518 | get "/success", PageController, :success
519 | get "/cancel", PageController, :cancel
520 | end
521 | ```
522 |
523 | We want **only authenticated users** to go to
524 | `/purchase/checkout-session` to purchase our product.
525 | When they visit this URL, they will be redirected to our `Stripe Checkout` page,
526 | with information about our product.
527 | They payment will either *succeed*
528 | (users will be redirected to `/purchase/success`)
529 | or *fail*
530 | (users will be redirected to `/purchase/cancel`).
531 |
532 | While the `success` and `cancel` pages
533 | are being handled by the already existent `PageController`,
534 | redirecting the users to the `Stripe Checkout` page
535 | is being handled by `CheckoutSessionController`.
536 | We need to create it!
537 |
538 | Inside `lib/app_web/controllers/`,
539 | create a file called `checkout_session_controller.ex`
540 | and paste the code:
541 |
542 | ```elixir
543 | defmodule AppWeb.CheckoutSessionController do
544 | use AppWeb, :controller
545 |
546 | def create(conn, _params) do
547 | url = AppWeb.Endpoint.url()
548 |
549 | params = %{
550 | line_items: [
551 | %{
552 | price: System.get_env("PRODUCT_PRICE_ID"),
553 | quantity: 1
554 | }
555 | ],
556 | mode: "payment",
557 | # https://stripe.com/docs/payments/checkout/custom-success-page
558 | success_url: url <> ~p"/purchase/success?session_id={CHECKOUT_SESSION_ID}",
559 | cancel_url: url <> ~p"/purchase/cancel?session_id={CHECKOUT_SESSION_ID}",
560 | # user_email: conn.assigns.person.email,
561 | automatic_tax: %{enabled: true}
562 | }
563 |
564 | {:ok, session} = Stripe.Checkout.Session.create(params)
565 |
566 | conn
567 | |> put_status(303)
568 | |> redirect(external: session.url)
569 | end
570 | end
571 | ```
572 |
573 | Let's analyze the code.
574 |
575 | We are using `Stripe.Checkout.Session.create/1`
576 | to create a [`Session`](https://stripe.com/docs/api/checkout/sessions)
577 | in Stripe.
578 | We need to pass a few required parameters.
579 | We are following [their API specification](https://stripe.com/docs/api/checkout/sessions/object),
580 | so you can always check their documentation if you're lost
581 | on what fields you need to pass.
582 | - the `**mode**` field pertains to the mode of the checkout session.
583 | We can setup subscription or one-time payments.
584 | We are doing the latter.
585 | - the `**success_url**` and `**cancel_url**` refer to the redirection URLs
586 | after the user either completes or cancels the checkout session.
587 | We are adding a `{CHECKOUT_SESSION_ID}` template in the URL.
588 | This tells Stripe to pass the `Checkout Session ID` to the client-side.
589 | [This will allow us to customize the order information
590 | upon completion or cancellation.](https://stripe.com/docs/payments/checkout/custom-success-page)
591 | - we set `**automatic_tax**` to be enabled so it makes tax conversions automatically.
592 | - the `**line_items**` array refer to the list of items the customer is purchasing.
593 | We are passing the item `id` and the `quantity`.
594 | - you can optionally pass a `customer_email`,
595 | so the field is already defined within the checkout session from the get-go.
596 |
597 | We are then creating a session
598 | and redirecting the user to the `Checkout` page.
599 |
600 | As you might have noticed,
601 | we are using an environment variable `PRODUCT_PRICE_ID`
602 | to set `price` field of the product.
603 |
604 | We *haven't created this yet*.
605 | So let's do it!
606 |
607 | ### 3.2 Creating a `Product` to sell
608 |
609 | As it stands, we have no products for customers to purchase.
610 | Let's create one! 😎
611 |
612 | Go to https://dashboard.stripe.com/test/products/create
613 | and fill up the information about your product.
614 |
615 |
616 |
617 |
618 | > This should be all you need to do.
619 | However, sometimes you may experience an error
620 | while creating a `checkout session`
621 | when doing the request in Phoenix.
622 | >
623 | > The error is probably
624 | `Stripe Tax has not been activated on your account.
625 | Please visit https://dashboard.stripe.com/settings/tax/activate to get started.`
626 | Don't be alarmed.
627 | If you follow the link, enable `Stripe Tax`
628 | and just create a tax rate on whatever country you want,
629 | You can check your tax rates in
630 | https://dashboard.stripe.com/test/tax-rates.
631 | >
632 | >
633 |
634 | After creating your product,
635 | you will be redirected to the page of the product created.
636 | You can always check the products you created
637 | in https://dashboard.stripe.com/test/products.
638 |
639 | The page of the product created should look like this.
640 |
641 |
642 |
643 | We are going to be using the `API ID`
644 | in the Pricing section.
645 | Copy it and paste it in your `.env` file.
646 |
647 | ```
648 | export PRODUCT_PRICE_ID= price id
649 | ```
650 |
651 | In the same terminal session, kill the server,
652 | run `source .env` and you should be good to go!
653 |
654 | ### 3.3 Success and failure after `Checkout Session`
655 |
656 | As we've stated before,
657 | the users are redirected to a `success` or `cancel` page
658 | depending on the outcome of the `Checkout Session`.
659 |
660 | Since we defined these endpoints
661 | be controlled by `PageController`,
662 | we need to add these handlers.
663 |
664 | Go to `lib/app_web/controllers/page_controller.ex`
665 | and add the following piece of code:
666 |
667 | ```elixir
668 | def success(conn, %{"session_id" => session_id}) do
669 | case Stripe.Checkout.Session.retrieve(session_id) do
670 | {:ok, _session} ->
671 | render(conn, :success, layout: false)
672 |
673 | {:error, _error} ->
674 | conn
675 | |> put_status(303)
676 | |> redirect(to: ~p"/")
677 | end
678 | end
679 |
680 | def success(conn, _params) do
681 | conn
682 | |> put_status(303)
683 | |> redirect(to: ~p"/")
684 | end
685 |
686 | def cancel(conn, %{"session_id" => session_id}) do
687 |
688 | case Stripe.Checkout.Session.retrieve(session_id) do
689 | {:ok, _session} ->
690 | render(conn, :cancel, layout: false)
691 |
692 | {:error, _error} ->
693 | conn
694 | |> put_status(303)
695 | |> redirect(to: ~p"/")
696 | end
697 | end
698 |
699 | def cancel(conn, _params) do
700 | conn
701 | |> put_status(303)
702 | |> redirect(to: ~p"/")
703 | end
704 | ```
705 |
706 | Let's break it down.
707 |
708 | When creating a session,
709 | we are requesting Stripe to redirect the user
710 | *back* to us with a query parameter `session_id`
711 | with the session ID.
712 |
713 | This `session_id` will allow us to
714 | conditionally render these pages according to the outcome
715 | of the process.
716 |
717 | Both `success` and `cancel` workflows have the same workflow.
718 |
719 | When the customer successfully pays for the product,
720 | we check if the `session_id` is valid
721 | by retrieving it from `Stripe`.
722 |
723 | ```elixir
724 | Stripe.Checkout.Session.retrieve(session_id)
725 | ```
726 |
727 | If it is successful, we render a page confirming the payment.
728 | If it is not, we simply redirect the user to the homepage.
729 | If the user tries to directly access `/purchase/success`,
730 | he is redirected to the homepage as well.
731 |
732 | The same procedure happens in the `cancel` scenario.
733 |
734 | We now need to create these pages!
735 | Inside `lib/app_web/controllers/page_html`,
736 | create two files.
737 | Create `success.html.heex`
738 | and use this code:
739 |
740 | ```html
741 |
742 |
743 |
748 |
749 |
Payment Done!
750 |
Thank you for completing your secure online payment.
763 | ```
764 |
765 | In this page, we are thanking the user
766 | for completing the payment
767 | and giving him the option to access
768 | the ever so glorious ✨**dashboard**✨.
769 |
770 | In the same directory,
771 | create the `cancel.html.heex` file:
772 |
773 | ```elixir
774 |
775 |
776 |
777 |
778 | Oh no...
779 |
780 |
looks like something went wrong.
781 |
Perhaps you cancelled your order? Internet went down? We can help you get back on track!
790 |
791 | ```
792 |
793 | In this page,
794 | we state that something went wrong
795 | and show a button to return to the homepage.
796 |
797 | ### 3.4 Making our "`dashboard`" cool
798 |
799 | We don't really have a `dashboard` to show.
800 | In fact, we don't need to, it's out of the scope of this tutorial.
801 | So let's just show a Nyan Cat! 🐈
802 |
803 | Inside `lib/app_web/controllers/app_html/app.html.heex`,
804 | change the code to the following:
805 |
806 | ```elixir
807 |
827 | ```
828 |
829 | All that's left is for the `Purchase` button
830 | in our homepage to redirect the user
831 | to the `/purchase/checkout-session`
832 | for the customer to pay for the product.
833 |
834 | Inside `lib/app_web/controllers/page_html/home.html.heex`,
835 | locate the line:
836 |
837 | ```html
838 |
839 | Purchase
840 |
841 | ```
842 |
843 | And replace it with:
844 |
845 | ```html
846 | <.link
847 | href={~p"/purchase/checkout-session"}
848 | method="post"
849 | class="relative px-5 py-2.5 transition-all ease-in duration-75 bg-white dark:bg-gray-900 rounded-md group-hover:bg-opacity-0"
850 | >
851 | Purchase
852 |
853 | ```
854 |
855 | And you should be done! 🎉
856 |
857 | Let's see what we've done so far.
858 | Run `mix phx.server`
859 | (make sure you loaded the env variables
860 | with `source .env`)
861 | and visit `localhost:4000`.
862 | You should see the following! 👏
863 |
864 | 
865 |
866 | > You can complete the payment
867 | using test data with the credit card.
868 | You can fill whatever e-mail address (real or not)
869 | or name to pay.
870 | The expiry date and CVC can also be random.
871 | >
872 | > - Payment succeeds: `4242 4242 4242 4242`
873 | > - Payment requires authentication: `4000 0025 0000 3155`
874 | > - Payment is declined: `4000 0000 0000 9995`
875 |
876 | You can check the customers info in
877 | https://dashboard.stripe.com/test/customers.
878 | You may delete the customers, if you want.
879 |
880 |
881 |
882 | ## 4. Blocking unpaid users from `dashboard`
883 |
884 | We now got a decent workflow going.
885 | But authenticated can still access `/dashboard`
886 | while just being authenticated.
887 | We want them to be logged in
888 | *and also have paid to access it*.
889 |
890 | We are currently not tracking our customers
891 | who have paid and should be granted access.
892 | We are going to be needing to create
893 | a way to save `User`s object
894 | with the referring `stripe_id`
895 | and a boolean field referring to the payment status.
896 |
897 | We could use a PostgresSQL engine for this,
898 | but for this use case it's a bit overkill.
899 | Instead, to simplify deployment,
900 | we will be using
901 | [`Erland Term Storage (ETS)`](https://elixirschool.com/en/lessons/storage/ets).
902 | This is a in-memory storage engine built into OTP
903 | and can be employed to store large amounts of data
904 | *with constant time data access*.
905 | Do note that since this engine is in-memory,
906 | **all data is lost when the process ends/server is shutdown**.
907 |
908 | There is an Elixir wrapper that makes it easy to use `ETS` and `DETS`,
909 | where the latter is a disk-based variant of the former,
910 | where data is persisted on disk.
911 | We'll be using
912 | [`pockets`](https://github.com/fireproofsocks/pockets).
913 |
914 | Let's install it.
915 |
916 | Add it to the `deps` section inside `mix.exs`.
917 |
918 | ```elixir
919 | def deps do
920 | [
921 | {:pockets, "~> 0.1.0"}
922 | ]
923 | end
924 | ```
925 |
926 | With that installed,
927 | we are going to be creating a module
928 | that will *manage the users table*.
929 | We are going to create it on startup,
930 | and create users/edit them.
931 |
932 | For that, create a file in `lib/app/users.ex`.
933 |
934 | ```elixir
935 | defmodule UsersTable do
936 |
937 | alias Pockets
938 |
939 | @table :users_table
940 | @filepath "cache.dets"
941 |
942 | def init do
943 | case Pockets.new(@table, @filepath) do
944 | {:ok, set} -> {:ok, set}
945 | {:error, _} ->
946 | Pockets.open(@table, @filepath)
947 | end
948 | end
949 |
950 | def create_user(%{:stripe_id => stripe_id, :person_id => person_id, :status => status}) do
951 | Pockets.put(@table, person_id, %{stripe_id: stripe_id, status: status})
952 | end
953 |
954 | def fetch_user(person_id) do
955 | Pockets.get(@table, person_id)
956 | end
957 |
958 | end
959 | ```
960 |
961 | Let's go over what we have done.
962 | We are going to be saving our users
963 | with a tuple containing the `**stripe_id**`,
964 | the `person_id` of the logged in user
965 | and a `**status**` boolean field,
966 | referring to whether the user has paid or not.
967 |
968 | All the functions being used are used
969 | according to the [`ets` wrapper documentation](https://github.com/TheFirstAvenger/ets).
970 | - the `init/0` function creates the table to store our users.
971 | If the file already exists, we open the file.
972 | - `create_user/3` receives a `stripe_id`, `person_id` and `status`
973 | (pertaining to whether the payment has been made or not)
974 | and creates a user object.
975 | - `fetch_user/1` retrieves the persisted user
976 | according to the given` person_id`.
977 |
978 | Let's make use of some of these functions.
979 | We want to setup the `DETS` table on the process startup.
980 | For this, we are going to initiate the table
981 | on the `start/1` function inside `lib/app/application.ex`.
982 | This function is executed when the process is created,
983 | so it fits right our needs!
984 | Add the following code below the `children` array variable
985 | inside `start/1`.
986 |
987 | ```elixir
988 | UsersTable.init()
989 | ```
990 |
991 | We now need to **create the user** every time
992 | a Stripe session is successful.
993 | We can do this inside `lib/app_web/controllers/page_controller.ex`
994 | on the `success/2` callback.
995 | Change the handler, so it looks like the following:
996 |
997 | ```elixir
998 | def success(conn, %{"session_id" => session_id}) do
999 |
1000 | case Stripe.Checkout.Session.retrieve(session_id) do
1001 | {:ok, session} ->
1002 |
1003 | person_id = conn.assigns.person.id
1004 | UsersTable.create_user(%{person_id: person_id, stripe_id: session.customer, status: true})
1005 |
1006 | render(conn, :success, layout: false)
1007 |
1008 | {:error, _error} ->
1009 | conn
1010 | |> put_status(303)
1011 | |> redirect(to: ~p"/")
1012 | end
1013 | end
1014 | ```
1015 |
1016 | Now, inside `lib/app_web/controllers/app_+controller.ex`,
1017 | we will render our "dashboard"
1018 | **only** if a user is found in our `Users` table.
1019 | If there isn't a user found, we redirect the user to the homepage.
1020 | Change the `home/2` function so it looks like the following.
1021 |
1022 | ```elixir
1023 | def home(conn, _params) do
1024 |
1025 | person_id = conn.assigns.person.id
1026 | case UsersTable.fetch_user(person_id) do
1027 | nil ->
1028 | conn |> redirect(to: ~p"/")
1029 |
1030 | _ ->
1031 | render(conn, :app, layout: false)
1032 | end
1033 | end
1034 | ```
1035 |
1036 | All there's left to do
1037 | is to change the `Purchase` button to dynamically change.
1038 | We want paid users to access the dashboard directly,
1039 | and non-paid users to purchase it.
1040 |
1041 | To do this, add this function
1042 | in `lib/app_web/controllers/page_html.ex`.
1043 |
1044 | ```elixir
1045 | def check_user_has_paid(person_id) do
1046 | user = UsersTable.fetch_user(person_id)
1047 |
1048 | if user == nil, do: false, else: user.status
1049 | end
1050 | ```
1051 |
1052 | Inside `lib/app_web/controllers/page_html/home.html.heex`,
1053 | change the `Purchase` button so it changes according to the user's payment status.
1054 |
1055 | ```html
1056 | <%= if check_user_has_paid(@conn.assigns.person.id) do %>
1057 | <.link
1058 | href={~p"/dashboard"}
1059 | class="relative px-5 py-2.5 transition-all ease-in duration-75 bg-white dark:bg-gray-900 rounded-md group-hover:bg-opacity-0"
1060 | >
1061 | Enter
1062 |
1063 | <% else %>
1064 | <.link
1065 | href={~p"/purchase/checkout-session"}
1066 | method="post"
1067 | class="relative px-5 py-2.5 transition-all ease-in duration-75 bg-white dark:bg-gray-900 rounded-md group-hover:bg-opacity-0"
1068 | >
1069 | Purchase
1070 |
1071 | <% end %>
1072 | ```
1073 |
1074 | ## 5. All done! 🎉
1075 |
1076 | And you should be done!
1077 | The user can now pay for our product.
1078 | We are restricting user access for only users that have paid.
1079 | Users that *have* made the payment **will have access**.
1080 |
1081 | 
1082 |
1083 |
1084 | # Thanks!
1085 |
1086 | Thanks for learning about payment processing with us!
1087 | If you have any questions, please ask!!
1088 | Please ⭐ this repo to help spread the word!
1089 |
1090 | If you are using environment variables in a way not mentioned in this guide,
1091 | or have a better way of managing them
1092 | or any other ideas or suggestions for improvements
1093 | please tell us!!
1094 |
1095 | [](https://hits.dwyl.com/dwyl/learn-payment-processing)
1096 |
--------------------------------------------------------------------------------
/lib/app.ex:
--------------------------------------------------------------------------------
1 | defmodule App do
2 | @moduledoc """
3 | App keeps the contexts that define your domain
4 | and business logic.
5 |
6 | Contexts are also responsible for managing your data, regardless
7 | if it comes from the database, an external API or others.
8 | """
9 | end
10 |
--------------------------------------------------------------------------------
/lib/app/application.ex:
--------------------------------------------------------------------------------
1 | defmodule App.Application do
2 | # See https://hexdocs.pm/elixir/Application.html
3 | # for more information on OTP Applications
4 | @moduledoc false
5 |
6 | use Application
7 |
8 | @impl true
9 | def start(_type, _args) do
10 | children = [
11 | # Start the Telemetry supervisor
12 | AppWeb.Telemetry,
13 | # Start the PubSub system
14 | {Phoenix.PubSub, name: App.PubSub},
15 | # Start Finch
16 | {Finch, name: App.Finch},
17 | # Start the Endpoint (http/https)
18 | AppWeb.Endpoint
19 | # Start a worker by calling: App.Worker.start_link(arg)
20 | # {App.Worker, arg}
21 | ]
22 |
23 | # Creating DETS user table
24 | UsersTable.init()
25 |
26 | # See https://hexdocs.pm/elixir/Supervisor.html
27 | # for other strategies and supported options
28 | opts = [strategy: :one_for_one, name: App.Supervisor]
29 | Supervisor.start_link(children, opts)
30 | end
31 |
32 | # Tell Phoenix to update the endpoint configuration
33 | # whenever the application is updated.
34 | @impl true
35 | def config_change(changed, _new, removed) do
36 | AppWeb.Endpoint.config_change(changed, removed)
37 | :ok
38 | end
39 | end
40 |
--------------------------------------------------------------------------------
/lib/app/users.ex:
--------------------------------------------------------------------------------
1 | defmodule UsersTable do
2 | alias Pockets
3 |
4 | @table (if Mix.env() == :prod do
5 | :users_table
6 | else
7 | :users_test_table
8 | end)
9 | @filepath (if Mix.env() == :prod do
10 | "cache.dets"
11 | else
12 | "cache_test.dets"
13 | end)
14 |
15 | def init() do
16 | case Pockets.new(@table, @filepath) do
17 | # Not testing creating a table because when testing, it loads a sample table.
18 | # coveralls-ignore-start
19 | {:ok, set} ->
20 | {:ok, set}
21 |
22 | # coveralls-ignore-end
23 | {:error, _} ->
24 | Pockets.open(@table, @filepath)
25 | end
26 | end
27 |
28 | def create_user(%{
29 | :stripe_id => stripe_id,
30 | :person_id => person_id,
31 | :status => status
32 | }) do
33 | Pockets.put(@table, person_id, %{stripe_id: stripe_id, status: status})
34 | end
35 |
36 | def fetch_user(person_id) do
37 | Pockets.get(@table, person_id)
38 | end
39 | end
40 |
--------------------------------------------------------------------------------
/lib/app_web.ex:
--------------------------------------------------------------------------------
1 | defmodule AppWeb do
2 | @moduledoc """
3 | The entrypoint for defining your web interface, such
4 | as controllers, components, channels, and so on.
5 |
6 | This can be used in your application as:
7 |
8 | use AppWeb, :controller
9 | use AppWeb, :html
10 |
11 | The definitions below will be executed for every controller,
12 | component, etc, so keep them short and clean, focused
13 | on imports, uses and aliases.
14 |
15 | Do NOT define functions inside the quoted expressions
16 | below. Instead, define additional modules and import
17 | those modules here.
18 | """
19 |
20 | def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
21 |
22 | def router do
23 | quote do
24 | use Phoenix.Router, helpers: false
25 |
26 | # Import common connection and controller functions to use in pipelines
27 | import Plug.Conn
28 | import Phoenix.Controller
29 | import Phoenix.LiveView.Router
30 | end
31 | end
32 |
33 | def channel do
34 | quote do
35 | use Phoenix.Channel
36 | end
37 | end
38 |
39 | def controller do
40 | quote do
41 | use Phoenix.Controller,
42 | namespace: AppWeb,
43 | formats: [:html, :json],
44 | layouts: [html: AppWeb.Layouts]
45 |
46 | import Plug.Conn
47 | import AppWeb.Gettext
48 |
49 | unquote(verified_routes())
50 | end
51 | end
52 |
53 | def live_view do
54 | quote do
55 | use Phoenix.LiveView,
56 | layout: {AppWeb.Layouts, :app}
57 |
58 | unquote(html_helpers())
59 | end
60 | end
61 |
62 | def live_component do
63 | quote do
64 | use Phoenix.LiveComponent
65 |
66 | unquote(html_helpers())
67 | end
68 | end
69 |
70 | def html do
71 | quote do
72 | use Phoenix.Component
73 |
74 | # Import convenience functions from controllers
75 | import Phoenix.Controller,
76 | only: [get_csrf_token: 0, view_module: 1, view_template: 1]
77 |
78 | # Include general helpers for rendering HTML
79 | unquote(html_helpers())
80 | end
81 | end
82 |
83 | defp html_helpers do
84 | quote do
85 | # HTML escaping functionality
86 | import Phoenix.HTML
87 | # Core UI components and translation
88 | import AppWeb.CoreComponents
89 | import AppWeb.Gettext
90 |
91 | # Shortcut for generating JS commands
92 | alias Phoenix.LiveView.JS
93 |
94 | # Routes generation with the ~p sigil
95 | unquote(verified_routes())
96 | end
97 | end
98 |
99 | def verified_routes do
100 | quote do
101 | use Phoenix.VerifiedRoutes,
102 | endpoint: AppWeb.Endpoint,
103 | router: AppWeb.Router,
104 | statics: AppWeb.static_paths()
105 | end
106 | end
107 |
108 | @doc """
109 | When used, dispatch to the appropriate controller/view/etc.
110 | """
111 | defmacro __using__(which) when is_atom(which) do
112 | apply(__MODULE__, which, [])
113 | end
114 | end
115 |
--------------------------------------------------------------------------------
/lib/app_web/components/core_components.ex:
--------------------------------------------------------------------------------
1 | defmodule AppWeb.CoreComponents do
2 | @moduledoc """
3 | Provides core UI components.
4 |
5 | The components in this module use Tailwind CSS, a utility-first CSS framework.
6 | See the [Tailwind CSS documentation](https://tailwindcss.com) to learn how to
7 | customize the generated components in this module.
8 |
9 | Icons are provided by [heroicons](https://heroicons.com), using the
10 | [heroicons_elixir](https://github.com/mveytsman/heroicons_elixir) project.
11 | """
12 | use Phoenix.Component
13 |
14 | alias Phoenix.LiveView.JS
15 | import AppWeb.Gettext
16 |
17 | @doc """
18 | Renders a modal.
19 |
20 | ## Examples
21 |
22 | <.modal id="confirm-modal">
23 | Are you sure?
24 | <:confirm>OK
25 | <:cancel>Cancel
26 |
27 |
28 | JS commands may be passed to the `:on_cancel` and `on_confirm` attributes
29 | for the caller to react to each button press, for example:
30 |
31 | <.modal id="confirm" on_confirm={JS.push("delete")} on_cancel={JS.navigate(~p"/posts")}>
32 | Are you sure you?
33 | <:confirm>OK
34 | <:cancel>Cancel
35 |
36 | """
37 | attr :id, :string, required: true
38 | attr :show, :boolean, default: false
39 | attr :on_cancel, JS, default: %JS{}
40 | attr :on_confirm, JS, default: %JS{}
41 |
42 | slot :inner_block, required: true
43 | slot :title
44 | slot :subtitle
45 | slot :confirm
46 | slot :cancel
47 |
48 | def modal(assigns) do
49 | ~H"""
50 |
55 | An example integrating Stripe
56 | with Phoenix
57 |
58 |
59 | This is a small project showcasing how to integrate Stripe in a Phoenix project.
60 | The workflow is simple: a logged in user has to pay to have access to
/dashboard
61 |
62 |
63 | Otherwise, they are redirected to a buying page to purchase so they can access it.
64 |
65 |
66 | <%= if Map.has_key?(@conn.assigns, :person) do %>
67 |