cac payemnt integration and cbe rate exchange webscrabing

This commit is contained in:
marshal
2026-06-15 14:54:20 +03:00
parent e3c66bfa5e
commit 0aed9824ae
20 changed files with 680 additions and 76 deletions

View File

@@ -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({

View 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),
}));

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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,

View File

@@ -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,
];
/**

View File

@@ -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);