mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
cac payemnt integration and cbe rate exchange webscrabing
This commit is contained in:
@@ -12,6 +12,7 @@ import cbeConfig from "./config/cbe.config";
|
||||
import ebirrConfig from "./config/ebirr.config";
|
||||
import cardConfig from "./config/card.config";
|
||||
import dmoneyConfig from "./config/dmoney.config";
|
||||
import cacConfig from "./config/cac.config";
|
||||
import { HealthModule } from "./modules/health/health.module";
|
||||
import { IntentsModule } from "./modules/intents/intents.module";
|
||||
import { OutboxModule } from "./modules/outbox/outbox.module";
|
||||
@@ -34,6 +35,7 @@ import { WebhooksModule } from "./modules/webhooks/webhooks.module";
|
||||
ebirrConfig,
|
||||
cardConfig,
|
||||
dmoneyConfig,
|
||||
cacConfig,
|
||||
],
|
||||
}),
|
||||
TypeOrmModule.forRootAsync({
|
||||
|
||||
13
apps/edr-payment-api/src/config/cac.config.ts
Normal file
13
apps/edr-payment-api/src/config/cac.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { registerAs } from "@nestjs/config";
|
||||
|
||||
export default registerAs("cac", () => ({
|
||||
baseUrl: process.env.CAC_BASE_URL || "",
|
||||
username: process.env.CAC_USERNAME || "",
|
||||
password: process.env.CAC_PASSWORD || "",
|
||||
appKey: process.env.CAC_APP_KEY || "",
|
||||
apiKey: process.env.CAC_API_KEY || "",
|
||||
companyServicesId: Number(process.env.CAC_COMPANY_SERVICES_ID || 0),
|
||||
currency: process.env.CAC_CURRENCY || "DJF",
|
||||
tokenTtlMs: Number(process.env.CAC_TOKEN_TTL_MS || 23 * 60 * 60 * 1000),
|
||||
otpExpiryMs: Number(process.env.CAC_OTP_EXPIRY_MS || 10 * 60 * 1000),
|
||||
}));
|
||||
@@ -0,0 +1,14 @@
|
||||
import { IsString, Length } from "class-validator";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { ConfirmPaymentRequest } from "@edr/types";
|
||||
|
||||
/** Wire shape is the shared `ConfirmPaymentRequest` contract from @edr/types. */
|
||||
export class ConfirmPaymentDto implements ConfirmPaymentRequest {
|
||||
@ApiProperty({
|
||||
description: "One-time password sent to the payer's mobile via SMS",
|
||||
example: "123456",
|
||||
})
|
||||
@IsString()
|
||||
@Length(1, 10)
|
||||
otp!: string;
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
InitiatePaymentRequestDto,
|
||||
IntentReferenceQueryDto,
|
||||
} from "./dto/initiate-payment.dto";
|
||||
import { ConfirmPaymentDto } from "./dto/confirm-payment.dto";
|
||||
import { IntentsService } from "./intents.service";
|
||||
|
||||
/**
|
||||
@@ -66,4 +67,17 @@ export class IntentsController {
|
||||
query.referenceId,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("intents/:id/confirm")
|
||||
@ApiOperation({
|
||||
summary: "Confirm an OTP-based payment intent (e.g. CAC Bank)",
|
||||
description:
|
||||
"Submits the SMS OTP to complete payment. Only supported for providers that use COLLECT_OTP clientAction.",
|
||||
})
|
||||
async confirm(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: ConfirmPaymentDto,
|
||||
): Promise<PaymentIntentSnapshot> {
|
||||
return this.intentsService.confirm(id, dto);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,14 @@ import {
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { DataSource, QueryFailedError } from "typeorm";
|
||||
import { createMerchantOrderId } from "@edr/payment-providers";
|
||||
import { createMerchantOrderId, CacBankProvider } from "@edr/payment-providers";
|
||||
import {
|
||||
ConfirmPaymentRequest,
|
||||
InitiatePaymentRequest,
|
||||
PaymentIntentSnapshot,
|
||||
PaymentReferenceType,
|
||||
PaymentService,
|
||||
ProviderMethod,
|
||||
ProviderPaymentStatus,
|
||||
ProviderStatus,
|
||||
} from "@edr/types";
|
||||
@@ -52,6 +54,7 @@ export class IntentsService {
|
||||
private readonly dataSource: DataSource,
|
||||
@Inject(PAYMENT_PROVIDER_MAP)
|
||||
private readonly providers: PaymentProviderMap,
|
||||
private readonly cacBankProvider: CacBankProvider,
|
||||
) {}
|
||||
|
||||
/* ------------------------------------------------------------------ initiate */
|
||||
@@ -85,6 +88,15 @@ export class IntentsService {
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
request.provider === ProviderMethod.CAC_BANK &&
|
||||
!request.payerAccount?.trim()
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"payerAccount (customer mobile number) is required for CAC_BANK",
|
||||
);
|
||||
}
|
||||
|
||||
const merchantOrderId = createMerchantOrderId();
|
||||
const result = await provider.initiate({
|
||||
merchantOrderId,
|
||||
@@ -134,6 +146,65 @@ export class IntentsService {
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ confirm (OTP providers) */
|
||||
|
||||
async confirm(
|
||||
intentId: string,
|
||||
request: ConfirmPaymentRequest,
|
||||
): Promise<PaymentIntentSnapshot> {
|
||||
const intent = await this.intentsRepository.findById(intentId);
|
||||
if (!intent) throw new NotFoundException("PaymentIntent not found");
|
||||
|
||||
if (intent.provider !== ProviderMethod.CAC_BANK) {
|
||||
throw new BadRequestException(
|
||||
`Confirm is not supported for provider: ${intent.provider}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (intent.status !== ProviderPaymentStatus.REQUIRES_ACTION) {
|
||||
throw new BadRequestException(
|
||||
`Intent is not awaiting confirmation (status=${intent.status})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!intent.providerOrderId) {
|
||||
throw new BadRequestException("Intent has no provider order id");
|
||||
}
|
||||
|
||||
const confirmResult = await this.cacBankProvider.confirmPayment(
|
||||
intent.providerOrderId,
|
||||
request.otp,
|
||||
);
|
||||
|
||||
if (confirmResult.reference) {
|
||||
await this.intentsRepository.update(intent.id, {
|
||||
rawInitiation: {
|
||||
...(intent.rawInitiation ?? {}),
|
||||
reference: confirmResult.reference,
|
||||
confirmResponse: confirmResult.rawResponse,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (confirmResult.status === "SUCCEEDED") {
|
||||
await this.applyProviderResult(intent.id, {
|
||||
status: ProviderPaymentStatus.SUCCEEDED,
|
||||
providerTxnId: confirmResult.providerTxnId,
|
||||
paidAt: new Date(),
|
||||
});
|
||||
} else {
|
||||
await this.applyProviderResult(intent.id, {
|
||||
status: ProviderPaymentStatus.FAILED,
|
||||
failureCode: confirmResult.failureCode,
|
||||
failureMessage: confirmResult.failureMessage,
|
||||
});
|
||||
}
|
||||
|
||||
const updated = await this.intentsRepository.findById(intent.id);
|
||||
if (!updated) throw new NotFoundException("PaymentIntent not found");
|
||||
return this.toSnapshot(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether an existing active intent can be returned as-is. An expired
|
||||
* REQUIRES_ACTION intent is retired (CANCELLED, no notification — nothing was paid)
|
||||
@@ -192,7 +263,7 @@ export class IntentsService {
|
||||
if (!refreshable || !stale || !provider) return intent;
|
||||
|
||||
try {
|
||||
const status = await provider.queryStatus(intent.merchantOrderId);
|
||||
const status = await this.queryProviderStatus(intent);
|
||||
await this.applyProviderResult(
|
||||
intent.id,
|
||||
this.fromProviderStatus(status),
|
||||
@@ -207,6 +278,26 @@ export class IntentsService {
|
||||
}
|
||||
}
|
||||
|
||||
private async queryProviderStatus(
|
||||
intent: PaymentIntent,
|
||||
): Promise<ProviderStatus> {
|
||||
const provider = this.providers.get(intent.provider);
|
||||
if (!provider) {
|
||||
throw new Error(`Unknown provider: ${intent.provider}`);
|
||||
}
|
||||
|
||||
if (intent.provider === ProviderMethod.CAC_BANK) {
|
||||
const reference = (intent.rawInitiation as { reference?: string })
|
||||
?.reference;
|
||||
return this.cacBankProvider.queryStatus(
|
||||
intent.merchantOrderId,
|
||||
reference,
|
||||
);
|
||||
}
|
||||
|
||||
return provider.queryStatus(intent.merchantOrderId);
|
||||
}
|
||||
|
||||
fromProviderStatus(status: ProviderStatus): ProviderResultInput {
|
||||
return {
|
||||
status: status.status,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Module } from "@nestjs/common";
|
||||
import { HttpModule } from "@nestjs/axios";
|
||||
import {
|
||||
CardProvider,
|
||||
CacBankProvider,
|
||||
CbeBirrProvider,
|
||||
DMoneyProvider,
|
||||
EBirrProvider,
|
||||
@@ -23,6 +24,7 @@ const providerClasses = [
|
||||
CardProvider,
|
||||
WaafiProvider,
|
||||
DMoneyProvider,
|
||||
CacBankProvider,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { SchedulerRegistry } from "@nestjs/schedule";
|
||||
import { ProviderPaymentStatus } from "@edr/types";
|
||||
import { ProviderPaymentStatus, ProviderMethod } from "@edr/types";
|
||||
import { CacBankProvider } from "@edr/payment-providers";
|
||||
import {
|
||||
PAYMENT_PROVIDER_MAP,
|
||||
PaymentProviderMap,
|
||||
@@ -38,6 +39,7 @@ export class ReconciliationService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly schedulerRegistry: SchedulerRegistry,
|
||||
@Inject(PAYMENT_PROVIDER_MAP)
|
||||
private readonly providers: PaymentProviderMap,
|
||||
private readonly cacBankProvider: CacBankProvider,
|
||||
) {
|
||||
this.intervalMs =
|
||||
config.get<number>("app.reconciliation.sweepIntervalMs") ?? 60_000;
|
||||
@@ -82,7 +84,13 @@ export class ReconciliationService implements OnModuleInit, OnModuleDestroy {
|
||||
try {
|
||||
const provider = this.providers.get(intent.provider);
|
||||
if (provider) {
|
||||
const status = await provider.queryStatus(intent.merchantOrderId);
|
||||
const status =
|
||||
intent.provider === ProviderMethod.CAC_BANK
|
||||
? await this.cacBankProvider.queryStatus(
|
||||
intent.merchantOrderId,
|
||||
(intent.rawInitiation as { reference?: string })?.reference,
|
||||
)
|
||||
: await provider.queryStatus(intent.merchantOrderId);
|
||||
const result = this.intentsService.fromProviderStatus(status);
|
||||
if (result.status !== intent.status || result.providerTxnId) {
|
||||
await this.intentsService.applyProviderResult(intent.id, result);
|
||||
|
||||
Reference in New Issue
Block a user