mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 06:00:55 +00:00
feat: ( payment ) create payment microservice
This commit is contained in:
@@ -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 {}
|
||||
@@ -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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user