first commit

This commit is contained in:
Mamadou Khoussa [028918 DSI/DAC/DIF/DS] 2025-10-22 00:14:41 +00:00
parent ad91d5c150
commit bde4f90235
20 changed files with 832 additions and 122 deletions

View File

@ -2,6 +2,7 @@
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import { off } from 'process';
import tseslint from 'typescript-eslint';
export default tseslint.config(
@ -27,6 +28,8 @@ export default tseslint.config(
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unsafe-assignment':'off',
'eslint-disable-next-line @typescript-eslint/no-unsafe-call':'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
'prettier/prettier': ['error', {

67
package-lock.json generated
View File

@ -9,6 +9,7 @@
"version": "0.0.1",
"license": "UNLICENSED",
"dependencies": {
"@nestjs/axios": "^4.0.1",
"@nestjs/bull": "^11.0.4",
"@nestjs/cache-manager": "^3.0.1",
"@nestjs/common": "^11.0.1",
@ -23,6 +24,7 @@
"@prisma/client": "^6.17.1",
"bcrypt": "^6.0.0",
"cache-manager-redis-store": "^3.0.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
@ -2192,6 +2194,17 @@
"@tybys/wasm-util": "^0.10.0"
}
},
"node_modules/@nestjs/axios": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-4.0.1.tgz",
"integrity": "sha512-68pFJgu+/AZbWkGu65Z3r55bTsCPlgyKaV4BSG8yUAD72q1PPuyVRgUwFv6BxdnibTUHlyxm06FmYWNC+bjN7A==",
"license": "MIT",
"peerDependencies": {
"@nestjs/common": "^10.0.0 || ^11.0.0",
"axios": "^1.3.1",
"rxjs": "^7.0.0"
}
},
"node_modules/@nestjs/bull": {
"version": "11.0.4",
"resolved": "https://registry.npmjs.org/@nestjs/bull/-/bull-11.0.4.tgz",
@ -4461,9 +4474,20 @@
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/axios": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
"license": "MIT",
"peer": true,
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/babel-jest": {
"version": "30.2.0",
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz",
@ -5031,6 +5055,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/class-transformer": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz",
"integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==",
"license": "MIT"
},
"node_modules/class-validator": {
"version": "0.14.2",
"resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.2.tgz",
@ -5211,7 +5241,6 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
@ -5524,7 +5553,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.4.0"
@ -5783,7 +5811,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@ -6428,6 +6455,27 @@
"dev": true,
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/foreground-child": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
@ -6477,7 +6525,6 @@
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"dev": true,
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
@ -6494,7 +6541,6 @@
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
@ -6504,7 +6550,6 @@
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dev": true,
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
@ -6879,7 +6924,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
@ -9490,6 +9534,13 @@
"node": ">= 0.10"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT",
"peer": true
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",

View File

@ -20,6 +20,7 @@
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs/axios": "^4.0.1",
"@nestjs/bull": "^11.0.4",
"@nestjs/cache-manager": "^3.0.1",
"@nestjs/common": "^11.0.1",
@ -34,6 +35,7 @@
"@prisma/client": "^6.17.1",
"bcrypt": "^6.0.0",
"cache-manager-redis-store": "^3.0.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"

View File

@ -3,3 +3,6 @@ npx prisma migrate dev --name init
# Générer le client Prisma
npx prisma generate
# Format
npx prisma format

View File

@ -0,0 +1,110 @@
/*
Warnings:
- A unique constraint covering the columns `[partnerId,code]` on the table `Plan` will be added. If there are existing duplicate values, this will fail.
- Added the required column `code` to the `Plan` table without a default value. This is not possible if the table is not empty.
- Added the required column `partnerId` to the `Plan` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "Plan" ADD COLUMN "code" TEXT NOT NULL,
ADD COLUMN "features" JSONB,
ADD COLUMN "intervalCount" INTEGER NOT NULL DEFAULT 1,
ADD COLUMN "limits" JSONB,
ADD COLUMN "partnerId" TEXT NOT NULL,
ADD COLUMN "trialDays" INTEGER NOT NULL DEFAULT 0;
-- AlterTable
ALTER TABLE "Webhook" ADD COLUMN "partnerId" TEXT;
-- CreateTable
CREATE TABLE "Invoice" (
"id" TEXT NOT NULL,
"number" TEXT NOT NULL,
"subscriptionId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"partnerId" TEXT NOT NULL,
"paymentId" TEXT,
"amount" DOUBLE PRECISION NOT NULL,
"currency" TEXT NOT NULL,
"status" TEXT NOT NULL,
"billingPeriodStart" TIMESTAMP(3) NOT NULL,
"billingPeriodEnd" TIMESTAMP(3) NOT NULL,
"dueDate" TIMESTAMP(3) NOT NULL,
"paidAt" TIMESTAMP(3),
"items" JSONB NOT NULL,
"attempts" INTEGER NOT NULL DEFAULT 0,
"failureReason" TEXT,
"metadata" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Invoice_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Notification" (
"id" TEXT NOT NULL,
"partnerId" TEXT NOT NULL,
"userId" TEXT,
"type" TEXT NOT NULL,
"channel" TEXT NOT NULL,
"recipient" TEXT NOT NULL,
"subject" TEXT,
"content" TEXT NOT NULL,
"templateId" TEXT,
"status" TEXT NOT NULL,
"batchId" TEXT,
"scheduledFor" TIMESTAMP(3),
"sentAt" TIMESTAMP(3),
"failedAt" TIMESTAMP(3),
"failureReason" TEXT,
"response" JSONB,
"metadata" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Notification_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Invoice_number_key" ON "Invoice"("number");
-- CreateIndex
CREATE UNIQUE INDEX "Invoice_paymentId_key" ON "Invoice"("paymentId");
-- CreateIndex
CREATE INDEX "Invoice_subscriptionId_idx" ON "Invoice"("subscriptionId");
-- CreateIndex
CREATE INDEX "Invoice_partnerId_status_idx" ON "Invoice"("partnerId", "status");
-- CreateIndex
CREATE INDEX "Plan_partnerId_active_idx" ON "Plan"("partnerId", "active");
-- CreateIndex
CREATE UNIQUE INDEX "Plan_partnerId_code_key" ON "Plan"("partnerId", "code");
-- AddForeignKey
ALTER TABLE "Plan" ADD CONSTRAINT "Plan_partnerId_fkey" FOREIGN KEY ("partnerId") REFERENCES "Partner"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Invoice" ADD CONSTRAINT "Invoice_subscriptionId_fkey" FOREIGN KEY ("subscriptionId") REFERENCES "Subscription"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Invoice" ADD CONSTRAINT "Invoice_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Invoice" ADD CONSTRAINT "Invoice_partnerId_fkey" FOREIGN KEY ("partnerId") REFERENCES "Partner"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Invoice" ADD CONSTRAINT "Invoice_paymentId_fkey" FOREIGN KEY ("paymentId") REFERENCES "Payment"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Webhook" ADD CONSTRAINT "Webhook_partnerId_fkey" FOREIGN KEY ("partnerId") REFERENCES "Partner"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_partnerId_fkey" FOREIGN KEY ("partnerId") REFERENCES "Partner"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -0,0 +1,3 @@
export class MTNAdapter{
}

View File

@ -0,0 +1,5 @@
//todo
export class OperatorsController{
}

View File

@ -0,0 +1,7 @@
//todo tomaj
export class OperatorsService{
getAdapter(code: any, country: any) :any{
throw new Error('Method not implemented.');
}
}

View File

@ -0,0 +1,4 @@
export class MTNTransformer{
}

View File

@ -0,0 +1,3 @@
export class OrangeTransformer{
}

View File

@ -1,35 +0,0 @@
import { IsString, IsNumber, IsOptional, IsUrl, Min } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class ChargeDto {
@ApiProperty()
@IsString()
userToken: string;
@ApiProperty()
@IsNumber()
@Min(0)
amount: number;
@ApiProperty()
@IsString()
currency: string;
@ApiProperty()
@IsString()
description: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
reference?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsUrl()
callbackUrl?: string;
@ApiProperty({ required: false })
@IsOptional()
metadata?: Record<string, any>;
}

View File

@ -0,0 +1,161 @@
import {
IsString,
IsNumber,
IsOptional,
IsUrl,
Min,
IsEnum,
IsDateString,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
export class ChargeDto {
@ApiProperty({ description: 'User token from authentication' })
@IsString()
userToken: string;
@ApiProperty({ description: 'Amount to charge' })
@IsNumber()
@Min(0)
amount: number;
@ApiProperty({ description: 'Currency code (XOF, XAF, USD, etc.)' })
@IsString()
currency: string;
@ApiProperty({ description: 'Payment description' })
@IsString()
description: string;
@ApiProperty({ required: false, description: 'Unique payment reference' })
@IsOptional()
@IsString()
reference?: string;
@ApiProperty({ required: false, description: 'Subscription ID if recurring' })
@IsOptional()
@IsString()
subscriptionId?: string;
@ApiProperty({ required: false, description: 'Callback URL for notifications' })
@IsOptional()
@IsUrl()
callbackUrl?: string;
@ApiProperty({ required: false, description: 'Additional metadata' })
@IsOptional()
metadata?: Record<string, any>;
@ApiProperty({ required: false, description: 'partnerId ' })
@IsOptional()
partnerId: string;
}
export class RefundDto {
@ApiProperty({ required: false, description: 'Amount to refund (partial refund)' })
@IsOptional()
@IsNumber()
@Min(0)
amount?: number;
@ApiProperty({ description: 'Reason for refund' })
@IsString()
reason: string;
@ApiProperty({ required: false, description: 'Additional metadata' })
@IsOptional()
metadata?: Record<string, any>;
}
export class PaymentQueryDto {
@ApiProperty({ required: false, enum: ['PENDING', 'SUCCESS', 'FAILED', 'REFUNDED'] })
@IsOptional()
@IsEnum(['PENDING', 'SUCCESS', 'FAILED', 'REFUNDED'])
status?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
userId?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
subscriptionId?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsDateString()
startDate?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsDateString()
endDate?: string;
@ApiProperty({ required: false, default: 1 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
page?: number = 1;
@ApiProperty({ required: false, default: 20 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
limit?: number = 20;
}
export class PaymentResponseDto {
@ApiProperty()
id: string;
@ApiProperty()
userId: string;
@ApiProperty()
amount: number;
@ApiProperty()
currency: string;
@ApiProperty()
description: string;
@ApiProperty()
reference: string;
@ApiProperty({ required: false })
operatorReference?: string;
@ApiProperty({ enum: ['PENDING', 'SUCCESS', 'FAILED', 'REFUNDED'] })
status: string;
@ApiProperty({ required: false })
failureReason?: string;
@ApiProperty({ required: false })
completedAt?: Date;
@ApiProperty()
createdAt: Date;
@ApiProperty()
updatedAt: Date;
}
export class PaymentListResponseDto {
@ApiProperty({ type: [PaymentResponseDto] })
data: PaymentResponseDto[];
@ApiProperty()
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}

View File

@ -0,0 +1,204 @@
import {
Controller,
Post,
Get,
Body,
Param,
Query,
UseGuards,
Request,
HttpCode,
HttpStatus,
BadRequestException,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiBearerAuth,
ApiResponse,
ApiQuery,
} from '@nestjs/swagger';
import { PaymentsService } from './payments.service';
import {
ChargeDto,
RefundDto,
PaymentQueryDto,
PaymentResponseDto,
PaymentListResponseDto,
} from './dto/payment.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { ApiKeyGuard } from '../../common/guards/api-key.guard';
@ApiTags('payments')
@Controller('payments')
export class PaymentsController {
constructor(private readonly paymentsService: PaymentsService) {}
@Post('charge')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Create a new charge' })
@ApiResponse({
status: 201,
description: 'Payment created successfully',
type: PaymentResponseDto,
})
@ApiResponse({ status: 400, description: 'Bad request' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
async createCharge(@Request() req, @Body() chargeDto: ChargeDto) {
return this.paymentsService.createCharge({
...chargeDto,
partnerId: req.user.partnerId,
});
}
@Post(':paymentId/refund')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Refund a payment' })
@ApiResponse({
status: 200,
description: 'Refund processed successfully',
})
@ApiResponse({ status: 404, description: 'Payment not found' })
async refundPayment(
@Request() req,
@Param('paymentId') paymentId: string,
@Body() refundDto: RefundDto,
) {
return this.paymentsService.refundPayment(
paymentId,
req.user.partnerId,
refundDto,
);
}
@Get(':paymentId')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get payment details' })
@ApiResponse({
status: 200,
description: 'Payment details retrieved',
type: PaymentResponseDto,
})
@ApiResponse({ status: 404, description: 'Payment not found' })
async getPayment(@Request() req, @Param('paymentId') paymentId: string) {
return this.paymentsService.getPayment(paymentId, req.user.partnerId);
}
@Get()
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'List payments' })
@ApiQuery({ name: 'status', required: false, enum: ['PENDING', 'SUCCESS', 'FAILED', 'REFUNDED'] })
@ApiQuery({ name: 'userId', required: false })
@ApiQuery({ name: 'subscriptionId', required: false })
@ApiQuery({ name: 'startDate', required: false, type: Date })
@ApiQuery({ name: 'endDate', required: false, type: Date })
@ApiQuery({ name: 'page', required: false, type: Number, default: 1 })
@ApiQuery({ name: 'limit', required: false, type: Number, default: 20 })
@ApiResponse({
status: 200,
description: 'List of payments',
type: PaymentListResponseDto,
})
async listPayments(@Request() req, @Query() query: PaymentQueryDto) {
return this.paymentsService.listPayments({
partnerId: req.user.partnerId,
...query,
});
}
@Get('reference/:reference')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get payment by reference' })
@ApiResponse({
status: 200,
description: 'Payment details retrieved',
type: PaymentResponseDto,
})
async getPaymentByReference(
@Request() req,
@Param('reference') reference: string,
) {
return this.paymentsService.getPaymentByReference(
reference,
req.user.partnerId,
);
}
@Post(':paymentId/retry')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Retry a failed payment' })
@ApiResponse({
status: 200,
description: 'Payment retry initiated',
})
@ApiResponse({ status: 400, description: 'Payment cannot be retried' })
async retryPayment(
@Request() req,
@Param('paymentId') paymentId: string,
) {
return this.paymentsService.retryPayment(paymentId, req.user.partnerId);
}
@Get('statistics/summary')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get payment statistics' })
@ApiQuery({ name: 'period', required: false, enum: ['daily', 'weekly', 'monthly', 'yearly'] })
@ApiQuery({ name: 'startDate', required: false, type: Date })
@ApiQuery({ name: 'endDate', required: false, type: Date })
async getStatistics(
@Request() req,
@Query('period') period?: string,
@Query('startDate') startDate?: string,
@Query('endDate') endDate?: string,
) {
return this.paymentsService.getStatistics({
partnerId: req.user.partnerId,
period: period || 'monthly',
startDate: startDate ? new Date(startDate) : undefined,
endDate: endDate ? new Date(endDate) : undefined,
});
}
@Post('validate')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Validate payment before processing' })
async validatePayment(@Request() req, @Body() chargeDto: ChargeDto) {
return this.paymentsService.validatePayment({
...chargeDto,
partnerId: req.user.partnerId,
});
}
// Webhook endpoints
@Post('webhook/callback')
@UseGuards(ApiKeyGuard)
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Webhook callback for payment updates' })
async handleWebhook(@Request() req, @Body() payload: any) {
const signature = req.headers['x-webhook-signature'];
const event = req.headers['x-webhook-event'];
if (!signature || !event) {
throw new BadRequestException('Missing webhook headers');
}
return this.paymentsService.handleWebhook({
partnerId: req.partner.id,
event,
payload,
signature,
});
}
}

View File

@ -2,11 +2,30 @@ import { Injectable, BadRequestException } from '@nestjs/common';
import { OperatorsService } from '../operators/operators.service';
import { PrismaService } from '../../shared/services/prisma.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { ChargeDto } from './dto/charge.dto';
import { PaymentStatus } from '@prisma/client';
import { ChargeDto } from './dto/payment.dto';
import { RefundDto } from './dto/payment.dto';
import { PaymentStatus } from 'generated/prisma';
@Injectable()
export class PaymentsService {
handleWebhook(arg0: { partnerId: any; event: any; payload: any; signature: any; }) {
throw new Error('Method not implemented.');
}
getPaymentByReference(reference: string, partnerId: any) {
throw new Error('Method not implemented.');
}
getPayment(paymentId: string, partnerId: any) {
throw new Error('Method not implemented.');
}
refundPayment(paymentId: string, partnerId: any, refundDto: RefundDto) {
throw new Error('Method not implemented.');
}
retryPayment(paymentId: any, attempt: any) {
throw new Error('Method not implemented.');
}
processPayment(paymentId: any) :any{
throw new Error('Method not implemented.');
}
constructor(
private readonly operatorsService: OperatorsService,
private readonly prisma: PrismaService,
@ -102,4 +121,166 @@ export class PaymentsService {
// Implémenter la notification webhook
// Utiliser Bull Queue pour gérer les retries
}
// Ajouter ces méthodes dans PaymentsService
async listPayments(filters: any) {
const where: any = {
partnerId: filters.partnerId,
};
if (filters.status) {
where.status = filters.status;
}
if (filters.userId) {
where.userId = filters.userId;
}
if (filters.subscriptionId) {
where.subscriptionId = filters.subscriptionId;
}
if (filters.startDate || filters.endDate) {
where.createdAt = {};
if (filters.startDate) {
where.createdAt.gte = new Date(filters.startDate);
}
if (filters.endDate) {
where.createdAt.lte = new Date(filters.endDate);
}
}
const page = filters.page || 1;
const limit = filters.limit || 20;
const skip = (page - 1) * limit;
const [payments, total] = await Promise.all([
this.prisma.payment.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
include: {
user: {
select: {
id: true,
msisdn: true,
},
},
subscription: {
select: {
id: true,
planId: true,
},
},
},
}),
this.prisma.payment.count({ where }),
]);
return {
data: payments,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
async getStatistics(params: {
partnerId: string;
period: string;
startDate?: Date;
endDate?: Date;
}) {
const { partnerId, period, startDate, endDate } = params;
const where: any = { partnerId };
if (startDate || endDate) {
where.createdAt = {};
if (startDate) where.createdAt.gte = startDate;
if (endDate) where.createdAt.lte = endDate;
}
const [
totalPayments,
successfulPayments,
failedPayments,
totalRevenue,
avgPaymentAmount,
] = await Promise.all([
this.prisma.payment.count({ where }),
this.prisma.payment.count({ where: { ...where, status: 'SUCCESS' } }),
this.prisma.payment.count({ where: { ...where, status: 'FAILED' } }),
this.prisma.payment.aggregate({
where: { ...where, status: 'SUCCESS' },
_sum: { amount: true },
}),
this.prisma.payment.aggregate({
where: { ...where, status: 'SUCCESS' },
_avg: { amount: true },
}),
]);
const successRate = totalPayments > 0
? (successfulPayments / totalPayments) * 100
: 0;
return {
totalPayments,
successfulPayments,
failedPayments,
successRate: Math.round(successRate * 100) / 100,
totalRevenue: totalRevenue._sum.amount || 0,
avgPaymentAmount: avgPaymentAmount._avg.amount || 0,
period,
startDate,
endDate,
};
}
async validatePayment(params: any) {
// Valider le user token
const user = await this.prisma.user.findUnique({
where: { userToken: params.userToken },
});
if (!user) {
return {
valid: false,
error: 'Invalid user token',
};
}
// Vérifier les limites
const todayPayments = await this.prisma.payment.count({
where: {
userId: user.id,
status: 'SUCCESS',
createdAt: {
gte: new Date(new Date().setHours(0, 0, 0, 0)),
},
},
});
if (todayPayments >= 10) {
return {
valid: false,
error: 'Daily payment limit reached',
};
}
return {
valid: true,
user: {
id: user.id,
msisdn: user.msisdn,
country: user.country,
},
};
}
}

View File

@ -1,5 +1,5 @@
import { Process, Processor } from '@nestjs/bull';
import { Job } from 'bull';
import bull from 'bull';
import { PaymentsService } from '../payments.service';
import { WebhookService } from '../services/webhook.service';
@ -11,7 +11,7 @@ export class PaymentProcessor {
) {}
@Process('process-payment')
async handlePayment(job: Job) {
async handlePayment(job: bull.Job) {
const { paymentId } = job.data;
try {
@ -36,7 +36,7 @@ export class PaymentProcessor {
}
@Process('retry-payment')
async handleRetry(job: Job) {
async handleRetry(job: bull.Job) {
const { paymentId, attempt } = job.data;
try {

View File

@ -0,0 +1,8 @@
//todo
export class WebhookService{
send(arg0: { url: any; event: string; payload: any; }) {
throw new Error('Method not implemented.');
}
}

View File

@ -1,15 +1,15 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../../shared/services/prisma.service';
import { PaymentsService } from '../../payments/payments.service';
import { InjectQueue } from '@nestjs/bull';
import { Queue } from 'bull';
import bull from 'bull';
@Injectable()
export class BillingService {
constructor(
private readonly prisma: PrismaService,
private readonly paymentsService: PaymentsService,
@InjectQueue('billing') private billingQueue: Queue,
@InjectQueue('billing') private billingQueue: bull.Queue,
) {}
async processBilling(subscriptionId: string) {

View File

@ -1,22 +1,24 @@
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bull';
import { Queue } from 'bull';
import bull from 'bull';
import { PrismaService } from '../../shared/services/prisma.service';
import { PaymentsService } from '../payments/payments.service';
import { CreateSubscriptionDto, UpdateSubscriptionDto } from './dto/subscription.dto';
import { SubscriptionStatus } from '@prisma/client';
//import { SubscriptionStatus } from '@prisma/client';
//import { SubscriptionStatus, Prisma } from '@prisma/client';
@Injectable()
export class SubscriptionsService {
constructor(
private readonly prisma: PrismaService,
private readonly paymentsService: PaymentsService,
@InjectQueue('subscriptions') private subscriptionQueue: Queue,
@InjectQueue('billing') private billingQueue: Queue,
@InjectQueue('subscriptions') private subscriptionQueue: bull.Queue,
@InjectQueue('billing') private billingQueue: bull.Queue,
) {}
async create(partnerId: string, dto: CreateSubscriptionDto) {
// Vérifier l'utilisateur
const user = await this.prisma.user.findFirst({
where: {
userToken: dto.userToken,

View File

@ -2,11 +2,9 @@ import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
constructor() {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
super({
log: ['query', 'info', 'warn', 'error'],
});