├── .eslintrc.js
├── .github
├── ISSUE_TEMPLATE
│ └── bug_report.md
└── dependabot.yml
├── .gitignore
├── .npmrc
├── .prettierignore
├── .prettierrc
├── .vscode
└── settings.json
├── LICENSE
├── README.md
├── contributors.md
├── docker-compose.yml
├── index.d.ts
├── nest-cli.json
├── package.json
├── pnpm-lock.yaml
├── prisma
├── migrations
│ ├── 20241023214105_init
│ │ └── migration.sql
│ └── migration_lock.toml
├── schema.prisma
└── seed.js
├── public
└── index.html
├── src
├── app.module.ts
├── logger
│ └── logger.middleware.ts
├── main.ts
├── mpesa-express
│ ├── dto
│ │ └── create-mpesa-express.dto.ts
│ ├── entities
│ │ └── mpesa-express.entity.ts
│ ├── mpesa-express.controller.ts
│ ├── mpesa-express.module.ts
│ └── mpesa-express.service.ts
└── services
│ ├── auth.service.ts
│ └── prisma.service.ts
├── test
├── app.e2e-spec.ts
└── jest-e2e.json
├── token.json
├── tsconfig.build.json
└── tsconfig.json
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | parser: '@typescript-eslint/parser',
3 | parserOptions: {
4 | project: 'tsconfig.json',
5 | tsconfigRootDir: __dirname,
6 | sourceType: 'module',
7 | },
8 | plugins: ['@typescript-eslint/eslint-plugin'],
9 | extends: ['plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended'],
10 | root: true,
11 | env: {
12 | node: true,
13 | jest: true,
14 | },
15 | ignorePatterns: ['.eslintrc.js'],
16 | rules: {
17 | '@typescript-eslint/interface-name-prefix': 'off',
18 | '@typescript-eslint/explicit-function-return-type': 'off',
19 | '@typescript-eslint/explicit-module-boundary-types': 'off',
20 | '@typescript-eslint/no-explicit-any': 'off',
21 | },
22 | };
23 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 | ---
8 |
9 | **Describe the bug**
10 | A clear and concise description of what the bug is.
11 |
12 | **To Reproduce**
13 | Steps to reproduce the behavior:
14 |
15 | 1. Go to '...'
16 | 2. Click on '....'
17 | 3. Scroll down to '....'
18 | 4. See error
19 |
20 | **Expected behavior**
21 | A clear and concise description of what you expected to happen.
22 |
23 | **Screenshots**
24 | If applicable, add screenshots to help explain your problem.
25 |
26 | **Desktop (please complete the following information):**
27 |
28 | - OS: [e.g. iOS]
29 | - Browser [e.g. chrome, safari]
30 | - Version [e.g. 22]
31 |
32 | **Smartphone (please complete the following information):**
33 |
34 | - Device: [e.g. iPhone6]
35 | - OS: [e.g. iOS8.1]
36 | - Browser [e.g. stock browser, safari]
37 | - Version [e.g. 22]
38 |
39 | **Additional context**
40 | Add any other context about the problem here.
41 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # To get started with Dependabot version updates, you'll need to specify which
2 | # package ecosystems to update and where the package manifests are located.
3 | # Please see the documentation for all configuration options:
4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
5 |
6 | version: 2
7 | updates:
8 | - package-ecosystem: 'npm' # See documentation for possible values
9 | directory: '/' # Location of package manifests
10 | schedule:
11 | interval: 'daily'
12 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # compiled output
2 | /dist
3 | /node_modules
4 | /build
5 | temp
6 | # Logs
7 | logs
8 | *.log
9 | npm-debug.log*
10 | pnpm-debug.log*
11 | yarn-debug.log*
12 | yarn-error.log*
13 | lerna-debug.log*
14 |
15 | # OS
16 | .DS_Store
17 |
18 | # Tests
19 | /coverage
20 | /.nyc_output
21 |
22 | # IDEs and editors
23 | /.idea
24 | .project
25 | .classpath
26 | .c9/
27 | *.launch
28 | .settings/
29 | *.sublime-workspace
30 |
31 | # IDE - VSCode
32 | .vscode/*
33 | !.vscode/settings.json
34 | !.vscode/tasks.json
35 | !.vscode/launch.json
36 | !.vscode/extensions.json
37 |
38 | # dotenv environment variable files
39 | .env
40 | .env.development.local
41 | .env.test.local
42 | .env.production.local
43 | .env.local
44 |
45 | # temp directory
46 | .temp
47 | .tmp
48 |
49 | # Runtime data
50 | pids
51 | *.pid
52 | *.seed
53 | *.pid.lock
54 |
55 | # Diagnostic reports (https://nodejs.org/api/report.html)
56 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
57 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 |
2 |
3 | strict-peer-dependencies = false
4 | auto-install = true
5 | engine-addr="https://db.prisma.sh"
6 | generator {
7 | provider = "prisma-client-js"
8 | binaryTargets = ["native"]
9 | }
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 |
2 | node_modules
3 | dist
4 | coverage
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true,
3 | "trailingComma": "all",
4 | "printWidth": 120,
5 | "tabWidth": 4
6 | }
7 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "terminal.integrated.fontSize": 11
3 | }
4 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2018-2020, The NerdsCatpult Authors
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4 |
5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6 |
7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ### SERVER IMPLEMENTATION OF DARAJA API
2 |
3 | - This is an implementation of the Daraja API by Safaricom. This is a follow up of the previous implementation of the same API using Express.js. This time round, I have used Nestjs to implement the API.
4 | implement the API, adding other features offfered by Safaricom's Daraja API.
5 | - Typescript support is enabled in this project.
6 |
7 | #### previous implementation of the same API using Express.js can be found in the branch [expressjs](https://github.com/Domains18/NodeJsDaraja/tree/expressjs-branch)
8 |
9 | ### Installation
10 |
11 | - Clone the repository
12 |
13 | ```bash
14 | git clone https://github.com/Domains18/NodeJsDaraja.git
15 | ```
16 |
17 | - Install dependencies
18 |
19 | ```bash
20 | pnpm install
21 | ```
22 |
23 | - Create a .env file in the root directory and add the following environment variables
24 |
25 | ```bash
26 | PORT=3000
27 | CONSUMER_KEY=YOUR_CONSUMER_KEY
28 | CONSUMER_SECRET=YOUR_CONSUMER_SECRET
29 | SHORTCODE=YOUR_SHORTCODE
30 | ```
31 |
32 | - Start the server
33 |
34 | ```bash
35 | pnpm start
36 | ```
37 |
38 | - The server will be running on http://localhost:3000
39 |
40 | ### CONTRIBUTING
41 |
42 | - Fork the repository
43 | - Create a new branch (feature/bug)
44 | - Make changes
45 | - Commit changes
46 | - Push changes to your branch
47 | - Create a pull request
48 |
49 | ### LICENSE
50 |
51 | - MIT License
52 | - [LICENSE](LICENSE)
53 |
54 | ### AUTHOR
55 |
56 | - Gibson Kemboi
57 | - [Email](mailto:dev.domains18@gmail.com)
58 |
59 | ### product of [NerdsCatapult](https://nerds.africa)
60 |
--------------------------------------------------------------------------------
/contributors.md:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Domains18/NodeJsDaraja/4d90004b020895da5e95aa305af49252d5069a5d/docker-compose.yml
--------------------------------------------------------------------------------
/index.d.ts:
--------------------------------------------------------------------------------
1 | declare namespace Nodejs {
2 | export interface ProcessEnv {
3 | DATABASE_URL: string;
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/nest-cli.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/nest-cli",
3 | "collection": "@nestjs/schematics",
4 | "sourceRoot": "src",
5 | "compilerOptions": {
6 | "deleteOutDir": true
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "daraja-nestjs",
3 | "version": "0.0.1",
4 | "description": "",
5 | "author": "",
6 | "private": true,
7 | "license": "UNLICENSED",
8 | "scripts": {
9 | "build": "nest build",
10 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
11 | "start": "nest start",
12 | "start:dev": "nest start --watch",
13 | "start:debug": "nest start --debug --watch",
14 | "start:prod": "node dist/main",
15 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
16 | "test": "jest",
17 | "test:watch": "jest --watch",
18 | "test:cov": "jest --coverage",
19 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
20 | "test:e2e": "jest --config ./test/jest-e2e.json"
21 | },
22 | "dependencies": {
23 | "@liaoliaots/nestjs-redis": "^10.0.0",
24 | "@nestjs/common": "^10.4.15",
25 | "@nestjs/config": "^3.3.0",
26 | "@nestjs/core": "^10.4.15",
27 | "@nestjs/mapped-types": "*",
28 | "@nestjs/platform-express": "^10.4.15",
29 | "@prisma/client": "5.21.1",
30 | "axios": "^1.8.4",
31 | "class-transformer": "^0.5.1",
32 | "class-validator": "^0.14.1",
33 | "ioredis": "^5.4.1",
34 | "nestjs-redis": "^1.3.3",
35 | "redis": "^4.7.0",
36 | "reflect-metadata": "^0.2.0",
37 | "rxjs": "^7.8.1"
38 | },
39 | "devDependencies": {
40 | "@nestjs/cli": "^10.4.8",
41 | "@nestjs/schematics": "^10.2.3",
42 | "@nestjs/testing": "^10.4.15",
43 | "@types/express": "^4.17.17",
44 | "@types/jest": "^29.5.2",
45 | "@types/node": "^22.10.2",
46 | "@types/supertest": "^6.0.0",
47 | "@typescript-eslint/eslint-plugin": "^8.19.0",
48 | "@typescript-eslint/parser": "^8.29.0",
49 | "eslint": "^8.42.0",
50 | "eslint-config-prettier": "^9.0.0",
51 | "eslint-plugin-prettier": "^5.0.0",
52 | "jest": "^29.5.0",
53 | "prettier": "^3.4.2",
54 | "prisma": "5.21.1",
55 | "source-map-support": "^0.5.21",
56 | "supertest": "^7.0.0",
57 | "ts-jest": "^29.1.0",
58 | "ts-loader": "^9.4.3",
59 | "ts-node": "^10.9.1",
60 | "tsconfig-paths": "^4.2.0",
61 | "typescript": "^5.7.2"
62 | },
63 | "jest": {
64 | "moduleFileExtensions": [
65 | "js",
66 | "json",
67 | "ts"
68 | ],
69 | "rootDir": "src",
70 | "testRegex": ".*\\.spec\\.ts$",
71 | "transform": {
72 | "^.+\\.(t|j)s$": "ts-jest"
73 | },
74 | "collectCoverageFrom": [
75 | "**/*.(t|j)s"
76 | ],
77 | "coverageDirectory": "../coverage",
78 | "testEnvironment": "node"
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/prisma/migrations/20241023214105_init/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateTable
2 | CREATE TABLE `Transaction` (
3 | `id` INTEGER NOT NULL AUTO_INCREMENT,
4 | `MerchantRequestID` VARCHAR(191) NOT NULL,
5 | `CheckoutRequestID` VARCHAR(191) NOT NULL,
6 | `ResultCode` VARCHAR(191) NOT NULL,
7 | `ResultDesc` VARCHAR(191) NOT NULL,
8 | `Amount` INTEGER NOT NULL,
9 | `MpesaReceiptNumber` VARCHAR(191) NOT NULL,
10 | `Balance` INTEGER NULL,
11 | `TransactionDate` DATETIME(3) NOT NULL,
12 | `PhoneNumber` VARCHAR(191) NOT NULL,
13 |
14 | PRIMARY KEY (`id`)
15 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
16 |
--------------------------------------------------------------------------------
/prisma/migrations/migration_lock.toml:
--------------------------------------------------------------------------------
1 | # Please do not edit this file manually
2 | # It should be added in your version-control system (i.e. Git)
3 | provider = "mysql"
--------------------------------------------------------------------------------
/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | // This is your Prisma schema file,
2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema
3 |
4 | // Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
5 | // Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
6 |
7 | generator client {
8 | provider = "prisma-client-js"
9 | }
10 |
11 | datasource db {
12 | provider = "mysql"
13 | url = env("DATABASE_URL")
14 | }
15 |
16 | model Transaction {
17 | id Int @id @default(autoincrement())
18 | MerchantRequestID String
19 | CheckoutRequestID String
20 | ResultCode String
21 | ResultDesc String
22 | Amount Int
23 | MpesaReceiptNumber String
24 | Balance Int?
25 | TransactionDate DateTime
26 | PhoneNumber String
27 |
28 | status Status @default(PENDING)
29 | }
30 |
31 |
32 | enum Status {
33 | PENDING
34 | COMPLETED
35 | FAILED
36 | }
--------------------------------------------------------------------------------
/prisma/seed.js:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Domains18/NodeJsDaraja/4d90004b020895da5e95aa305af49252d5069a5d/prisma/seed.js
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Document
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/src/app.module.ts:
--------------------------------------------------------------------------------
1 | import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common';
2 | import { LoggerMiddleware } from './logger/logger.middleware';
3 | import { MpesaExpressModule } from './mpesa-express/mpesa-express.module';
4 | import { ConfigModule, ConfigService } from '@nestjs/config';
5 | import { RedisModule } from '@liaoliaots/nestjs-redis';
6 |
7 |
8 | @Module({
9 | imports: [MpesaExpressModule,
10 | ConfigModule.forRoot({ isGlobal: true }),
11 | RedisModule.forRootAsync({
12 | useFactory: (configService: ConfigService) => configService.get('REDIS_URL'),
13 | inject: [ConfigService],
14 | }),
15 | ],
16 | controllers: [],
17 | providers: [],
18 | })
19 | export class AppModule {
20 | configure(consumer: MiddlewareConsumer) {
21 | consumer.apply(LoggerMiddleware).forRoutes('*');
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/logger/logger.middleware.ts:
--------------------------------------------------------------------------------
1 | import { Injectable, NestMiddleware } from '@nestjs/common';
2 | import { Request, Response, NextFunction } from 'express';
3 | import * as fs from 'fs';
4 | import * as path from 'path';
5 |
6 | @Injectable()
7 | export class LoggerMiddleware implements NestMiddleware {
8 | use(req: Request, res: Response, next: NextFunction) {
9 | const start = Date.now();
10 |
11 | res.on('finish', () => {
12 | const { method, url } = req;
13 | const { statusCode } = res;
14 | const responseTime = Date.now() - start;
15 |
16 | const logMessage = `${method} ${url} ${statusCode} - ${responseTime}ms\n`;
17 |
18 | const logDirectory = path.join(__dirname, '..', 'logs');
19 | const logFilePath = path.join(logDirectory, 'access.log');
20 |
21 | if (!fs.existsSync(logDirectory)) {
22 | fs.mkdirSync(logDirectory);
23 | }
24 |
25 | // Append the log message to the log file
26 | fs.appendFileSync(logFilePath, logMessage, { encoding: 'utf8' });
27 | });
28 |
29 | next();
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/main.ts:
--------------------------------------------------------------------------------
1 | import { Logger } from '@nestjs/common';
2 | import { NestFactory } from '@nestjs/core';
3 | import { ValidationPipe } from '@nestjs/common';
4 | import { AppModule } from './app.module';
5 |
6 | async function bootstrap() {
7 | const app = await NestFactory.create(AppModule, {
8 | cors: true,
9 | logger: ['error', 'warn', 'log', 'debug', 'verbose', 'fatal'],
10 | });
11 | app.useGlobalPipes(
12 | new ValidationPipe({
13 | whitelist: true,
14 | forbidNonWhitelisted: true,
15 | transform: true,
16 | transformOptions: {
17 | enableImplicitConversion: true,
18 | },
19 | }),
20 | );
21 | app.enableCors();
22 | const globalPrefix = 'api';
23 | app.setGlobalPrefix(globalPrefix);
24 | const port = process.env.PORT || 3000;
25 | await app.listen(port);
26 | Logger.log(`🚀 Application is running on: http://localhost:${port}/${globalPrefix}`);
27 | }
28 |
29 | bootstrap();
30 |
--------------------------------------------------------------------------------
/src/mpesa-express/dto/create-mpesa-express.dto.ts:
--------------------------------------------------------------------------------
1 | import { IsNotEmpty, IsNumber, IsString } from 'class-validator';
2 |
3 | export class CreateMpesaExpressDto {
4 | @IsNotEmpty()
5 | @IsString()
6 | phoneNum: string;
7 |
8 | @IsNotEmpty()
9 | @IsNumber()
10 | amount: number;
11 |
12 | @IsNotEmpty()
13 | @IsString()
14 | accountRef: string;
15 | }
16 |
--------------------------------------------------------------------------------
/src/mpesa-express/entities/mpesa-express.entity.ts:
--------------------------------------------------------------------------------
1 | //use this class to define the structure of the response from the mpesa express api and spread it to match the type from prisma to save it to the database
2 |
3 | export class MpesaExpress {
4 | MerhcantRequestID: string;
5 | CheckoutRequestID: string;
6 | ResponseCode: string;
7 | ResultDesc: string;
8 | ResultDescription: string;
9 | ResultCode: string;
10 | }
11 |
--------------------------------------------------------------------------------
/src/mpesa-express/mpesa-express.controller.ts:
--------------------------------------------------------------------------------
1 | import { Controller, Post, Body, Logger, HttpException, HttpStatus } from '@nestjs/common';
2 | import { MpesaExpressService } from './mpesa-express.service';
3 | import { CreateMpesaExpressDto } from './dto/create-mpesa-express.dto';
4 | import { Redis } from 'ioredis';
5 | import { RedisService } from '@liaoliaots/nestjs-redis';
6 | import { PrismaService } from 'src/services/prisma.service';
7 |
8 | interface STKCallback {
9 | Body: {
10 | stkCallback: {
11 | MerchantRequestID: string;
12 | CheckoutRequestID: string;
13 | ResultCode: number;
14 | ResultDesc: string;
15 | };
16 | };
17 | }
18 |
19 | interface PaymentStatus {status: 'PENDING' | 'COMPLETED' | 'FAILED';[key: string]: any;}
20 |
21 | @Controller('mpesa')
22 | export class MpesaExpressController {
23 | private readonly logger = new Logger(MpesaExpressController.name);
24 | private readonly redis: Redis;
25 |
26 | constructor(
27 | private readonly mpesaExpressService: MpesaExpressService,
28 | private readonly redisService: RedisService,
29 | private readonly prisma: PrismaService,
30 | ) {this.redis = this.redisService.getOrThrow();}
31 |
32 | @Post('/stkpush')
33 | async initiateSTKPush(@Body() createMpesaExpressDto: CreateMpesaExpressDto) {
34 | try {
35 | const result = await this.mpesaExpressService.stkPush(createMpesaExpressDto);
36 | return {
37 | success: true,
38 | data: result,
39 | };
40 | } catch (error) {
41 | this.logger.error(`STK Push failed: ${error.message}`);
42 | throw new HttpException('Failed to initiate payment', HttpStatus.INTERNAL_SERVER_ERROR);
43 | }
44 | }
45 |
46 | @Post('/callback')
47 | async handleSTKCallback(@Body() callback: STKCallback) {
48 | return this.mpesaExpressService.processCallback(callback);
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/mpesa-express/mpesa-express.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 | import { MpesaExpressService } from './mpesa-express.service';
3 | import { MpesaExpressController } from './mpesa-express.controller';
4 | import { AuthService } from 'src/services/auth.service';
5 | import { PrismaService } from 'src/services/prisma.service';
6 |
7 | @Module({
8 | imports: [],
9 | controllers: [MpesaExpressController],
10 | providers: [MpesaExpressService, AuthService, PrismaService],
11 | })
12 | export class MpesaExpressModule {}
13 |
--------------------------------------------------------------------------------
/src/mpesa-express/mpesa-express.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable, HttpException, Logger } from '@nestjs/common';
2 | import { CreateMpesaExpressDto } from './dto/create-mpesa-express.dto';
3 | import { AuthService } from 'src/services/auth.service';
4 | import { ConfigService } from '@nestjs/config';
5 | import { RedisService } from '@liaoliaots/nestjs-redis';
6 | import { PrismaService } from 'src/services/prisma.service';
7 | import { Redis } from 'ioredis';
8 | import axios, { AxiosError } from 'axios';
9 | import { Status } from '@prisma/client';
10 |
11 | interface MpesaConfig {
12 | shortcode: string;
13 | passkey: string;
14 | callbackUrl: string;
15 | transactionType: string;
16 | }
17 |
18 | interface STKPushRequest {
19 | BusinessShortCode: string;
20 | Password: string;
21 | Timestamp: string;
22 | TransactionType: string;
23 | Amount: number;
24 | PartyA: string;
25 | PartyB: string;
26 | PhoneNumber: string;
27 | CallBackURL: string;
28 | AccountReference: string;
29 | TransactionDesc: string;
30 | }
31 |
32 | interface TransactionCache {
33 | CheckoutRequestID: string;
34 | MerchantRequestID: string;
35 | Amount: number;
36 | PhoneNumber: string;
37 | status: Status;
38 | }
39 |
40 | @Injectable()
41 | export class MpesaExpressService {
42 | private readonly logger = new Logger(MpesaExpressService.name);
43 | private readonly mpesaConfig: MpesaConfig;
44 | private readonly redis: Redis;
45 |
46 | constructor(
47 | private readonly authService: AuthService,
48 | private readonly configService: ConfigService,
49 | private readonly redisService: RedisService,
50 | private readonly prisma: PrismaService,
51 | ) {
52 | this.mpesaConfig = {
53 | shortcode: '174379',
54 | passkey: this.configService.get('PASS_KEY'),
55 | callbackUrl: 'https://goose-merry-mollusk.ngrok-free.app/api/mpesa/callback',
56 | transactionType: 'CustomerPayBillOnline',
57 | };
58 | this.redis = this.redisService.getOrThrow();
59 | }
60 |
61 | async stkPush(dto: CreateMpesaExpressDto): Promise {
62 | try {
63 | await this.validateDto(dto);
64 |
65 | const token = await this.getAuthToken();
66 | const timestamp = this.generateTimestamp();
67 | const password = this.generatePassword(timestamp);
68 |
69 | const requestBody = this.createSTKPushRequest(dto, timestamp, password);
70 | const response = await this.sendSTKPushRequest(requestBody, token);
71 |
72 | await this.cacheInitialTransaction({
73 | CheckoutRequestID: response.data.CheckoutRequestID,
74 | MerchantRequestID: response.data.MerchantRequestID,
75 | Amount: dto.amount,
76 | PhoneNumber: dto.phoneNum,
77 | status: Status.PENDING,
78 | });
79 |
80 | return response.data;
81 | } catch (error) {
82 | this.handleError(error);
83 | }
84 | }
85 |
86 | async processCallback(callbackData: any): Promise {
87 | try {
88 | const {
89 | Body: { stkCallback },
90 | } = callbackData;
91 | const { MerchantRequestID, CheckoutRequestID, ResultCode, ResultDesc, CallbackMetadata } = stkCallback;
92 |
93 | const cachedTransaction = await this.getCachedTransaction(CheckoutRequestID);
94 | if (!cachedTransaction) {
95 | throw new HttpException('Transaction not found in cache, check the cache logic', 404);
96 | }
97 | const metadata = this.extractCallbackMetadata(CallbackMetadata?.Item || []);
98 |
99 | const transactionData = {
100 | MerchantRequestID,
101 | CheckoutRequestID,
102 | ResultCode,
103 | ResultDesc,
104 | Amount: cachedTransaction.Amount,
105 | MpesaReceiptNumber: metadata.MpesaReceiptNumber || '',
106 | Balance: metadata.Balance || 0,
107 | TransactionDate: new Date(metadata.TransactionDate || Date.now()),
108 | PhoneNumber: cachedTransaction.PhoneNumber,
109 | status: ResultCode === '0' ? Status.COMPLETED : Status.FAILED,
110 | };
111 |
112 | // Save to database
113 | await this.saveTransactionToDatabase(transactionData);
114 |
115 | // Clean up Redis cache
116 | await this.redis.del(CheckoutRequestID);
117 | } catch (error) {
118 | this.logger.error(`Callback processing failed: ${error.message}`);
119 | throw new HttpException('Failed to process callback', 500);
120 | }
121 | }
122 |
123 | private async getCachedTransaction(checkoutRequestId: string): Promise {
124 | const cached = await this.redis.get(checkoutRequestId);
125 | return cached ? JSON.parse(cached) : null;
126 | }
127 |
128 | private extractCallbackMetadata(items: any[]): Record {
129 | return items.reduce((acc, item) => ({ ...acc, [item.Name]: item.Value }), {});
130 | }
131 |
132 | private async saveTransactionToDatabase(transactionData: any): Promise {
133 | try {
134 | await this.prisma.transaction.create({
135 | data: transactionData,
136 | });
137 | this.logger.debug(`Transaction saved to database: ${transactionData.CheckoutRequestID}`);
138 | } catch (error) {
139 | this.logger.error(`Database error: ${error.message}`);
140 | throw new HttpException('Failed to save transaction', 500);
141 | }
142 | }
143 |
144 | private async cacheInitialTransaction(transactionData: TransactionCache): Promise {
145 | try {
146 | await this.redis.setex(
147 | transactionData.CheckoutRequestID,
148 | 3600, // 1 hour expiry
149 | JSON.stringify(transactionData),
150 | );
151 | } catch (error) {
152 | this.logger.error(`Error caching transaction: ${error}`);
153 | throw new HttpException('Failed to cache transaction', 500);
154 | }
155 | }
156 |
157 | private validateDto(dto: CreateMpesaExpressDto): void {
158 | const validations = [
159 | {
160 | condition: !dto.phoneNum.match(/^2547\d{8}$/),
161 | message: 'Phone number must be in the format 2547XXXXXXXX',
162 | },
163 | {
164 | condition: !dto.accountRef.match(/^[a-zA-Z0-9]{1,12}$/),
165 | message: 'Account reference must be alphanumeric and not more than 12 characters',
166 | },
167 | {
168 | condition: dto.amount <= 0,
169 | message: 'Amount must be greater than 0',
170 | },
171 | ];
172 |
173 | const failure = validations.find((validation) => validation.condition);
174 | if (failure) {
175 | this.logger.error(`Validation failed: ${failure.message}`);
176 | throw new HttpException(failure.message, 400);
177 | }
178 | }
179 |
180 | private generateTimestamp(): string {
181 | const date = new Date();
182 | const pad = (num: number) => num.toString().padStart(2, '0');
183 |
184 | return (
185 | `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}` +
186 | `${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`
187 | );
188 | }
189 |
190 | private generatePassword(timestamp: string): string {
191 | const { shortcode, passkey } = this.mpesaConfig;
192 | return Buffer.from(`${shortcode}${passkey}${timestamp}`).toString('base64');
193 | }
194 |
195 | private async getAuthToken(): Promise {
196 | const token = await this.authService.generateToken();
197 | if (!token) {
198 | throw new HttpException('Failed to generate token, please check your environment variables', 401);
199 | }
200 | return token;
201 | }
202 |
203 | private createSTKPushRequest(dto: CreateMpesaExpressDto, timestamp: string, password: string): STKPushRequest {
204 | const { shortcode, transactionType, callbackUrl } = this.mpesaConfig;
205 |
206 | return {
207 | BusinessShortCode: shortcode,
208 | Password: password,
209 | Timestamp: timestamp,
210 | TransactionType: transactionType,
211 | Amount: dto.amount,
212 | PartyA: dto.phoneNum,
213 | PartyB: shortcode,
214 | PhoneNumber: dto.phoneNum,
215 | CallBackURL: callbackUrl,
216 | AccountReference: dto.accountRef,
217 | TransactionDesc: 'szken',
218 | };
219 | }
220 |
221 | private async sendSTKPushRequest(requestBody: STKPushRequest, token: string) {
222 | return axios.post('https://sandbox.safaricom.co.ke/mpesa/stkpush/v1/processrequest', requestBody, {
223 | headers: {
224 | Authorization: `Bearer ${token}`,
225 | 'Content-Type': 'application/json',
226 | },
227 | });
228 | }
229 |
230 | private handleError(error: unknown): never {
231 | if (error instanceof HttpException) {
232 | throw error;
233 | }
234 |
235 | if (error instanceof AxiosError) {
236 | this.logger.error(`API Error: ${error.message}`, error.response?.data);
237 | throw new HttpException(`Failed to process payment: ${error.message}`, error.response?.status || 500);
238 | }
239 |
240 | this.logger.error(`Unexpected error: ${error}`);
241 | throw new HttpException('Internal server error', 500);
242 | }
243 | }
244 |
--------------------------------------------------------------------------------
/src/services/auth.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable, Logger } from '@nestjs/common';
2 | import { ConfigService } from '@nestjs/config';
3 |
4 | @Injectable()
5 | export class AuthService {
6 | constructor(private configService: ConfigService) {}
7 |
8 | private logger = new Logger('AuthServices');
9 |
10 | async generateToken() {
11 | try {
12 | const secret = this.configService.get('CONSUMER_SECRET');
13 | const consumer = this.configService.get('CONSUMER_KEY');
14 |
15 | if (!secret || !consumer) {
16 | this.logger.error('Consumer key or secret not found');
17 | return null;
18 | }
19 |
20 | const auth = Buffer.from(`${consumer}:${secret}`).toString('base64');
21 |
22 | const response = await fetch(
23 | 'https://sandbox.safaricom.co.ke/oauth/v1/generate?grant_type=client_credentials',
24 | {
25 | headers: {
26 | authorization: `Basic ${auth}`,
27 | 'Content-Type': 'application/json',
28 | },
29 | method: 'GET',
30 | },
31 | );
32 |
33 | if (!response.ok) {
34 | this.logger.error(`Failed to get token: ${response.statusText}`);
35 | return null;
36 | }
37 |
38 | const data = await response.json();
39 | return data.access_token; // Only return the token part
40 | } catch (error) {
41 | this.logger.error(`Error: ${error.message}`);
42 | return null;
43 | }
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/services/prisma.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable, OnModuleInit } from '@nestjs/common';
2 | import { PrismaClient } from '@prisma/client';
3 |
4 | @Injectable()
5 | export class PrismaService extends PrismaClient implements OnModuleInit {
6 | async onModuleInit() {
7 | await this.$connect();
8 | }
9 |
10 | async onModuleDestroy() {
11 | await this.$disconnect();
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/test/app.e2e-spec.ts:
--------------------------------------------------------------------------------
1 | import { Test, TestingModule } from '@nestjs/testing';
2 | import { INestApplication } from '@nestjs/common';
3 | import * as request from 'supertest';
4 | import { AppModule } from './../src/app.module';
5 |
6 | describe('AppController (e2e)', () => {
7 | let app: INestApplication;
8 |
9 | beforeEach(async () => {
10 | const moduleFixture: TestingModule = await Test.createTestingModule({
11 | imports: [AppModule],
12 | }).compile();
13 |
14 | app = moduleFixture.createNestApplication();
15 | await app.init();
16 | });
17 |
18 | it('/ (GET)', () => {
19 | return request(app.getHttpServer()).get('/').expect(200).expect('Hello World!');
20 | });
21 | });
22 |
--------------------------------------------------------------------------------
/test/jest-e2e.json:
--------------------------------------------------------------------------------
1 | {
2 | "moduleFileExtensions": ["js", "json", "ts"],
3 | "rootDir": ".",
4 | "testEnvironment": "node",
5 | "testRegex": ".e2e-spec.ts$",
6 | "transform": {
7 | "^.+\\.(t|j)s$": "ts-jest"
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/token.json:
--------------------------------------------------------------------------------
1 | {}
--------------------------------------------------------------------------------
/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
4 | }
5 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "module": "commonjs",
4 | "declaration": true,
5 | "removeComments": true,
6 | "emitDecoratorMetadata": true,
7 | "experimentalDecorators": true,
8 | "allowSyntheticDefaultImports": true,
9 | "target": "ES2021",
10 | "sourceMap": true,
11 | "outDir": "./dist",
12 | "baseUrl": "./",
13 | "incremental": true,
14 | "skipLibCheck": true,
15 | "strictNullChecks": false,
16 | "noImplicitAny": false,
17 | "strictBindCallApply": false,
18 | "forceConsistentCasingInFileNames": false,
19 | "noFallthroughCasesInSwitch": false
20 | }
21 | }
22 |
--------------------------------------------------------------------------------