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 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 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 (action = 'signup') => { setIsLoggingIn(true); // when signing up, we want to use the signup method from auth class, otherwise, use the login method try { const { success, errors } = await auth.request(action, {email, password}); setIsLoggedIn(success); setErrors(errors); } catch (err) { console.log(err); } setIsLoggingIn(false); }; return ( <Layout style={styles.page}> <Card style={styles.card} status='primary'> <Input style={styles.formInput} label='EMAIL' value={email} onChangeText={setEmail} /> <Input style={styles.formInput} label='PASSWORD' secureTextEntry={true} value={password} onChangeText={setPassword} /> {errors.length > 0 && errors.map(message => <Text key={`auth_error_${message}`} status='danger'>{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}`} status='danger'>{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?
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!
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.
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-argon2
command.
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.
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:
Field | Criteria | Reason |
price |
Number, for now only integers (dealing with cents is a whole other deal) | Contains the price of the product, which is the amount that will be charged to the user’s card |
product |
String, cannot be empty | Contains the name of the product for which the payment was made |
token_id |
String, cannot be empty | tokenId generated by the Stripe library inside the app when the user inputs valid card details |
charge_id |
String, cannot be empty | ID of the charge entry that Stripe returns after the user’s card has been successfully charged |
user_id |
Number, a foreign key that refers to an entry in the users table | ID of the user who made the payment |
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.
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.
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.
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.
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 = 'http://192.168.1.205:3333'; 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
classThe 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
classCreate 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.
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!
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?
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!
LogRocket is a React Native monitoring solution that helps you reproduce issues instantly, prioritize bugs, and understand performance in your React Native apps.
LogRocket also helps you increase conversion rates and product usage by showing you exactly how users are interacting with your app. LogRocket's product analytics features surface the reasons why users don't complete a particular flow or don't adopt a new feature.
Start proactively monitoring your React Native apps — try LogRocket for free.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowBuild scalable admin dashboards with Filament and Laravel using Form Builder, Notifications, and Actions for clean, interactive panels.
Break down the parts of a URL and explore APIs for working with them in JavaScript, parsing them, building query strings, checking their validity, etc.
In this guide, explore lazy loading and error loading as two techniques for fetching data in React apps.
Deno is a popular JavaScript runtime, and it recently launched version 2.0 with several new features, bug fixes, and improvements […]