├── .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 | --------------------------------------------------------------------------------