├── .gitignore
├── .env.example
├── client
├── css
│ ├── account.css
│ ├── common.css
│ └── login.css
├── login.js
├── account.html
├── login.html
└── account.js
├── package.json
├── LICENSE.md
├── README.md
└── server.js
/.gitignore:
--------------------------------------------------------------------------------
1 | .env
2 | node_modules/
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | STATIC_DIR='./client'
2 | PAYSTACK_SECRET_KEY=
3 | PORT='5000'
4 | SERVER_URL='http://localhost:5000'
--------------------------------------------------------------------------------
/client/css/account.css:
--------------------------------------------------------------------------------
1 | .container {
2 | width: auto;
3 | max-width: 680px;
4 | padding: 0 15px;
5 | }
6 |
--------------------------------------------------------------------------------
/client/css/common.css:
--------------------------------------------------------------------------------
1 | html,
2 | body {
3 | height: 100%;
4 | }
5 |
6 | .btn-primary {
7 | background-color: #00c3f7;
8 | border: none;
9 | }
10 | .btn-primary:hover {
11 | background-color: #00c2f7c1;
12 | }
13 |
--------------------------------------------------------------------------------
/client/css/login.css:
--------------------------------------------------------------------------------
1 | body {
2 | height: 100vh;
3 | display: flex;
4 | align-items: center;
5 | background-color: #f5f5f5;
6 | }
7 |
8 | #signin-form {
9 | width: 100%;
10 | max-width: 330px;
11 | padding: 15px;
12 | margin: 0 auto;
13 | }
14 |
15 | #signin-form .form-control {
16 | position: relative;
17 | box-sizing: border-box;
18 | height: auto;
19 | padding: 10px;
20 | font-size: 16px;
21 | }
22 |
23 | #signin-form input[type="email"] {
24 | margin-bottom: -1px;
25 | }
26 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Sample Subscriptions App",
3 | "version": "1.0.0",
4 | "description": "A sample application showing how to use Paystack's Subscription API",
5 | "main": "server.js",
6 | "scripts": {
7 | "start": "node server.js",
8 | "dev": "nodemon server.js"
9 | },
10 | "keywords": [],
11 | "author": "",
12 | "license": "ISC",
13 | "dependencies": {
14 | "@paystack/paystack-sdk": "^1.0.1",
15 | "dotenv": "^16.0.1",
16 | "express": "^4.19.2"
17 | },
18 | "devDependencies": {
19 | "nodemon": "^2.0.21"
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/client/login.js:
--------------------------------------------------------------------------------
1 | window.addEventListener("DOMContentLoaded", async () => {
2 | const form = document.getElementById("signin-form");
3 | if (form) {
4 | form.addEventListener("submit", async (e) => {
5 | e.preventDefault();
6 |
7 | const email = document.getElementById("email").value;
8 |
9 | const customer = await fetch("/create-customer", {
10 | method: "post",
11 | headers: {
12 | "Content-Type": "application/json",
13 | },
14 | body: JSON.stringify({
15 | email: email,
16 | }),
17 | })
18 | .then((res) => res.json())
19 | .catch((error) => console.log(error));
20 | window.localStorage.setItem("customer_email", email);
21 | window.localStorage.setItem("customer_code", customer.customer_code);
22 | window.localStorage.setItem("customer_id", customer.id);
23 |
24 | window.location.href = "/account.html";
25 | });
26 | }
27 | });
28 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 PaystackOSS
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/client/account.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Account
5 |
6 |
7 |
11 |
12 |
18 |
19 |
20 |
21 |
22 |
23 |
Account Dashboard
24 |
25 |
26 |
27 |
32 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/client/login.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
11 |
17 |
18 |
19 | Sign In
20 |
21 |
22 |
23 |
24 |
Log in
25 |
26 | A simple demo on collecting subscription payments using Paystack
27 |
28 |
41 |
42 |
43 |
44 |
49 |
50 |
51 |
52 |
53 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Paystack Subscriptions Sample App
2 |
3 | This sample application shows how to integrate Paystack's Subscriptions API in your apps. For the official documentation for Paystack Subscriptions, [head over to the docs](https://paystack.com/docs/payments/subscriptions)
4 |
5 |
6 | ## Demo
7 |
8 | View a live demo of the app [here](https://codesandbox.io/p/github/PaystackOSS/sample-subscriptions-app/draft/condescending-borg).
9 |
10 | ## Get Started
11 |
12 | ### Requirements
13 | - **A Paystack account**: If you don't already have one, [sign up for a Paystack account](https://dashboard.paystack.com/#/signup). You'll need to do this to get your API keys.
14 | - **API keys**: You can grab these [from your Paystack dashboard](https://dashboard.paystack.com/#/settings/developers)
15 | - **Existing plans**: You'll need to have existing (active) plan objects that you can subscribe your customers to. If you don't already have any plans, you can just create a couple [from your Paystack dashboard](https://dashboard.paystack.com/#/plans?status=active)
16 |
17 | ### Running the sample locally
18 |
19 | 1. Clone this repo:
20 | ```
21 | git clone https://github.com/PaystackOSS/sample-subscriptions-app
22 | ```
23 |
24 | 2. Navigate to the root directory and install dependencies
25 | ```
26 | npm install
27 | ```
28 |
29 | 3. Rename the `.env.example` file to `.env` and add your Paystack secret key, and your server's URL including the port. This is necessary for the callback URL you'll be redirected to after completing a transaction. You can also change the default port from 5000 to a port of your choosing:
30 |
31 | ```
32 | PAYSTACK_SECRET_KEY=sk_domain_xxxxxx
33 | SERVER_URL=http://localhost:5000
34 | ```
35 |
36 | 4. Start the application
37 |
38 | ```
39 | npm start
40 | ```
41 |
42 | 5. Visit http://localhost:5000 in your browser to interact with the app. You should be able to signup/login, subscribe to a plan, and view/manage your existing plan(s).
43 |
44 |
45 |
46 | ## Contributing
47 | If you notice any issues with this app, please [open an issue](https://github.com/PaystackOSS/sample-subscriptions-app/issues/new). PRs are also more than welcome, so feel free to [submit a PR](https://github.com/PaystackOSS/sample-subscriptions-app/compare) to fix an issue, or add a new feature!
48 |
49 | ## License
50 |
51 | This repository is made available under the MIT license. Read [LICENSE.md](https://github.com/PaystackOSS/sample-subscriptions-app/blob/master/LICENSE.md) for more information.
52 |
53 |
--------------------------------------------------------------------------------
/client/account.js:
--------------------------------------------------------------------------------
1 | window.addEventListener('DOMContentLoaded', async () => {
2 | const customer_id = window.localStorage.getItem('customer_id');
3 | const customer_email = window.localStorage.getItem('customer_email');
4 |
5 | const subscriptions = await fetch(
6 | `/subscription?customer=${customer_id}`
7 | ).then((res) => res.json());
8 | let subscription = subscriptions[0];
9 |
10 | if (subscription) {
11 | const subscriptionInfoDiv = document.getElementById('subscription-info');
12 | subscriptionInfoDiv.innerHTML = `
13 |
14 | Hi ${customer_email}
15 |
You're currently on the ${subscription.plan.name} plan
16 |
17 | Status: ${subscription.status}
18 |
19 |
20 | Subscription Code: ${subscription.subscription_code}
21 |
22 |
23 | Card on file: ${subscription.authorization.brand} card ending in ${
24 | subscription.authorization.last4
25 | } expires on ${subscription.authorization.exp_month}/${
26 | subscription.authorization.exp_year
27 | }
28 |
29 |
30 | Next payment date: ${new Date(subscription.next_payment_date)}
31 |
32 |
33 | Manage subscription
36 | `;
37 | } else {
38 | const plans = await fetch('/plans', {
39 | method: 'get',
40 | })
41 | .then((res) => res.json())
42 | .catch((error) => console.log(error));
43 |
44 | let accountDashDiv = document.getElementById('account-dashboard');
45 | accountDashDiv.innerHTML +=
46 | 'You are currently not on any plan. Select a plan below to subscribe.
';
47 |
48 | let selectPlanDiv = document.createElement('div');
49 | selectPlanDiv.style.display = 'flex';
50 | selectPlanDiv.style.flexDirection = 'row';
51 |
52 | plans.forEach((plan) => {
53 | let planDiv = document.createElement('div');
54 | planDiv.innerHTML = `
55 |
56 |
57 |
${plan.name}
58 |
${plan.currency} ${
59 | plan.amount / 100
60 | }/month
61 |
${plan.description}
62 |
Subscribe
65 |
66 |
67 | `;
68 |
69 | selectPlanDiv.append(planDiv);
70 | });
71 | accountDashDiv.append(selectPlanDiv);
72 | }
73 | });
74 |
75 | async function signUpForPlan(plan_code) {
76 | let email = window.localStorage.getItem('customer_email');
77 | let { authorization_url } = await fetch('/initialize-transaction-with-plan', {
78 | method: 'POST',
79 | headers: {
80 | 'Content-Type': 'application/json',
81 | },
82 | body: JSON.stringify({
83 | email,
84 | amount: 50000,
85 | plan: plan_code,
86 | }),
87 | })
88 | .then((res) => res.json())
89 | .catch((error) => console.log(error));
90 |
91 | window.location.href = authorization_url;
92 | }
93 |
--------------------------------------------------------------------------------
/server.js:
--------------------------------------------------------------------------------
1 | require('dotenv').config({ path: './.env' });
2 | const express = require('express');
3 | const { resolve } = require('path');
4 | const Paystack = require('@paystack/paystack-sdk');
5 | const crypto = require('crypto');
6 |
7 | const app = express();
8 | const paystack = new Paystack(process.env.PAYSTACK_SECRET_KEY);
9 |
10 | app.use(express.json());
11 | app.use(express.urlencoded({ extended: true }));
12 | app.use(express.static(process.env.STATIC_DIR));
13 |
14 | app.get('/', async (req, res) => {
15 | const path = resolve(process.env.STATIC_DIR + '/login.html');
16 | res.sendFile(path);
17 | });
18 |
19 | app.get('/plans', async (req, res) => {
20 | let fetchPlansResponse = await paystack.plan.list({});
21 |
22 | if (fetchPlansResponse.status === false) {
23 | console.log('Error fetching plans: ', fetchPlansResponse.message);
24 | return res
25 | .status(400)
26 | .send(`Error fetching subscriptions: ${fetchPlansResponse.message}`);
27 | }
28 |
29 | return res.status(200).send(fetchPlansResponse.data);
30 | });
31 |
32 | app.get('/subscription', async (req, res) => {
33 | try {
34 | let { customer } = req.query;
35 |
36 | if (!customer) {
37 | throw Error('Please include a valid customer ID');
38 | }
39 |
40 | let fetchSubscriptionsResponse = await paystack.subscription.list({
41 | customer,
42 | });
43 |
44 | if (fetchSubscriptionsResponse.status === false) {
45 | console.log(
46 | 'Error fetching subscriptions: ',
47 | fetchSubscriptionsResponse.message
48 | );
49 | return res
50 | .status(400)
51 | .send(
52 | `Error fetching subscriptions: ${fetchSubscriptionsResponse.message}`
53 | );
54 | }
55 |
56 | let subscriptions = fetchSubscriptionsResponse.data.filter(
57 | (subscription) =>
58 | subscription.status === 'active' ||
59 | subscription.status === 'non-renewing'
60 | );
61 |
62 | return res.status(200).send(subscriptions);
63 | } catch (error) {
64 | console.log(error);
65 | return res.status(400).send(error.message);
66 | }
67 | });
68 |
69 | app.post('/initialize-transaction-with-plan', async (req, res) => {
70 | try {
71 | let { email, amount, plan } = req.body;
72 |
73 | if (!email || !amount || !plan) {
74 | throw Error(
75 | 'Please provide a valid customer email, amount to charge, and plan code'
76 | );
77 | }
78 |
79 | let initializeTransactionResponse = await paystack.transaction.initialize({
80 | email,
81 | amount,
82 | plan,
83 | channels: ['card'], // limiting the checkout to show card, as it's the only channel that subscriptions are currently available through
84 | callback_url: `${process.env.SERVER_URL}/account.html`,
85 | });
86 |
87 | if (initializeTransactionResponse.status === false) {
88 | return console.log(
89 | 'Error initializing transaction: ',
90 | initializeTransactionResponse.message
91 | );
92 | }
93 | let transaction = initializeTransactionResponse.data;
94 | return res.status(200).send(transaction);
95 | } catch (error) {
96 | return res.status(400).send(error.message);
97 | }
98 | });
99 |
100 | app.post('/create-subscription', async (req, res) => {
101 | try {
102 | let { customer, plan, authorization, start_date } = req.body;
103 |
104 | if (!customer || !plan) {
105 | throw Error('Please provide a valid customer code and plan ID');
106 | }
107 |
108 | let createSubscriptionResponse = await paystack.subscription.create({
109 | customer,
110 | plan,
111 | authorization,
112 | start_date,
113 | });
114 |
115 | if (createSubscriptionResponse.status === false) {
116 | return console.log(
117 | 'Error creating subscription: ',
118 | createSubscriptionResponse.message
119 | );
120 | }
121 | let subscription = createSubscriptionResponse.data;
122 | return res.status(200).send(subscription);
123 | } catch (error) {
124 | return res.status(400).send(error.message);
125 | }
126 | });
127 |
128 | app.post('/cancel-subscription', async (req, res) => {
129 | try {
130 | let { code, token } = req.body;
131 |
132 | if (!code || !token) {
133 | throw Error(
134 | 'Please provide a valid customer code and subscription token'
135 | );
136 | }
137 |
138 | let disableSubscriptionResponse = await paystack.subscription.disable({
139 | code,
140 | token,
141 | });
142 |
143 | return res.send('Subscription successfully disabled');
144 | } catch (error) {
145 | return res.status(400).send(error);
146 | }
147 | });
148 |
149 | app.get('/update-payment-method', async (req, res) => {
150 | try {
151 | const { subscription_code } = req.query;
152 | const manageSubscriptionLinkResponse =
153 | await paystack.subscription.manageLink({
154 | code: subscription_code,
155 | });
156 | if (manageSubscriptionLinkResponse.status === false) {
157 | console.log(manageSubscriptionLinkResponse.message);
158 | }
159 |
160 | let manageSubscriptionLink = manageSubscriptionLinkResponse.data.link;
161 | return res.redirect(manageSubscriptionLink);
162 | } catch (error) {
163 | console.log(error);
164 | }
165 | });
166 |
167 | app.post('/create-customer', async (req, res) => {
168 | try {
169 | let { email } = req.body;
170 |
171 | if (!email) {
172 | throw Error('Please include a valid email address');
173 | }
174 |
175 | let createCustomerResponse = await paystack.customer.create({
176 | email,
177 | });
178 |
179 | if (createCustomerResponse.status === false) {
180 | console.log('Error creating customer: ', createCustomerResponse.message);
181 | return res
182 | .status(400)
183 | .send(`Error creating customer: ${createCustomerResponse.message}`);
184 | }
185 | let customer = createCustomerResponse.data;
186 |
187 | // This is where you would save your customer to your DB. Here, we're mocking that by just storing the customer_code in a cookie
188 | res.cookie('customer', customer.customer_code);
189 | return res.status(200).send(customer);
190 | } catch (error) {
191 | console.log(error);
192 | return res.status(400).send(error.message);
193 | }
194 | });
195 |
196 | // Handle subscription events sent by Paystack
197 | app.post('/webhook', async (req, res) => {
198 | const hash = crypto
199 | .createHmac('sha512', secret)
200 | .update(JSON.stringify(req.body))
201 | .digest('hex');
202 | if (hash == req.headers['x-paystack-signature']) {
203 | const webhook = req.body;
204 | res.status(200).send('Webhook received');
205 |
206 | switch (webhook.event) {
207 | case 'subscription.create': // Sent when a subscription is created successfully
208 | case 'charge.success': // Sent when a subscription payment is made successfully
209 | case 'invoice.create': // Sent when an invoice is created to capture an upcoming subscription charge. Should happen 2-3 days before the charge happens
210 | case 'invoice.payment_failed': // Sent when a subscription payment fails
211 | case 'subscription.not_renew': // Sent when a subscription is canceled to indicate that it won't be charged on the next payment date
212 | case 'subscription.disable': // Sent when a canceled subscription reaches the end of the subscription period
213 | case 'subscription.expiring_cards': // Sent at the beginning of each month with info on what cards are expiring that month
214 | }
215 | }
216 | });
217 |
218 | app.listen(process.env.PORT, () => {
219 | console.log(`App running at http://localhost:${process.env.PORT}`);
220 | });
221 |
--------------------------------------------------------------------------------