Preparation
Since you’ve chosen to read this post, I’m sure you are familiar with how fast the JavaScript ecosystem evolves. After conquering the web, it is now rampant in the native app industry as well, with tools like React Native for mobile apps and Electron for desktop apps.
These days, the gap between apps built with React Native and those built with true native stacks is only getting narrower. One of the major details that benefits both indie and big corporate app developers alike is being able to handle payment in their apps without a lot of fuss.
If you have a great idea for an app, it’s only a matter of time before you start looking into how to monetize it and receive payments directly from users with as little friction as possible. So in this post, I will walk you through building an entire app, with authentication and payment processing, using React Native (via Expo), UI Kitten, Stripe, and AdonisJs, a backend Node.js framework.
Before you start following along, please keep in mind that this post assumes you are familiar with the basics of Git, React Native, React, TypesScript, and REST APIs in general.
If you’re new to one or all of those, you’re still welcome to read along and learn what you can along the way. In order to keep this post on topic, however, a lot of the lower-level detail will not be explained throughout the post. Also, please make sure that you have your environment set up for React Native app development.
With applications like this, where you have a server app and a client app, I usually put them in a container directory and give it the name of the project. I’m really bad with names, so I couldn’t come up with anything better than payme, which is just short for payment and makes me sound like I’m asking for money. Oh well!
So first, create a new directory named
payme and navigate inside it from your terminal:
mkdir payme cd payme
If you’re impatient like me and want to see the code first before you commit to anything, here’s the GitHub repo containing the entire codebase — dig in! 🙂 Or, if you just want to see the end result, here’s a quick video preview.
Expo app
Expo is a platform/toolkit built on top of React Native to make developer experience much easier and smoother. You can think of it as RN on steroids. It does have some limitations when it comes to native functionality, but there’s always a workaround for that kind of thing.
The first thing we’re gonna need is
expo-cli installed globally, and using the CLI, we will generate a new boilerplate app:
npm install -g expo-cli expo init payme
The second command will ask you to pick a template, and using your arrow key, select the second one from the list, as shown in the image below. This generates a blank TypeScript Expo app.
Remember when I said there are some limitations with Expo? Sorry to say that we have hit one of those — and so soon into the post!
If you need to make it work with iOS, you will need to use the bare workflow instead of managed because Expo does not support the Stripe module on iOS yet. For more details, please read this documentation from Expo.
However, all the code we will be writing throughout this post will be compatible in both bare and managed workflow.
Once complete, you will be left with a new directory named
payme. Since we’re going to have a server-side API app and a client-side Expo app, let’s rename the generated app folder from
payme to
app. Then, install a few of the packages that we will be using very soon:
mv payme app cd app expo install @react-native-community/async-storage @ui-kitten/eva-icons @ui-kitten/components @eva-design/eva react-native-svg expo-payments-stripe
While the installation is running, please go through this UI Kitten documentation on branding and how to generate a theme file with your preferred color palette for your app. Following the steps from the documentation, you should get a
theme.json with a bunch of colors. Put that file into the root of the app directory.
Finally, we can get to coding! Open up the
App.tsx file and replace the existing code with the following:
import { StatusBar } from 'expo-status-bar'; import React, {useEffect, useState} from 'react'; import * as eva from '@eva-design/eva'; import { ApplicationProvider, IconRegistry } from '@ui-kitten/components'; import { default as theme } from './theme.json'; import { EvaIconsPack } from '@ui-kitten/eva-icons'; import { AuthPage } from "./AuthPage"; import { ProductPage } from "./ProductPage"; import { Auth } from "./auth"; import { Payment } from "./payment"; const auth = new Auth(); const payment = new Payment(auth); export default function App() { const [isLoggedIn, setIsLoggedIn] = useState(false); const [isLoggingIn, setIsLoggingIn] = useState(true); useEffect(() => { auth.getToken().then((token) => { setIsLoggingIn(false); if (!!token) setIsLoggedIn(true); }); }); return ( <> <IconRegistry icons={EvaIconsPack} /> <ApplicationProvider {...eva} theme={{...eva.light, ...theme}}> <StatusBar style="auto" /> { isLoggedIn ? <ProductPage {...{payment}} /> : <AuthPage {...{auth, setIsLoggedIn, setIsLoggingIn, isLoggingIn}} /> } </ApplicationProvider> </> ); }
Some of this code is just boilerplate, but let’s break it down a bit. To set up UI Kitten with the custom theme that we generated, we are importing the
theme.json file and passing the
theme object to
ApplicationProvider.
We are importing and instantiating two classes,
Auth and
Payment, from their designated files. The
auth object is injected into the
Payment class as a dependency. These encapsulate the business logic and communicate with the server API.
We have two state variables in this component, one representing whether the user is already logged in and the other representing whether the login operation is currently happening
Initially, we load the UI in login mode, and on load, we do a check for existing authentication info using
auth.getToken(). This is necessary because if the user logs in once, subsequently opening the app shouldn’t show the login screen every time.
Then, we register the icon set from UI Kitten, set up the layout, show the status bar, yada, yada, yada… just more boilerplate stuff that I copy/pasted in from the UI Kitten’s official documentation. If you’re reading this a long time after this post was published, please make sure you follow and match the doc with this setup.
Finally, based on the login state, if the user is not logged in, we render the
AuthPage component; otherwise, we render the
ProductPage component. The
ProductPage component only takes in the payment object as
[rp[.
The
AuthPage component, however, needs to be able to change the global state based on interactions that happen inside the component itself. This is why we pass all the state variables and the setter functions to modify those variables.
As your app grows, this kind of quick-and-dirty way of managing state might not cut it anymore, and you will have to pick up tools like React Context or more advanced state management libraries like Redux or Recoil, but for the limited scope of this post, this should do just fine.
Authentication page
Authentication is probably one of the first things you’d work on when starting a project — and yet, it is one of the most complex things to get done, with a lot of edge cases and your app’s contextual dependencies.
For the purpose of this post, we will keep it simple and concise. As soon as the app opens, we want to show a login screen to the user where they can sign up with their email address and password, or log in if they already have an account.
FIrst off, create a new file named
AuthPage.tsx in the root of the app and drop in the code below:
import {Layout, Icon, Button} from "@ui-kitten/components"; import { Layout, Card, Button, Input, Text } from "@ui-kitten/components"; import { StyleSheet, View } from "react-native"; import React, { useState } from "react"; import { Auth } from "./auth"; const styles = StyleSheet.create({ page: { flex: 1, padding: 15, alignItems: 'center', justifyContent: 'center', }, card: { alignSelf: 'stretch', }, formInput: { marginTop: 16, }, footer: { marginTop: 10, alignSelf: 'stretch', flexDirection: 'row', justifyContent: 'space-between', }, statusContainer: { alignSelf: 'center', }, actionsContainer: { flexDirection: 'row-reverse', }, button: { marginLeft: 10, } }); type AuthPageProps = { auth: Auth, isLoggingIn: boolean, setIsLoggedIn: (isLoggedIn: boolean) => any, setIsLoggingIn: (isLoggedIn: boolean) => any, }; export const AuthPage = ({ auth, isLoggingIn, setIsLoggedIn, setIsLoggingIn }: AuthPageProps) => { const [password, setPassword] = useState<string>(); const [email, setEmail] = useState<string>(); const [errors, setErrors] = useState<string[]>([]); const handlePrimaryButtonPress = async ( <Input style={styles.formInput}{message}</Text> )} </Card> <View style={styles.footer}> <View style={styles.statusContainer}> <Text>{isLoggingIn ? 'Authenticating...' : ''}</Text> </View> <View style={styles.actionsContainer}> <Button size="small" style={styles.button} disabled={isLoggingIn} onPress={() => handlePrimaryButtonPress('signup')}> Sign Up </Button> <Button size="small" status="info" appearance="outline" disabled={isLoggingIn} onPress={() => handlePrimaryButtonPress('login')}> Log In </Button> </View> </View> </Layout> ); }
Ideally, in a real-world application, you would have your own architecture and folder structure, but for the purpose and scope of this post, we will just drop everything in the root of the app folder.
That seems like a lot of code, but trust me: in a real-world authentication page, you will have at least five times more code than this. Fear not — it’s fairly easy to break down and consume it in chunks.
First, we have some stylesheet definitions. Since UI building is not in the primary scope of this post, I will not go into much detail on why and how this works. If you’re completely new to this, please read up on it here. To summarize, we’re just adding some styles that we will later apply to our various elements rendered by the
AuthPage component.
Then we create a type, defining all the different props this component expects to receive from its renderer.
Within the component itself, we have three state variables. The first two are email and password, which will be strings that the user inputs through the UI. Then we have an array of strings containing error messages. These will be errors that we might receive from the server side in response to the authentication requests. Imagine errors like duplicate email address for signup, wrong password for login, etc.
Next, we have an event handler function that will be triggered for either login or signup actions, depending on which button the user hits. Inside the body of the function, we call the various state setter functions passed to the component to let the parent know what’s happening.
Finally, we call the
auth.request(action, {email, password}) method with the email and password from the state. Imagine this will just send out the data to our API server and get some response back.
Now, let’s get to what the user will actually see. To make that nice and pretty, we use the
Layout and
Card components from UI Kitten and place the card right in the center of the screen with two inputs inside — one for email and one for password. Let’s analyze the password field, for example:
<Input style={styles.formInput} label='PASSWORD' secureTextEntry={true} value={password} onChangeText={setPassword} />
We use the
formInput style, which gives it a nice margin on top. Then the
label prop adds a text on top of the input field. The
secureTextEntry prop makes the text hide as the user types in the password. As the handler for the
onChangeText event, we pass the
setPassword function, which will update the state value. That value is then set as the value of the input field itself.
Pretty standard React stuff with a sprinkle of UI Kitten magic. There’s a lot more you can do to make these inputs look and feel according to your test by simply passing some props, read this to learn more about that.
Underneath, we loop through all the error message strings from state and render a
Text component showing the message itself, if there is any. The
status='danger' part will make the text color red thanks to UI Kitten.
{errors.length > 0 && errors.map(message => <Text key={`auth_error_${message}`}{message}</Text> )}
Finally, we get to the footer. Divided into two horizontal sections, on the right, it shows two buttons: one for Log in and the other for Sign up. On the left, it is an empty area by default, but when we are communicating with the server, text saying Authenticating… will be displayed there to let the user know that we are processing their request.
Notice that the two buttons call the same function but with different parameters. They also have slightly different props — for instance, one has
appearance='outline', which gives it a border and makes the background color a bit faded. We are also passing
disabled={isLoggingIn}, which makes sure that after the user presses a button and we send the data over to the server, both the buttons will be disabled to avoid multiple submissions.
Phew! First component of the app done — not bad, right? Well, the bad news is that you can’t see it in action yet because of the missing pieces here like the
auth object and the
PaymentPage component. However, to thank you for your hard work so far, I will reward you with a screenshot of how it will look once the app does render the page in all its glory! Lo and behold!
OK, sorry, I probably hyped it up a bit too much. It’s not that amazing, but hey, it’s not that bad either, right?
Product page
Just like we did for the auth page, let’s create a new file named
ProductPage.tsx in the root of the app and drop the below code in there:
import {Layout, Icon, Button} from "@ui-kitten/components"; import { StyleSheet } from "react-native"; import React, {useEffect, useState} from "react"; import {Payment, PaymentData} from "./payment"; const styles = StyleSheet.create({ container: { flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', }, button: { margin: 2 } }); const products = [{ status: 'primary', text: 'Buy Video', product: 'video', icon: 'film', price: 50, }, { price: 30, status: 'info', product: 'audio', text: 'Buy Audio', icon: 'headphones', }]; type ProductPageProps = { payment: Payment, }; export const ProductPage = ({ payment }: ProductPageProps) => { const [paymentReady, setPaymentReady] = useState(false); const [paymentHistory, setPaymentHistory] = useState<PaymentData[]>([]); // Initialize the payment module, on android, this MUST be inside the useEffect hook // on iOS, the initialization can happen anywhere useEffect(() => { payment.init().then(() => { setPaymentReady(true); setPaymentHistory(payment.history); }); }); const handlePayment = async (price: number, product: string) => { await payment.request(price, product); setPaymentHistory(payment.history); }; const hasPurchased = (product: string) => !!paymentHistory.find(item => item.product === product); return ( <Layout style={styles.container}> {products.map(({product, status, icon, text, price}) => ( <Button status={status} style={styles.button} disabled={!paymentReady || hasPurchased(product)} onPress={() => handlePayment(price, product)} key={`${status}_${icon}_${text}`} accessoryLeft={props => <Icon {...props} name={icon}/>}> {`${text} $${price}`} </Button> ))} </Layout> ); };
Let’s take a closer look at the code here. Just like before, we have some style definitions that we will add to different components later on.
Then we have an array containing two product entries. Each entry has a
status,
price,
product,
icon, and
text properties. Except for the
price and
product properties, these are all mainly for the UI, whereas the former two will be later used in API communication. In a real-world app, you will probably get this list from the server instead of hardcoding it into the app’s code.
In the definition of the component itself, we set it up to receive the payment object as a prop, which we passed from the
App component, remember? On load, we initialize the payment module by calling
init(), and to keep the UI in sync with the state of the payment module loading, we introduce a state variable that is set to
false initially and, upon initialization of the payment module, is toggled on.
Also, there’s another state variable that is supposed to contain all the previously made payments by the user. After calling
init() on the payment object, we reset that state variable with the
history property from the payment object. As if, after initializing, the history property will contain the payment history. We will see how that is done later.
We also have an event handler function,
handlePayment, that gets triggered with price and product data and calls the
request method from the payment module. After the call ends, we are re-adjusting the
paymentHistory state with the
history property from the
payment object as if, after making a payment, the history property will contain different data then before.
We are blindly trusting the payment object to contain all these methods for the time being, but worry not, we will soon build it ourselves.
Then comes the UI rendering. Inside a
Layout component from UI Kitten, we are simply looping through both our products and rendering one button for each of them. This is where you see the properties like
status,
text, etc. set in the
product object are coming in handy.
UI Kitten lets us easily manipulate the appearance of various components like buttons through simple properties. For instance,
status="primary" will give the button a distinct style based on the primary color from your theme’s definition.
We are also disabling the button if the payment initialization is not complete or there is already a payment entry in the history for the same product. You can play around with the UI look and feel by changing/adding various props documented here.
Notice that we’re using our previously created
handlePayment function as the listener for
onPress events, and we’re also using the various styles we created at the beginning to add some spacing between the buttons and center them vertically within the screen.
To give you a glimpse of how all of this looks, here’s a screenshot:
With that, we will take a short break from the frontend side of things and build out the API. Once that’s ready, we will come back to this to connect the two and wrap things up!
Adonis app
Just like Expo, AdonisJs comes with a handy boilerplate generator command. To run it, make sure you’re inside the
payme/ directory and run the following command:
yarn create adonis-ts-app payme
You will get a few questions and provide answers like the below image and you will be left with a directory named
payme:
Just like before, we will move this to a more fitting name for the monorepo structure:
mv payme api. Now, you can navigate inside the directory and run the app:
cd api yarn start
You should see an output that tells you that the app is running and accessible from localhost on port 3333.
Authentication
Adonis makes user authentication a breeze right out of the box, with a lot of flexibility. We will get started by installing some of the packages that we will be using very soon:
yarn add stripe @adonisjs/auth@alpha.
The first package we installed there is the Node package for Stripe. The second is the auth package from Adonis, which gives us everything we need for user authentication after running
node ace invoke @adonisjs/auth and providing all the input it requires.
Here are the answers I provided, and you can see all the files it generates for you:
Notice that we are using Lucid as the provider of data storage, which we haven’t set up yet. It’s the official Adonis database ORM layer. Let’s set that up by first installing the package itself:
yarn add @adonisjs/lucid@alpha. This will ask you to choose a database provider. Normally, you’d pick something like MySQL or PostgreSQL, but for the small scope of this post, I’ll just stick to SQLite.
With that, we are now ready to run the migrations generated by Adonis auth package:
node ace migration:run. This will create the necessary tables with the proper schema to support JWT-based user authentication.
Note: At the time of writing this post, there is an issue with the way packages are set up in Adonis, and you might need to manually install the phc-argon2 package by running the
yarn add phc-argon2command.
For this very basic app, we will only set up signup and login actions, but in the real world, you’d need to build a much more complicated flow with reset password, email verification, etc.
For signup and login, we need two REST endpoints, and those are usually managed through routes and controllers in Adonis. So let’s create those endpoints in the
start/routes.ts file and remove the existing code:
import Route from '@ioc:Adonis/Core/Route'; Route.post('/login', 'AuthController.login'); Route.post('/signup', 'AuthController.signup');
This will register two POST request endpoints,
/login and
/signup, and when requests are sent to those endpoints, Adonis will execute the associated methods from the controller class named
AuthController.
However, that does not exist yet, so let’s create a new file (and the necessary directories, i.e.,
Controllers and
Http) in
app/Controllers/Http/AuthController.ts . Or you can just run the command
node ace make:controller auth, which will generate the files and directories for you. Now place in the following code in the controller file:
import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' import User from 'App/Models/User'; export default class AuthController { public async login ({ request, auth }: HttpContextContract) { const email = request.input('email'); const password = request.input('password'); const token = await auth.use('api').attempt(email, password); return token.toJSON(); } public async signup ({ request, auth }: HttpContextContract) { const email = request.input('email'); const password = request.input('password'); const user = await User.create({email, password}); const token = await auth.use('api').generate(user); return token.toJSON(); } }
The
login method expects an email and a password to be sent in the body of the POST request, and using the injected
auth module, Adonis will check whether there is a user entry in the database matching those credentials through the
attempt method.
If the user exists, it will issue a token and send it in the response. The token response contains two properties,
{token: <actual jwt token>, type: <type of the token, usually Bearer>}.
The signup method is a bit simplified here, but in a real-world application, you will probably require other fields like name, address, etc. In our case, it expects the same data as the login method: email and password. Given those two, it will create a new
User entry in the database and then generate tokens for the newly created user, which will then be sent back to the caller as a response.
Think of the above setup as the counterpart for the
AuthPage component that we just built for our React Native app. So now, the remaining thing for our API is the counterpart for the
PaymentPage component. Let’s build that out, but we will take a more scenic route for this one since Adonis won’t build everything for us like it did for the auth module.
Data structure
When architecting a feature from the ground up, I usually like to start from the granular data layer — come up with the data structure at the database level that can support everything the feature might require. The way that works in Adonis is through migrations.
A migration lets you interact with your database and create/update your tables and the columns inside the tables. In our case, we are building an app to demonstrate how to take payment using Stripe. So what we want to keep in the database is, for every payment, one row in a table with all the details of one payment.
Fittingly, we would want to call that table
payments. Like I said, instead of creating that table manually in our database, we will run the command
node ace make:migration payment to generate payment table migration. Then, update the generated migration file that ends with
_payments.ts in the
api/database/migrations/ directory with the following new lines:
table.increments('id'); //--> new lines start table.integer('user_id').unsigned().references('id').inTable('users').onDelete('CASCADE'); table.string('charge_id').unique().notNullable(); table.string('token_id').notNullable(); table.string('product').notNullable(); table.integer('price').notNullable(); //--> new lines end table.timestamps(true);
Now before we dive into the code we just wrote, let’s run the command
node ace migration:run to create the new table with the structure described in that piece of code.
OK, while the migration is running, let’s take a closer look at this, starting with the pre-existing part. We are creating a new table named
payments that has a field named
id, which is the primary key of the table and auto-increments every time a new row is added to the table.
There’s also some magic going on with
timestamps(true), which basically translates to: add two columns named
created_at and
updated_at, which are timestamp fields. Now for the new fields:
As mentioned before, migrations only create the data structure at the database level, which means we still need to let our app know what kind of data it will be dealing with. That’s where models come in.
Run the command
node ace make:model payment to generate the payment model, which will create a new file in
app/Models/Payment.ts. Open up that file and place the following code in there:
import { DateTime } from 'luxon' import { BaseModel, column, belongsTo, BelongsTo } from '@ioc:Adonis/Lucid/Orm' import User from 'App/Models/User'; export default class Payment extends BaseModel { @column({ isPrimary: true }) public id: number @column() public price: number @column() public product: string @column() public tokenId: string @column() public chargeId: string @column() public userId: number @belongsTo(() => User) public user: BelongsTo<typeof User> @column.dateTime({ autoCreate: true }) public createdAt: DateTime @column.dateTime({ autoCreate: true, autoUpdate: true }) public updatedAt: DateTime }
As you can see, it’s pretty much a mirror of what we did with migration but more TypeScript-esque. Note that we are spelling out the timestamp fields at the model level, which was handled by the
timestamps(true) call in migration.
The other thing to notice here is the relationship bit that tells the ORM that every payment entry has a parent user.
@belongsTo(() => User) public user: BelongsTo<typeof User>
This is what enables us to run all sorts of relational queries using the cleverly designed Lucid ORM. We will see at least one example very soon in the payment controller. This only establishes the connection from payment model to user model. For the user model to be aware of its relationship with the payment model, we need to go into
app/Models/User.ts and add the following lines:
import { column, beforeSave, BaseModel, hasMany, HasMany, } from '@ioc:Adonis/Lucid/Orm'; //....previously generated code export default class User extends BaseModel { //....previously generated code @hasMany(() => Payment) public payments: HasMany<typeof Payment> }
Note: If you’re not familiar with relational data structure, or would like to learn more about how Adonis tackles that, please read on here.
Now let’s jump up one more level and expose the data management through HTTP requests. There are three things involved in this step.
1.) Route
Routes are what give the outer world a way to communicate with our Adonis app via HTTP. Let’s start by creating a new REST endpoint in the
start/route.ts file with the following content:
Route.get('/payment', 'PaymentsController.list').middleware('auth'); Route.post('/payment', 'PaymentsController.charge').middleware('auth');
We are adding one handler for GET requests at the
/payment endpoint, and when a request arrives there, we tell Adonis to
list the method from the
PaymentsController class. The other handler is for POST requests at the same endpoint and executes the
charge method from the controller class. There’s also a dangling portion with
middleware('auth') that we will demystify soon.
2.) Controller
Run the command
node ace make:controller payment to generate a payment controller, which will create a new file in
app/Http/Controllers/Payment.ts. Open that file and place in the following code:
import { HttpContextContract } from "@ioc:Adonis/Core/HttpContext"; import Env from '@ioc:Adonis/Core/Env' import Payment from "App/Models/Payment"; import Stripe from 'stripe'; const stripeSecretKey = `${Env.get('STRIPE_SECRET_KEY')}`; const stripe = new Stripe(stripeSecretKey, { apiVersion: '2020-08-27' }); export default class PaymentsController { public async charge ({ request, auth }: HttpContextContract) { const payment = new Payment(); payment.tokenId = request.input('tokenId'); payment.price = request.input('price'); const { id } = await stripe.charges.create({ amount: payment.price * 100, source: payment.tokenId, currency: 'usd', }); payment.chargeId = id; await auth.user?.related('payments').save(payment); return payment; } public async list ({ auth }: HttpContextContract) { const user = auth.user; if (!user) return []; await user?.preload('payments'); return user.payments; } }
A lot to unpack there. Let’s start from the top:
import Stripe from 'stripe'; const stripeSecretKey = `${Env.get('STRIPE_SECRET_KEY')}`; const stripe = new Stripe(stripeSecretKey, { apiVersion: '2020-08-27' });
Remember we installed Stripe when installing the authentication library? This is where we use it.
After importing, we instantiate Stripe with the secret key. However, in the interest of security best practice, instead of hardcoding the key, we are using the
Env provider from Adonis, which lets us easily read from env variables. You would Update the
.env in the root of the
api directory and add a new entry like below:
STRIPE_SECRET_KEY=<test secret key from your stripe account>
To get the secret key, you’d have to log in to your Stripe account and then navigate to Developers -> API Keys. Then, toggle on View test data from the top right corner. Now you can either create a new secret key, or if you already have one, feel free to use that.
Keep in mind that we are using test data because we want to be able to test the payment feature without having to actually spend money or charging a real card. Once you’re done building the app and release it to production, you should replace this key with a real key.
Next up, the charge method:
public async charge ({ request, auth }: HttpContextContract) { const payment = new Payment(); payment.tokenId = request.input('tokenId'); payment.product = request.input('product'); payment.price = request.input('price'); const { id } = await stripe.charges.create({ amount: payment.price * 100, description: payment.product, source: payment.tokenId, currency: 'usd', }); payment.chargeId = id; await auth.user?.related('payments').save(payment); return payment; }
Here, we’re expecting the client to send us a Stripe
tokenId, the
price, and the
product, and with those, we instantiate a new payment model object. Before saving, we attempt to charge the user’s card by calling the
charges.create() method. Stripe handles amounts in cents, so if the total amount we want to charge is $50, we need to send 5000 as the amount for the charge request.
We are also hardcoding the currency to be
usd, but in a real-world app, you’d probably adjust it based on the user’s card or based on some other app-level variable. There is a lot more data you can send to Stripe when charging a user’s card. To find out more, please read their amazingly detailed API documentation here.
If the charge is successful, Stripe will return an ID (along with a lot of other info) that we can later use to retrieve info about the transaction. That’s the only piece of data we are going to store in our database under the
chargeId column.
Note that we are not handling any case in which Stripe cannot charge the card — whether because the card info is wrong, the card is expired, or any other reason — and returns error info instead of
chargeId. I am leaving that as an exercise for the reader.
Then to insert the row, instead of directly inserting using payment model, we use the
user.related('payments').save(). That way, Adonis will automatically associate the payment entry with the logged-in user.
Notice that we’re expecting a certain
auth object to be passed to us, from which we’re accessing the user property. This is automatically injected by the auth middleware and gives us access to the user model. From the user model, since we established the
hasMany relationship, Adonis can automatically fill in the details for associating child to parent entry.
The second method,
list, is a bit more simple. It simply finds and returns all entries from the
payments table that belongs to the logged-in user who is making the
GET request.
public async list ({ auth }: HttpContextContract) { const user = auth.user; if (!user) return []; await user?.preload('payments'); return user.payments; }
Just like the charge method, having access to the user model, we can run
preload('payments') to load all the payment entries that are related to the logged-in user.
Middleware
For security purposes, we wouldn’t want anyone who is not logged in to make a payment request. Manually validating every request is a tedious and risky process, which is why Adonis makes it super easy through the use of middlewares.
In the previous step, we saw an
auth object injected into our controller method, and that’s possible because of the magic of middlewares. We do have to do some work to make the magic work, though.
We already added
.middleware('auth') to the route definition. The last thing we have to do to make it work is register the middleware. Open up the
start/kernel.ts file, and at the bottom, replace the line that looks like
Server.middleware.registerNamed({}) with the following:
Server.middleware.registerNamed({ auth: 'App/Middleware/Auth', });
Now Adonis knows what we mean by the
auth middleware, and all the logic to prevent non-authenticated requests from reaching the controller method has already been generated for us by the auth generator.
Combining the two apps
Now that our REST API is ready with all the endpoints, let’s move to the last phase of connecting the two. Long gone are the days when you’d need an entire npm library to communicate between client and server; nowadays, all you need is
fetch. We start by making an
api.ts file in the root of our
app/ directory and placing in the following code:
const API_URL = ''; export const postDataToApi = async ({endpoint = '', data = {}, headers = {}}) => { const response = await fetch(`${API_URL}/${endpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...headers }, body: JSON.stringify(data) }); return response.json(); }; export const getDataFromApi = async ({endpoint = '', headers = {}}) => { const response = await fetch(`${API_URL}/${endpoint}`, { headers: { 'Content-Type': 'application/json', ...headers }, }); return response.json(); };
Here, we have the API endpoint hardcoded here and pointed at, but note that the IP part of it will be different for you. To get that, you can follow something like this. This is necessary because our app will be running on a mobile device, but our API app is running on a different device. Assuming both the devices are connected to the same network, you should be getting an IP address container 192.168 prefix.
Then we have two functions: one for making POST requests and the other for GET requests. Both functions allow specifying an endpoint and additional header params. For POST requests, it also allows us to pass additional
data to the body of the request.
That’s all we need to communicate with our API server. Using these helpers, we will be making requests to the server on a case-by-case basis.
Auth class
The first case we handle is the
Auth module. Let’s create a new file in the root of the app/ directory named
auth.ts and place the following code inside of it:
import AsyncStorage from '@react-native-community/async-storage'; import { postDataToApi } from "./api"; interface AuthData { email: string | undefined; password: string | undefined; } export class Auth { tokenKey: string = '@authKey'; async request(endpoint = 'login', data: AuthData) { const { token, errors } = await postDataToApi({ endpoint, data }); if (token) { await AsyncStorage.setItem(this.tokenKey, token); return { success: true, errors: [] }; } const errorMapper: (params: { message: string }) => string = ({ message }) => message; return { success: false, errors: errors.map(errorMapper) }; } async getToken() { return AsyncStorage.getItem(this.tokenKey); } async getApiHeader() { const token = await this.getToken(); return { 'Authorization': `Bearer ${token}` }; } }
This class has a
request method that sends email and password credentials to our API server using the
postDataToApi helper function, which will return an object with either a
token or an
errors array with details of what went wrong. If we do receive a token, we are storing it in the local storage using the
AsyncStorage package, which is the quickest way to persist data locally on the device.
If something is wrong with the login/signup request, the API will respond with arrays of objects containing errors, and each object will contain a
message property. To simplify this data, we map through the array and convert it to an array of strings that we can loop through and display to the user.
Then, we have a
getToken method to retrieve a previously saved token from storage, which we call when the
App component is loaded in view. The last method is a helper that creates the
Authorization with the stored token, which can be sent with HTTP requests to our API server.
Since the authentication requests do not need to use an existing token, we are not using it anywhere here, but we will see its usage in the
Payment class.
Payment class
Create a new file in the root of the
app/ directory and put the code below inside of it:
import { PaymentsStripe as Stripe } from 'expo-payments-stripe'; import {Auth} from "./auth"; import {getDataFromApi, postDataToApi} from "./api"; type PaymentData = { id: number, price: number, product: string, }; export class Payment { auth: Auth; history: PaymentData[] = []; constructor(auth: Auth) { this.auth = auth; } async init() { await Stripe.setOptionsAsync({ androidPayMode: 'test', publishableKey: 'pk_test_<rest_of_your_key>', }); await this.getHistory(); return true; } async getHistory() { const response = await getDataFromApi({endpoint: 'payment', headers: await this.auth.getApiHeader()}); if (response.errors) { this.history = []; return false; } if (response.length > 0) { this.history = response; return true; } } async request(price: number, product: string) { try { const { tokenId } = await Stripe.paymentRequestWithCardFormAsync(); const payment: PaymentData = await postDataToApi({ endpoint: 'payment', data: {price, token: tokenId, product}, headers: await this.auth.getApiHeader() }); if (payment.id) this.history.push(payment); return payment; } catch(err) { console.log(err); return null; } } }
This is doing a little more than just making server API requests. First of all, it expects an instance of the
Auth class to be injected during instantiation. Then we have an
init method that instantiates Expo’s Stripe library with a
publishableKey.
To get the publishable key, follow the same steps you did to get the secret key when setting up the server-side Stripe charge mechanism, and along with the secret key, you will see a publishable key on the same page. After instantiating Stripe, we are calling another class method
getHistory.
getHistory is a simple GET request to our API server to retrieve all the previous payments that the current user has made. To identify the current user in the HTTP request, we are passing the auth token header by using the
this.auth.getApiHeader() method call. This request goes out to the
/payment endpoint.
If you remember our implementation for that endpoint, it returns an array of all the payment rows from our database associated with the authenticated user. So if we receive an array in the response, we are storing that in the
history property of the class, which, you may recall, is used in the component to update its state.
request is the last method of this class. This is called when the user presses the Buy button on one of the products and will receive the price and the name of the product. The first thing we do with this method is fire off a
Stripe.paymentRequestWithCardFormAsync() call, which will show a nice modal for the user to input their card info. This is all built into the Expo Stripe library. You can customize the look and feel of the modal by passing various config options to this call. Here’s a screenshot of how the modal looks:
Stripe makes testing card payment super simple; you don’t need a real card to test any of its features. You can find test card details here and use one from there to fill up this form. Notice that if you insert wrong card info, the UI will take care of validation automatically and show you the error.
Once you input a valid/test card and press done, Stripe will return various info as an object, the most important among them being the
tokenId property. Just because you input valid card info and pressed done does not mean that your card has been charged. To do that safely and securely, we need to pass the
tokenId to our API server as a POST request to the
/payment endpoint.
We already set that up to call the
charge() method using the Stripe library, remember? Along with the
tokenId, we are passing the price and the name of the product. Remember that the price is being passed in dollars, but on the server, it will be converted to cents, and the name of the product will be added to the description of the charge.
Also, to identify which user is making the payment, we are, once again, using the
this.auth.getApiHeader() method to add the authentication token in the header of the request.
Once the charge has been made, the server will create a new payment entry and return it back to us. So, as long as the returned response is an object with an ID property, we’ll know the payment was successful. We append this new payment to the
history array property to keep server and client data in sync because right after this call, the component updates its state using the
history property, which disables the button.
Notice that we are not handling the error case — again, I’m leaving this as an exercise for the reader.
Still with me? Great! You’ve done a wonderful job making it this far, and the reward is going to be worth it, I promise.
Take it for a spin
Now for the payoff: run the
yarn start command from the
app/ directory and you will be presented with a QR code in the terminal. I am using an actual device to test this, but you can easily test it on an iOS or Android emulator if you want to.
If you haven’t yet, install and then open the Expo app on your device and scan the QR code. It will automatically load and open the app on your device. You should also make sure that the API server is still running.
At this point, you should see the authentication screen, just like the screenshot from before. Fill in an email address and password and then press Sign up to create an account. Once you do that, you should get a new screen with two buttons on it, just like the screenshot from the
ProductPage section.
Before you go ahead and press one of those, I’d like you to close the app and reopen it to confirm that you are not asked to authenticate again and are instead brought directly to the product page. Thanks to the async-storage package, your authentication token is now saved on your device even if you leave the app.
Now go ahead and press one of the buttons and you should see the card input popup appear, just like the screenshot above. Play around with the inputs to see various validations happening in real time.
Once you insert valid/test card details and press done, you should see that the button you just pressed before the popup appeared is now disabled. If you now close the app and reopen it, you will see that the button remains disabled for you.
But we can’t just trust our UI blindly when there’s money involved! So let’s verify that the payment actually happened by heading over to the Stripe dashboard.
From the left sidebar on your dashboard, toggle on View test data and then go to the Payments panel. Make sure you see the Test data label on top of the list of payments, and the latest entry in there should be the one our app just created. Congrats! You just made some free cash!
Closing remarks
While we have built an amazing app together, we have to admit that this is nowhere close to a polished or finished product that we’d ever put into real users’ hands. So how about we plan some improvements we can make on top of what we have right now?
- Move the product list from a static array in-app to a dynamic, stored-in-database list of products retrieved from the API
- Make the products look fancy by adding images, descriptions, titles, etc.
- Using the payment list endpoint, show a list of products the user has purchased
- Once the user purchases a product, show an indicator on the product to remind the user that they have already purchased it — but also let the user purchase the same product again if they choose to, instead of just disabling the option to buy
- Show a success popup when the payment request is successful
If you’ve made it this far, I am 110 percent confident that you can implement all of the above on your own, and I would highly encourage everyone to give it a shot. Please let me know if you complete any or all of the above items by reaching out to me on Twitter with your Git repo or the finished app published in the store!.