feat: ( payment ) create payment microservice

This commit is contained in:
Abubeker Yasin
2026-06-11 15:25:24 +03:00
parent 3235567b41
commit 2b430f8e76
54 changed files with 2809 additions and 0 deletions

View File

@@ -0,0 +1,10 @@
import { Module } from "@nestjs/common";
import { IntentsModule } from "../intents/intents.module";
import { ProvidersModule } from "../providers/providers.module";
import { ReconciliationService } from "./reconciliation.service";
@Module({
imports: [IntentsModule, ProvidersModule],
providers: [ReconciliationService],
})
export class ReconciliationModule {}

View File

@@ -0,0 +1,114 @@
import {
Inject,
Injectable,
Logger,
OnModuleDestroy,
OnModuleInit,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { SchedulerRegistry } from "@nestjs/schedule";
import { ProviderPaymentStatus } from "@edr/types";
import {
PAYMENT_PROVIDER_MAP,
PaymentProviderMap,
} from "../providers/providers.module";
import { PaymentIntent } from "../intents/entities/payment-intent.entity";
import { IntentsRepository } from "../intents/intents.repository";
import { IntentsService } from "../intents/intents.service";
const SWEEP_INTERVAL_NAME = "reconciliation-sweep";
/**
* Safety net (architecture.md §7.4): webhooks get lost, users abandon hosted pages. The sweep
* queries the provider for stale non-terminal intents and feeds the answer through the same
* state machine the webhooks use; intents whose provider session expired are CANCELLED.
*/
@Injectable()
export class ReconciliationService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(ReconciliationService.name);
private readonly intervalMs: number;
private readonly staleAfterMs: number;
private readonly batchSize: number;
private sweeping = false;
constructor(
config: ConfigService,
private readonly intentsRepository: IntentsRepository,
private readonly intentsService: IntentsService,
private readonly schedulerRegistry: SchedulerRegistry,
@Inject(PAYMENT_PROVIDER_MAP)
private readonly providers: PaymentProviderMap,
) {
this.intervalMs =
config.get<number>("app.reconciliation.sweepIntervalMs") ?? 60_000;
this.staleAfterMs =
config.get<number>("app.reconciliation.staleAfterMs") ?? 60_000;
this.batchSize = config.get<number>("app.reconciliation.batchSize") ?? 20;
}
onModuleInit(): void {
const interval = setInterval(() => void this.sweep(), this.intervalMs);
this.schedulerRegistry.addInterval(SWEEP_INTERVAL_NAME, interval);
}
onModuleDestroy(): void {
if (this.schedulerRegistry.doesExist("interval", SWEEP_INTERVAL_NAME)) {
this.schedulerRegistry.deleteInterval(SWEEP_INTERVAL_NAME);
}
}
async sweep(): Promise<void> {
if (this.sweeping) return;
this.sweeping = true;
try {
const cutoff = new Date(Date.now() - this.staleAfterMs);
const stale = await this.intentsRepository.findStale(
cutoff,
this.batchSize,
);
for (const intent of stale) {
await this.reconcileIntent(intent);
}
} catch (err) {
this.logger.error(
`sweep failed: ${err instanceof Error ? err.message : String(err)}`,
);
} finally {
this.sweeping = false;
}
}
private async reconcileIntent(intent: PaymentIntent): Promise<void> {
try {
const provider = this.providers.get(intent.provider);
if (provider) {
const status = 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);
}
if (
result.status === ProviderPaymentStatus.SUCCEEDED ||
result.status === ProviderPaymentStatus.FAILED ||
result.status === ProviderPaymentStatus.CANCELLED
) {
this.logger.log(`reconciled intent ${intent.id}${result.status}`);
return;
}
}
// Provider still says pending (or is unknown): expire only once the session is dead.
if (intent.expiresAt && intent.expiresAt.getTime() < Date.now()) {
await this.intentsService.expireIntent(intent.id);
this.logger.log(
`expired abandoned intent ${intent.id} (${intent.merchantOrderId})`,
);
}
} catch (err) {
// Per-intent failures must not stall the sweep; the row stays stale and is retried.
this.logger.warn(
`reconcile failed for intent ${intent.id}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
}