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,16 @@
import { Controller, Get } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
@ApiTags("Health")
@Controller("health")
export class HealthController {
@Get()
@ApiOperation({ summary: "Liveness probe" })
check() {
return {
status: "ok",
service: "edr-payment-api",
timestamp: new Date().toISOString(),
};
}
}

View File

@@ -0,0 +1,7 @@
import { Module } from "@nestjs/common";
import { HealthController } from "./health.controller";
@Module({
controllers: [HealthController],
})
export class HealthModule {}

View File

@@ -0,0 +1,100 @@
import {
IsEnum,
IsIn,
IsInt,
IsOptional,
IsPositive,
IsString,
Length,
MaxLength,
} from "class-validator";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import {
InitiatePaymentRequest,
PaymentPlatform,
PaymentReferenceType,
PaymentService,
ProviderMethod,
} from "@edr/types";
/** Wire shape is the shared `InitiatePaymentRequest` contract from @edr/types. */
export class InitiatePaymentRequestDto implements InitiatePaymentRequest {
@ApiProperty({ enum: PaymentService })
@IsEnum(PaymentService)
service!: PaymentService;
@ApiProperty({ enum: PaymentReferenceType })
@IsEnum(PaymentReferenceType)
referenceType!: PaymentReferenceType;
@ApiProperty({
description:
"Domain order id (booking/shipment id) — already validated by the calling app",
})
@IsString()
@Length(1, 64)
referenceId!: string;
@ApiPropertyOptional({
description:
"Human-readable order ref shown on provider pages; defaults to referenceId",
})
@IsOptional()
@IsString()
@MaxLength(64)
orderRef?: string;
@ApiProperty({
description:
"Authoritative amount in minor units, computed server-side by the app",
})
@IsInt()
@IsPositive()
amountMinor!: number;
@ApiProperty({ example: "ETB" })
@IsString()
@Length(3, 8)
currency!: string;
@ApiProperty({ enum: ProviderMethod })
@IsEnum(ProviderMethod)
provider!: ProviderMethod;
@ApiPropertyOptional({ enum: ["web", "mobile"] })
@IsOptional()
@IsIn(["web", "mobile"])
platform?: PaymentPlatform;
@ApiPropertyOptional({
description:
"Payer wallet MSISDN for providers that pre-fill it (e.g. Waafi MWALLET_ACCOUNT)",
})
@IsOptional()
@IsString()
@MaxLength(32)
payerAccount?: string;
@ApiPropertyOptional({
description: "Caller key to dedupe retried initiations",
})
@IsOptional()
@IsString()
@MaxLength(128)
idempotencyKey?: string;
}
export class IntentReferenceQueryDto {
@ApiProperty({ enum: PaymentService })
@IsEnum(PaymentService)
service!: PaymentService;
@ApiProperty({ enum: PaymentReferenceType })
@IsEnum(PaymentReferenceType)
referenceType!: PaymentReferenceType;
@ApiProperty()
@IsString()
@Length(1, 64)
referenceId!: string;
}

View File

@@ -0,0 +1,135 @@
import { Column, Entity, Index } from "typeorm";
import { BaseEntity } from "@edr/api-common";
import {
ClientAction,
PaymentReferenceType,
PaymentService,
ProviderMethod,
ProviderPaymentStatus,
} from "@edr/types";
/**
* One payment attempt for one domain order — the platform-wide source of truth for payment
* state. `reference_id` is a soft reference into the owning app's schema (never a FK; see
* docs/payment-service/architecture.md §5).
*
* Enum-valued columns are stored as varchar (values mirror the shared @edr/types enums) so
* adding a provider/status never needs an ALTER TYPE migration.
*/
@Entity({ name: "payment_intent" })
// One ACTIVE intent per domain order; FAILED/CANCELLED attempts may accumulate as audit rows.
@Index(
"uq_payment_intent_active_reference",
["service", "referenceType", "referenceId"],
{
unique: true,
where: `status NOT IN ('FAILED','CANCELLED') AND deleted_at IS NULL`,
},
)
@Index("idx_payment_intent_sweep", ["status", "updatedAt"])
@Index("idx_payment_intent_idempotency", ["service", "idempotencyKey"])
export class PaymentIntent extends BaseEntity {
/** Owning domain app — routing discriminator for notifications. */
@Column({ name: "service", type: "varchar", length: 16 })
service!: PaymentService;
@Column({ name: "reference_type", type: "varchar", length: 16 })
referenceType!: PaymentReferenceType;
/** Domain order id (booking/shipment). Soft reference — no cross-schema FK. */
@Column({ name: "reference_id", type: "varchar", length: 64 })
referenceId!: string;
/** Provider-facing reference, prefixed PSG-/FRT- so webhooks route before a DB lookup. */
@Column({
name: "merchant_order_id",
type: "varchar",
length: 64,
unique: true,
})
merchantOrderId!: string;
@Column({ name: "provider", type: "varchar", length: 16 })
provider!: ProviderMethod;
/** Provider-side order/session id (prepay id, HPP orderId, …). */
@Column({
name: "provider_order_id",
type: "varchar",
length: 128,
nullable: true,
})
providerOrderId?: string | null;
/** Final provider transaction id, set on terminal success. */
@Index("idx_payment_intent_provider_txn")
@Column({
name: "provider_txn_id",
type: "varchar",
length: 128,
nullable: true,
})
providerTxnId?: string | null;
/** App-asserted authoritative amount in minor units. */
@Column({ name: "amount_minor", type: "integer" })
amountMinor!: number;
/** Provider-reported amount; reconciled against amount_minor (e.g. Waafi truncates decimals). */
@Column({ name: "confirmed_amount_minor", type: "integer", nullable: true })
confirmedAmountMinor?: number | null;
@Column({ name: "currency", type: "varchar", length: 8 })
currency!: string;
/** State machine: REQUIRES_ACTION → PROCESSING → SUCCEEDED | FAILED | CANCELLED (absorbing). */
@Column({
name: "status",
type: "varchar",
length: 24,
default: ProviderPaymentStatus.REQUIRES_ACTION,
})
status!: ProviderPaymentStatus;
/** Redirect/launch payload returned to the app for the user to complete payment. */
@Column({ name: "client_action", type: "jsonb", nullable: true })
clientAction?: ClientAction | null;
@Column({ name: "failure_code", type: "varchar", length: 64, nullable: true })
failureCode?: string | null;
@Column({ name: "failure_message", type: "text", nullable: true })
failureMessage?: string | null;
/** Caller-supplied initiate dedupe key (in addition to the per-reference upsert). */
@Column({
name: "idempotency_key",
type: "varchar",
length: 128,
nullable: true,
})
idempotencyKey?: string | null;
@Column({ name: "expires_at", type: "timestamptz", nullable: true })
expiresAt?: Date | null;
@Column({ name: "paid_at", type: "timestamptz", nullable: true })
paidAt?: Date | null;
/** Audit copy of the provider initiation request/response (secrets redacted upstream). */
@Column({ name: "raw_initiation", type: "jsonb", nullable: true })
rawInitiation?: Record<string, unknown> | null;
}
/** Statuses that keep the per-reference unique index "active" (block a new intent). */
export const ACTIVE_INTENT_STATUSES = [
ProviderPaymentStatus.REQUIRES_ACTION,
ProviderPaymentStatus.PROCESSING,
ProviderPaymentStatus.SUCCEEDED,
] as const;
export const TERMINAL_INTENT_STATUSES = [
ProviderPaymentStatus.SUCCEEDED,
ProviderPaymentStatus.FAILED,
ProviderPaymentStatus.CANCELLED,
] as const;

View File

@@ -0,0 +1,69 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
Query,
UseGuards,
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { PaymentIntentSnapshot } from "@edr/types";
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
import {
InitiatePaymentRequestDto,
IntentReferenceQueryDto,
} from "./dto/initiate-payment.dto";
import { IntentsService } from "./intents.service";
/**
* Internal surface — called only by the domain apps (service-authenticated), never by
* browsers. Domain validation ("is this booking payable", authoritative amount) has already
* happened in the calling app.
*/
@ApiTags("Payments (internal)")
@UseGuards(ServiceAuthGuard)
@Controller("payments")
export class IntentsController {
constructor(private readonly intentsService: IntentsService) {}
@Post("initiate")
@ApiOperation({
summary:
"Create (or idempotently reuse) a payment intent and open a provider session",
description:
"One active intent per (service, referenceType, referenceId). Re-initiating a non-terminal intent returns the existing clientAction.",
})
async initiate(
@Body() dto: InitiatePaymentRequestDto,
): Promise<PaymentIntentSnapshot> {
return this.intentsService.initiate(dto);
}
@Get("intents/:id")
@ApiOperation({
summary: "Intent status by id (pull/reconcile)",
description:
"Stale non-terminal intents trigger a provider status query before returning.",
})
async getIntent(
@Param("id", ParseUUIDPipe) id: string,
): Promise<PaymentIntentSnapshot> {
return this.intentsService.getIntent(id);
}
@Get("intents")
@ApiOperation({
summary: "Active intent status by domain reference (pull/reconcile)",
})
async getIntentByReference(
@Query() query: IntentReferenceQueryDto,
): Promise<PaymentIntentSnapshot> {
return this.intentsService.getIntentByReference(
query.service,
query.referenceType,
query.referenceId,
);
}
}

View File

@@ -0,0 +1,21 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { ProvidersModule } from "../providers/providers.module";
import { NotificationOutbox } from "../outbox/entities/notification-outbox.entity";
import { PaymentIntent } from "./entities/payment-intent.entity";
import { IntentsController } from "./intents.controller";
import { IntentsRepository } from "./intents.repository";
import { IntentsService } from "./intents.service";
@Module({
// NotificationOutbox is registered here because terminal transitions insert outbox rows
// inside the intent-finalizing transaction (transactional outbox).
imports: [
TypeOrmModule.forFeature([PaymentIntent, NotificationOutbox]),
ProvidersModule,
],
controllers: [IntentsController],
providers: [IntentsService, IntentsRepository],
exports: [IntentsService, IntentsRepository],
})
export class IntentsModule {}

View File

@@ -0,0 +1,73 @@
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { In, LessThan, Not, Repository } from "typeorm";
import { BaseRepository } from "@edr/api-common";
import {
PaymentReferenceType,
PaymentService,
ProviderPaymentStatus,
} from "@edr/types";
import { PaymentIntent } from "./entities/payment-intent.entity";
@Injectable()
export class IntentsRepository extends BaseRepository<PaymentIntent> {
constructor(
@InjectRepository(PaymentIntent)
repository: Repository<PaymentIntent>,
) {
super(repository);
}
/** The single non-FAILED/CANCELLED intent for a domain order (matches the partial unique index). */
async findActiveByReference(
service: PaymentService,
referenceType: PaymentReferenceType,
referenceId: string,
): Promise<PaymentIntent | null> {
return this.repository.findOne({
where: {
service,
referenceType,
referenceId,
status: Not(
In([ProviderPaymentStatus.FAILED, ProviderPaymentStatus.CANCELLED]),
),
},
order: { createdAt: "DESC" },
});
}
async findByMerchantOrderId(
merchantOrderId: string,
): Promise<PaymentIntent | null> {
return this.repository.findOne({ where: { merchantOrderId } });
}
async findByIdempotencyKey(
service: PaymentService,
idempotencyKey: string,
): Promise<PaymentIntent | null> {
return this.repository.findOne({
where: { service, idempotencyKey },
order: { createdAt: "DESC" },
});
}
/** Non-terminal intents untouched since `updatedBefore` — input for the reconciliation sweep. */
async findStale(
updatedBefore: Date,
limit: number,
): Promise<PaymentIntent[]> {
return this.repository.find({
where: {
status: In([
ProviderPaymentStatus.REQUIRES_ACTION,
ProviderPaymentStatus.PROCESSING,
]),
updatedAt: LessThan(updatedBefore),
},
order: { updatedAt: "ASC" },
take: limit,
});
}
}

View File

@@ -0,0 +1,342 @@
import {
BadRequestException,
Inject,
Injectable,
Logger,
NotFoundException,
} from "@nestjs/common";
import { DataSource, QueryFailedError } from "typeorm";
import { createMerchantOrderId } from "@edr/payment-providers";
import {
InitiatePaymentRequest,
MERCHANT_ORDER_PREFIX,
PaymentIntentSnapshot,
PaymentReferenceType,
PaymentService,
ProviderPaymentStatus,
ProviderStatus,
} from "@edr/types";
import {
PAYMENT_PROVIDER_MAP,
PaymentProviderMap,
} from "../providers/providers.module";
import { NotificationOutbox } from "../outbox/entities/notification-outbox.entity";
import { buildOutboxRow } from "../outbox/payment-event.factory";
import {
PaymentIntent,
TERMINAL_INTENT_STATUSES,
} from "./entities/payment-intent.entity";
import { IntentsRepository } from "./intents.repository";
const PG_UNIQUE_VIOLATION = "23505";
/** Don't hit the provider again if the intent was refreshed this recently. */
const REFRESH_MIN_AGE_MS = 5_000;
/** Result of a provider signal (webhook or status query) applied to the state machine. */
export interface ProviderResultInput {
status: ProviderPaymentStatus;
providerTxnId?: string;
paidAt?: Date;
confirmedAmountMinor?: number;
failureCode?: string;
failureMessage?: string;
}
@Injectable()
export class IntentsService {
private readonly logger = new Logger(IntentsService.name);
constructor(
private readonly intentsRepository: IntentsRepository,
// DataSource is used only for the finalize transaction (intent update + outbox insert
// must commit atomically); routine access still goes through the custom repository.
private readonly dataSource: DataSource,
@Inject(PAYMENT_PROVIDER_MAP)
private readonly providers: PaymentProviderMap,
) {}
/* ------------------------------------------------------------------ initiate */
async initiate(
request: InitiatePaymentRequest,
): Promise<PaymentIntentSnapshot> {
if (request.idempotencyKey) {
const byKey = await this.intentsRepository.findByIdempotencyKey(
request.service,
request.idempotencyKey,
);
if (byKey) return this.toSnapshot(byKey);
}
const existing = await this.intentsRepository.findActiveByReference(
request.service,
request.referenceType,
request.referenceId,
);
if (existing) {
const reusable = await this.reuseOrRetire(existing);
if (reusable) return this.toSnapshot(reusable);
}
const provider = this.providers.get(request.provider);
if (!provider) {
throw new BadRequestException(
`Unsupported payment provider: ${request.provider}`,
);
}
const merchantOrderId = `${MERCHANT_ORDER_PREFIX[request.service]}${createMerchantOrderId()}`;
const result = await provider.initiate({
merchantOrderId,
orderRef: request.orderRef ?? request.referenceId,
amountMinor: request.amountMinor,
currency: request.currency,
platform: request.platform,
payerAccount: request.payerAccount,
});
try {
const intent = await this.intentsRepository.create({
service: request.service,
referenceType: request.referenceType,
referenceId: request.referenceId,
merchantOrderId,
provider: request.provider,
providerOrderId: result.providerOrderId,
amountMinor: request.amountMinor,
currency: request.currency,
status: ProviderPaymentStatus.REQUIRES_ACTION,
clientAction: result.clientAction,
idempotencyKey: request.idempotencyKey ?? null,
expiresAt: result.expiresAt,
rawInitiation: result.rawInitiation,
});
this.logger.log(
`intent ${intent.id} created: ${request.service}/${request.referenceType}/${request.referenceId} via ${request.provider} (${merchantOrderId})`,
);
return this.toSnapshot(intent);
} catch (err) {
// Concurrent initiate for the same reference lost the partial-unique race — return the
// winner's intent. The provider session we just opened is simply abandoned.
if (
err instanceof QueryFailedError &&
(err.driverError as { code?: string })?.code === PG_UNIQUE_VIOLATION
) {
const winner = await this.intentsRepository.findActiveByReference(
request.service,
request.referenceType,
request.referenceId,
);
if (winner) return this.toSnapshot(winner);
}
throw err;
}
}
/**
* Decide whether an existing active intent can be returned as-is. An expired
* REQUIRES_ACTION intent is retired (CANCELLED, no notification — nothing was paid)
* so a fresh provider session can be opened.
*/
private async reuseOrRetire(
intent: PaymentIntent,
): Promise<PaymentIntent | null> {
const expired =
intent.status === ProviderPaymentStatus.REQUIRES_ACTION &&
intent.expiresAt != null &&
intent.expiresAt.getTime() < Date.now();
if (!expired) return intent;
await this.intentsRepository.update(intent.id, {
status: ProviderPaymentStatus.CANCELLED,
failureCode: "EXPIRED",
failureMessage: "Provider session expired before the payer acted",
});
return null;
}
/* ------------------------------------------------------------------ lookups */
async getIntent(id: string): Promise<PaymentIntentSnapshot> {
const intent = await this.intentsRepository.findById(id);
if (!intent) throw new NotFoundException("PaymentIntent not found");
return this.toSnapshot(await this.refreshIfStale(intent));
}
async getIntentByReference(
service: PaymentService,
referenceType: PaymentReferenceType,
referenceId: string,
): Promise<PaymentIntentSnapshot> {
const intent = await this.intentsRepository.findActiveByReference(
service,
referenceType,
referenceId,
);
if (!intent) throw new NotFoundException("PaymentIntent not found");
return this.toSnapshot(await this.refreshIfStale(intent));
}
/**
* Pull-side reconciliation: when a polled intent is non-terminal and stale, ask the
* provider for the truth and run the answer through the state machine. The browser
* redirect never confirms payment — this query (or a webhook) does.
*/
private async refreshIfStale(intent: PaymentIntent): Promise<PaymentIntent> {
const refreshable =
intent.status === ProviderPaymentStatus.REQUIRES_ACTION ||
intent.status === ProviderPaymentStatus.PROCESSING;
const stale = intent.updatedAt.getTime() < Date.now() - REFRESH_MIN_AGE_MS;
const provider = this.providers.get(intent.provider);
if (!refreshable || !stale || !provider) return intent;
try {
const status = await provider.queryStatus(intent.merchantOrderId);
await this.applyProviderResult(
intent.id,
this.fromProviderStatus(status),
);
return (await this.intentsRepository.findById(intent.id)) ?? intent;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.warn(
`queryStatus failed for intent ${intent.id}: ${message}; returning cached`,
);
return intent;
}
}
fromProviderStatus(status: ProviderStatus): ProviderResultInput {
return {
status: status.status,
providerTxnId: status.providerTxnId,
failureCode: status.failureCode,
failureMessage: status.failureMessage,
};
}
/* ------------------------------------------------------------------ state machine */
/**
* Advance the intent state machine with a verified provider signal. Terminal states are
* absorbing; a terminal transition writes the notification_outbox row IN THE SAME
* TRANSACTION as the intent update (transactional outbox — architecture.md §8).
*/
async applyProviderResult(
intentId: string,
result: ProviderResultInput,
): Promise<{ alreadyTerminal: boolean }> {
return this.dataSource.transaction(async (manager) => {
const intent = await manager
.getRepository(PaymentIntent)
.createQueryBuilder("intent")
.setLock("pessimistic_write")
.where("intent.id = :intentId", { intentId })
.getOne();
if (!intent) throw new NotFoundException("PaymentIntent not found");
if (
(TERMINAL_INTENT_STATUSES as readonly ProviderPaymentStatus[]).includes(
intent.status,
)
) {
return { alreadyTerminal: true };
}
if (result.status === ProviderPaymentStatus.SUCCEEDED) {
const paidAt = result.paidAt ?? new Date();
intent.status = ProviderPaymentStatus.SUCCEEDED;
intent.providerTxnId = result.providerTxnId ?? intent.providerTxnId;
intent.paidAt = paidAt;
intent.confirmedAmountMinor =
result.confirmedAmountMinor ?? intent.confirmedAmountMinor;
intent.failureCode = null;
intent.failureMessage = null;
await manager.save(intent);
await manager.getRepository(NotificationOutbox).save(
buildOutboxRow(intent, {
eventType: "payment.succeeded",
providerTxnId: intent.providerTxnId ?? undefined,
paidAt,
}),
);
if (
result.confirmedAmountMinor != null &&
result.confirmedAmountMinor !== intent.amountMinor
) {
this.logger.error(
`intent ${intent.id} amount mismatch: asserted=${intent.amountMinor} confirmed=${result.confirmedAmountMinor}`,
);
}
this.logger.log(
`intent ${intent.id} SUCCEEDED (txn=${intent.providerTxnId ?? "n/a"})`,
);
return { alreadyTerminal: false };
}
if (
result.status === ProviderPaymentStatus.FAILED ||
result.status === ProviderPaymentStatus.CANCELLED
) {
intent.status = result.status;
intent.providerTxnId = result.providerTxnId ?? intent.providerTxnId;
intent.failureCode = result.failureCode ?? null;
intent.failureMessage = result.failureMessage ?? null;
await manager.save(intent);
await manager.getRepository(NotificationOutbox).save(
buildOutboxRow(intent, {
eventType: "payment.failed",
failureCode: result.failureCode,
failureMessage: result.failureMessage,
}),
);
this.logger.log(
`intent ${intent.id} ${result.status} (${result.failureCode ?? "n/a"})`,
);
return { alreadyTerminal: false };
}
// Non-terminal: REQUIRES_ACTION may move to PROCESSING; never the reverse.
if (
result.status === ProviderPaymentStatus.PROCESSING &&
intent.status === ProviderPaymentStatus.REQUIRES_ACTION
) {
intent.status = ProviderPaymentStatus.PROCESSING;
}
intent.providerTxnId = result.providerTxnId ?? intent.providerTxnId;
await manager.save(intent);
return { alreadyTerminal: false };
});
}
/** Expire an abandoned intent (reconciliation sweep) — CANCELLED + payment.failed event. */
async expireIntent(intentId: string): Promise<void> {
await this.applyProviderResult(intentId, {
status: ProviderPaymentStatus.CANCELLED,
failureCode: "EXPIRED",
failureMessage: "Payment session expired before completion",
});
}
/* ------------------------------------------------------------------ mapping */
toSnapshot(intent: PaymentIntent): PaymentIntentSnapshot {
return {
intentId: intent.id,
service: intent.service,
referenceType: intent.referenceType,
referenceId: intent.referenceId,
merchantOrderId: intent.merchantOrderId,
provider: intent.provider,
status: intent.status,
amountMinor: intent.amountMinor,
currency: intent.currency,
clientAction: intent.clientAction ?? undefined,
providerTxnId: intent.providerTxnId ?? undefined,
paidAt: intent.paidAt?.toISOString(),
failureCode: intent.failureCode ?? undefined,
failureMessage: intent.failureMessage ?? undefined,
expiresAt: intent.expiresAt?.toISOString(),
};
}
}

View File

@@ -0,0 +1,55 @@
import { Column, Entity, Index } from "typeorm";
import { BaseEntity } from "@edr/api-common";
import {
PaymentEvent,
PaymentEventType,
PaymentReferenceType,
PaymentService,
} from "@edr/types";
export type OutboxStatus = "PENDING" | "SENT" | "FAILED";
/**
* Transactional outbox: a row is inserted in the SAME transaction that finalizes an intent,
* so "payment succeeded" and "a notification is owed" commit or roll back together. The relay
* drains PENDING rows and retries until acked (at-least-once delivery; consumers are idempotent).
*/
@Entity({ name: "notification_outbox" })
@Index("idx_notification_outbox_relay", ["status", "nextRetryAt"])
export class NotificationOutbox extends BaseEntity {
@Column({ name: "event_type", type: "varchar", length: 32 })
eventType!: PaymentEventType;
/** Routing discriminator — which app's mark-paid endpoint the relay delivers to. */
@Column({ name: "service", type: "varchar", length: 16 })
service!: PaymentService;
@Index("idx_notification_outbox_intent")
@Column({ name: "intent_id", type: "uuid" })
intentId!: string;
@Column({ name: "reference_type", type: "varchar", length: 16 })
referenceType!: PaymentReferenceType;
@Column({ name: "reference_id", type: "varchar", length: 64 })
referenceId!: string;
/** The full versioned event envelope delivered verbatim to the consumer. */
@Column({ name: "payload", type: "jsonb" })
payload!: PaymentEvent;
@Column({ name: "status", type: "varchar", length: 16, default: "PENDING" })
status!: OutboxStatus;
@Column({ name: "attempts", type: "integer", default: 0 })
attempts!: number;
@Column({ name: "next_retry_at", type: "timestamptz", nullable: true })
nextRetryAt?: Date | null;
@Column({ name: "last_error", type: "text", nullable: true })
lastError?: string | null;
@Column({ name: "sent_at", type: "timestamptz", nullable: true })
sentAt?: Date | null;
}

View File

@@ -0,0 +1,119 @@
import {
Inject,
Injectable,
Logger,
OnModuleDestroy,
OnModuleInit,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { SchedulerRegistry } from "@nestjs/schedule";
import { NotificationOutbox } from "./entities/notification-outbox.entity";
import { OutboxRepository } from "./outbox.repository";
import {
PAYMENT_EVENT_PUBLISHER,
PaymentEventPublisher,
} from "./publisher/payment-event-publisher";
const RELAY_INTERVAL_NAME = "outbox-relay";
/** Retry backoff: base doubles per attempt, capped. */
const BACKOFF_BASE_MS = 10_000;
const BACKOFF_CAP_MS = 10 * 60_000;
/**
* Drains the transactional outbox: PENDING rows are published (HTTP now, RabbitMQ later),
* marked SENT on ack, retried with exponential backoff on failure, and flagged FAILED after
* OUTBOX_MAX_ATTEMPTS (an alertable condition — delivery is at-least-once, never dropped
* silently). A crash between commit and publish only delays delivery.
*/
@Injectable()
export class OutboxRelayService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(OutboxRelayService.name);
private readonly intervalMs: number;
private readonly maxAttempts: number;
private readonly batchSize: number;
private draining = false;
constructor(
config: ConfigService,
private readonly outboxRepository: OutboxRepository,
private readonly schedulerRegistry: SchedulerRegistry,
@Inject(PAYMENT_EVENT_PUBLISHER)
private readonly publisher: PaymentEventPublisher,
) {
this.intervalMs = config.get<number>("notifier.relayIntervalMs") ?? 5_000;
this.maxAttempts = config.get<number>("notifier.maxAttempts") ?? 10;
this.batchSize = config.get<number>("notifier.relayBatchSize") ?? 20;
}
onModuleInit(): void {
const interval = setInterval(() => void this.drain(), this.intervalMs);
this.schedulerRegistry.addInterval(RELAY_INTERVAL_NAME, interval);
}
onModuleDestroy(): void {
if (this.schedulerRegistry.doesExist("interval", RELAY_INTERVAL_NAME)) {
this.schedulerRegistry.deleteInterval(RELAY_INTERVAL_NAME);
}
}
/** One relay pass; re-entrant ticks are skipped so slow deliveries don't overlap. */
async drain(): Promise<void> {
if (this.draining) return;
this.draining = true;
try {
const due = await this.outboxRepository.findDue(this.batchSize);
for (const row of due) {
await this.deliver(row);
}
} catch (err) {
this.logger.error(
`relay pass failed: ${err instanceof Error ? err.message : String(err)}`,
);
} finally {
this.draining = false;
}
}
private async deliver(row: NotificationOutbox): Promise<void> {
try {
await this.publisher.publish(row.payload);
await this.outboxRepository.markSent(row.id);
} catch (err) {
const message = this.describeError(err);
const attempts = row.attempts + 1;
const exhausted = attempts >= this.maxAttempts;
const backoffMs = Math.min(
BACKOFF_BASE_MS * 2 ** row.attempts,
BACKOFF_CAP_MS,
);
await this.outboxRepository.markAttemptFailed(
row,
message,
exhausted ? null : new Date(Date.now() + backoffMs),
exhausted,
);
if (exhausted) {
// ALERT: a paid order may not be confirmed in the owning app — needs operator action.
this.logger.error(
`outbox ${row.id} (${row.eventType} intent=${row.intentId}) FAILED after ${attempts} attempts: ${message}`,
);
} else {
this.logger.warn(
`outbox ${row.id} delivery attempt ${attempts} failed (retry in ${backoffMs}ms): ${message}`,
);
}
}
}
/** Connection failures surface as AggregateError with an empty message — dig out the code. */
private describeError(err: unknown): string {
if (err instanceof Error) {
if (err.message) return err.message;
const code = (err as { code?: string }).code;
if (code) return code;
const inner = (err as { errors?: unknown[] }).errors?.[0];
if (inner instanceof Error && inner.message) return inner.message;
}
return String(err);
}
}

View File

@@ -0,0 +1,20 @@
import { Module } from "@nestjs/common";
import { HttpModule } from "@nestjs/axios";
import { TypeOrmModule } from "@nestjs/typeorm";
import { NotificationOutbox } from "./entities/notification-outbox.entity";
import { OutboxRelayService } from "./outbox-relay.service";
import { OutboxRepository } from "./outbox.repository";
import { HttpPaymentEventPublisher } from "./publisher/http-payment-event-publisher";
import { PAYMENT_EVENT_PUBLISHER } from "./publisher/payment-event-publisher";
@Module({
imports: [TypeOrmModule.forFeature([NotificationOutbox]), HttpModule],
providers: [
OutboxRepository,
OutboxRelayService,
// Swap to RabbitPaymentEventPublisher here when the broker lands — nothing else changes.
{ provide: PAYMENT_EVENT_PUBLISHER, useClass: HttpPaymentEventPublisher },
],
exports: [OutboxRepository],
})
export class OutboxModule {}

View File

@@ -0,0 +1,58 @@
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { BaseRepository } from "@edr/api-common";
import { NotificationOutbox } from "./entities/notification-outbox.entity";
@Injectable()
export class OutboxRepository extends BaseRepository<NotificationOutbox> {
constructor(
@InjectRepository(NotificationOutbox)
repository: Repository<NotificationOutbox>,
) {
super(repository);
}
/**
* PENDING rows whose retry time has come, oldest first. The relay runs as a single
* non-overlapping loop per instance; with multiple service instances this should move to a
* SELECT … FOR UPDATE SKIP LOCKED claim.
*/
async findDue(limit: number): Promise<NotificationOutbox[]> {
return this.repository
.createQueryBuilder("outbox")
.where(`outbox.status = 'PENDING'`)
.andWhere(
"(outbox.next_retry_at IS NULL OR outbox.next_retry_at <= now())",
)
.orderBy("outbox.created_at", "ASC")
.take(limit)
.getMany();
}
async markSent(id: string): Promise<void> {
await this.update(id, {
status: "SENT",
sentAt: new Date(),
lastError: null,
});
}
async markAttemptFailed(
row: NotificationOutbox,
error: string,
nextRetryAt: Date | null,
exhausted: boolean,
): Promise<void> {
await this.update(row.id, {
attempts: row.attempts + 1,
lastError: error,
nextRetryAt,
status: exhausted ? "FAILED" : "PENDING",
});
}
async countBacklog(): Promise<number> {
return this.repository.count({ where: { status: "PENDING" } });
}
}

View File

@@ -0,0 +1,66 @@
import { randomUUID } from "node:crypto";
import {
PaymentEvent,
PaymentFailedEvent,
PaymentSucceededEvent,
} from "@edr/types";
import { PaymentIntent } from "../intents/entities/payment-intent.entity";
import { NotificationOutbox } from "./entities/notification-outbox.entity";
/**
* Build a ready-to-insert outbox row for a terminal intent. Pure (no DI) so the intents
* state machine can insert it inside its own DB transaction without a module cycle.
* The row id is generated here because the event envelope embeds it as `eventId`.
*/
export function buildOutboxRow(
intent: PaymentIntent,
terminal:
| { eventType: "payment.succeeded"; providerTxnId?: string; paidAt: Date }
| {
eventType: "payment.failed";
failureCode?: string;
failureMessage?: string;
},
): Partial<NotificationOutbox> {
const id = randomUUID();
const base = {
version: 1 as const,
eventId: id,
occurredAt: new Date().toISOString(),
service: intent.service,
intentId: intent.id,
referenceType: intent.referenceType,
referenceId: intent.referenceId,
merchantOrderId: intent.merchantOrderId,
provider: intent.provider,
amountMinor: intent.amountMinor,
currency: intent.currency,
};
const event: PaymentEvent =
terminal.eventType === "payment.succeeded"
? ({
...base,
eventType: "payment.succeeded",
providerTxnId: terminal.providerTxnId,
paidAt: terminal.paidAt.toISOString(),
} satisfies PaymentSucceededEvent)
: ({
...base,
eventType: "payment.failed",
failureCode: terminal.failureCode,
failureMessage: terminal.failureMessage,
} satisfies PaymentFailedEvent);
return {
id,
eventType: event.eventType,
service: intent.service,
intentId: intent.id,
referenceType: intent.referenceType,
referenceId: intent.referenceId,
payload: event,
status: "PENDING",
attempts: 0,
};
}

View File

@@ -0,0 +1,53 @@
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { HttpService } from "@nestjs/axios";
import { firstValueFrom } from "rxjs";
import { PaymentEvent, PaymentService } from "@edr/types";
import { PaymentEventPublisher } from "./payment-event-publisher";
/**
* Delivers events by POSTing to the owning app's idempotent mark-paid endpoint, routed by
* the `service` discriminator. Authenticated with the shared service token (the same secret
* the apps use to call /payments/initiate).
*/
@Injectable()
export class HttpPaymentEventPublisher implements PaymentEventPublisher {
private readonly logger = new Logger(HttpPaymentEventPublisher.name);
private readonly routes: Record<PaymentService, string>;
private readonly timeoutMs: number;
private readonly serviceToken: string;
constructor(
config: ConfigService,
private readonly http: HttpService,
) {
this.routes = {
[PaymentService.PASSENGER]:
config.get<string>("notifier.passengerUrl") ?? "",
[PaymentService.FREIGHT]: config.get<string>("notifier.freightUrl") ?? "",
};
this.timeoutMs = config.get<number>("notifier.httpTimeoutMs") ?? 10_000;
this.serviceToken = config.get<string>("app.serviceAuthToken") ?? "";
}
async publish(event: PaymentEvent): Promise<void> {
const url = this.routes[event.service];
if (!url) {
throw new Error(
`No mark-paid URL configured for service ${event.service}`,
);
}
const response = await firstValueFrom(
this.http.post(url, event, {
timeout: this.timeoutMs,
headers: this.serviceToken
? { "x-service-token": this.serviceToken }
: {},
}),
);
this.logger.log(
`delivered ${event.eventType} (${event.eventId}) to ${event.service} — HTTP ${response.status}`,
);
}
}

View File

@@ -0,0 +1,13 @@
import { PaymentEvent } from "@edr/types";
/**
* Publisher port (architecture.md §12): how a payment event leaves this service.
* HTTP implementation now; a RabbitMQ implementation later is a DI swap only — the outbox
* and relay stay exactly as they are.
*/
export interface PaymentEventPublisher {
/** Deliver one event; throw on failure so the relay can retry with backoff. */
publish(event: PaymentEvent): Promise<void>;
}
export const PAYMENT_EVENT_PUBLISHER = Symbol("PAYMENT_EVENT_PUBLISHER");

View File

@@ -0,0 +1,46 @@
import { Module } from "@nestjs/common";
import { HttpModule } from "@nestjs/axios";
import {
CardProvider,
CbeBirrProvider,
DMoneyProvider,
EBirrProvider,
PaymentProvider,
TelebirrProvider,
WaafiProvider,
} from "@edr/payment-providers";
import { ProviderMethod } from "@edr/types";
/** Injection token for the Map<ProviderMethod, PaymentProvider> used to select a gateway. */
export const PAYMENT_PROVIDER_MAP = Symbol("PAYMENT_PROVIDER_MAP");
export type PaymentProviderMap = Map<ProviderMethod, PaymentProvider>;
const providerClasses = [
TelebirrProvider,
CbeBirrProvider,
EBirrProvider,
CardProvider,
WaafiProvider,
DMoneyProvider,
];
/**
* Thin DI wiring around @edr/payment-providers — the exact provider set the passenger app
* used to construct, relocated here. After cutover this service is the only consumer of the
* provider SDK and of the provider secrets (config/{waafi,telebirr,…}.config.ts).
*/
@Module({
imports: [HttpModule.register({ timeout: 10_000 })],
providers: [
...providerClasses,
{
provide: PAYMENT_PROVIDER_MAP,
useFactory: (...providers: PaymentProvider[]): PaymentProviderMap =>
new Map(providers.map((provider) => [provider.method, provider])),
inject: providerClasses,
},
],
exports: [PAYMENT_PROVIDER_MAP, ...providerClasses],
})
export class ProvidersModule {}

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

View File

@@ -0,0 +1,57 @@
import { Column, Entity, Index } from "typeorm";
import { BaseEntity } from "@edr/api-common";
import { ProviderMethod } from "@edr/types";
/**
* Idempotency + audit record for every inbound provider webhook. The unique
* (provider, external_event_id) pair is the dedupe key: a duplicate insert hits the unique
* violation and the handler short-circuits with a 200 ack.
*/
@Entity({ name: "payment_webhook_event" })
@Index("uq_payment_webhook_event_external", ["provider", "externalEventId"], {
unique: true,
})
export class PaymentWebhookEvent extends BaseEntity {
@Column({ name: "provider", type: "varchar", length: 16 })
provider!: ProviderMethod;
/** Provider event id when given (e.g. Waafi X-Webhook-Event-Id), else derived from the payload. */
@Column({ name: "external_event_id", type: "varchar", length: 191 })
externalEventId!: string;
@Column({
name: "merchant_order_id",
type: "varchar",
length: 64,
nullable: true,
})
merchantOrderId?: string | null;
@Column({
name: "provider_txn_id",
type: "varchar",
length: 128,
nullable: true,
})
providerTxnId?: string | null;
@Column({ name: "signature_valid", type: "boolean", default: false })
signatureValid!: boolean;
/** Raw provider status string as sent (pre-mapping). */
@Column({ name: "status", type: "varchar", length: 64, nullable: true })
status?: string | null;
/** Full webhook body — hostile input, stored verbatim for audit/replay analysis. */
@Column({ name: "payload", type: "jsonb" })
payload!: Record<string, unknown>;
@Column({ name: "received_at", type: "timestamptz", default: () => "now()" })
receivedAt!: Date;
@Column({ name: "processed_at", type: "timestamptz", nullable: true })
processedAt?: Date | null;
@Column({ name: "processing_error", type: "text", nullable: true })
processingError?: string | null;
}

View File

@@ -0,0 +1,35 @@
import { Injectable } from "@nestjs/common";
import { CardProvider, CardWebhookPayload } from "@edr/payment-providers";
import { WebhookProcessorService } from "../webhook-processor.service";
@Injectable()
export class CardWebhookService {
constructor(
private readonly provider: CardProvider,
private readonly processor: WebhookProcessorService,
) {}
async handle(payload: CardWebhookPayload, signature: string): Promise<void> {
const signatureValid = this.provider.verifyWebhookSignature(
payload as unknown as Record<string, unknown>,
signature,
);
const object = payload.data.object;
const mapped = this.provider.mapWebhookStatus(object.status);
await this.processor.process({
provider: this.provider.method,
externalEventId: `${payload.id}_${payload.type}`,
merchantOrderId: object.metadata.merchantOrderId,
providerTxnId: object.transaction_id,
signatureValid,
rawStatus: object.status,
payload: payload as unknown as Record<string, unknown>,
result: {
status: mapped,
providerTxnId: object.transaction_id,
failureCode: object.status,
},
});
}
}

View File

@@ -0,0 +1,30 @@
import { Injectable } from "@nestjs/common";
import { CbeBirrProvider, CbeBirrWebhookPayload } from "@edr/payment-providers";
import { WebhookProcessorService } from "../webhook-processor.service";
@Injectable()
export class CbeBirrWebhookService {
constructor(
private readonly provider: CbeBirrProvider,
private readonly processor: WebhookProcessorService,
) {}
async handle(payload: CbeBirrWebhookPayload): Promise<void> {
const signatureValid = this.provider.verifyWebhookSignature(
payload as unknown as Record<string, unknown>,
);
const mapped = this.provider.mapWebhookStatus(payload.status);
const providerTxnId = payload.transactionId ?? payload.orderId;
await this.processor.process({
provider: this.provider.method,
externalEventId: `${payload.orderId}_${payload.status}`,
merchantOrderId: payload.merchantOrderId,
providerTxnId,
signatureValid,
rawStatus: payload.status,
payload: payload as unknown as Record<string, unknown>,
result: { status: mapped, providerTxnId, failureCode: payload.status },
});
}
}

View File

@@ -0,0 +1,34 @@
import { Injectable } from "@nestjs/common";
import { DMoneyProvider, DMoneyWebhookPayload } from "@edr/payment-providers";
import { WebhookProcessorService } from "../webhook-processor.service";
@Injectable()
export class DMoneyWebhookService {
constructor(
private readonly provider: DMoneyProvider,
private readonly processor: WebhookProcessorService,
) {}
async handle(payload: DMoneyWebhookPayload): Promise<void> {
const signatureValid = this.provider.verifyWebhookSignature(
payload as unknown as Record<string, unknown>,
);
const mapped = this.provider.mapWebhookStatus(payload.status);
await this.processor.process({
provider: this.provider.method,
externalEventId: `${payload.orderId}_${payload.status}`,
merchantOrderId: payload.merchantOrderId,
providerTxnId: payload.transactionId,
signatureValid,
rawStatus: payload.status,
payload: payload as unknown as Record<string, unknown>,
result: {
status: mapped,
providerTxnId: payload.transactionId,
paidAt: payload.paidAt ? new Date(payload.paidAt) : undefined,
failureCode: payload.status,
},
});
}
}

View File

@@ -0,0 +1,33 @@
import { Injectable } from "@nestjs/common";
import { EBirrProvider, EBirrWebhookPayload } from "@edr/payment-providers";
import { WebhookProcessorService } from "../webhook-processor.service";
@Injectable()
export class EBirrWebhookService {
constructor(
private readonly provider: EBirrProvider,
private readonly processor: WebhookProcessorService,
) {}
async handle(payload: EBirrWebhookPayload): Promise<void> {
const signatureValid = this.provider.verifyWebhookSignature(
payload as unknown as Record<string, unknown>,
);
const mapped = this.provider.mapWebhookStatus(payload.tradeStatus);
await this.processor.process({
provider: this.provider.method,
externalEventId: `${payload.orderNo}_${payload.tradeStatus}_${payload.timestamp}`,
merchantOrderId: payload.orderNo,
providerTxnId: payload.tradeNo,
signatureValid,
rawStatus: payload.tradeStatus,
payload: payload as unknown as Record<string, unknown>,
result: {
status: mapped,
providerTxnId: payload.tradeNo,
failureCode: payload.tradeStatus,
},
});
}
}

View File

@@ -0,0 +1,46 @@
import { Injectable } from "@nestjs/common";
import {
TelebirrProvider,
TelebirrWebhookPayload,
} from "@edr/payment-providers";
import { WebhookProcessorService } from "../webhook-processor.service";
@Injectable()
export class TelebirrWebhookService {
constructor(
private readonly provider: TelebirrProvider,
private readonly processor: WebhookProcessorService,
) {}
async handle(payload: TelebirrWebhookPayload): Promise<void> {
// TODO: re-enable Telebirr public-key signature verification — skipped for now
// (carried over from the passenger handler; see telebirr.provider verifyWebhookSignature).
const signatureValid = true;
const mapped = this.provider.mapWebhookTradeStatus(payload.trade_status);
const providerTxnId = payload.trans_id ?? payload.payment_order_id;
await this.processor.process({
provider: this.provider.method,
externalEventId: `${payload.payment_order_id}_${payload.trade_status}`,
merchantOrderId: payload.merch_order_id,
providerTxnId,
signatureValid,
rawStatus: payload.trade_status,
payload: payload as unknown as Record<string, unknown>,
result: {
status: mapped,
providerTxnId,
paidAt: this.parseEpochSeconds(payload.trans_end_time),
failureCode: payload.trade_status,
},
});
}
private parseEpochSeconds(raw: string | undefined): Date | undefined {
if (!raw) return undefined;
const n = parseInt(raw, 10);
if (Number.isNaN(n)) return undefined;
return new Date(n * 1000);
}
}

View File

@@ -0,0 +1,82 @@
import { Injectable, Logger } from "@nestjs/common";
import {
WaafiProvider,
WaafiWebhookHeaders,
WaafiWebhookPayload,
} from "@edr/payment-providers";
import { WebhookProcessorService } from "../webhook-processor.service";
/** Reject webhooks whose timestamp is older than this (replay protection). */
const WAAFI_REPLAY_WINDOW_SECONDS = 300;
@Injectable()
export class WaafiWebhookService {
private readonly logger = new Logger(WaafiWebhookService.name);
constructor(
private readonly provider: WaafiProvider,
private readonly processor: WebhookProcessorService,
) {}
async handle(
payload: WaafiWebhookPayload,
rawBody: string,
headers: WaafiWebhookHeaders,
): Promise<void> {
// Unsigned validation ping sent on registration — acknowledge without verifying or persisting.
if (payload.event === "webhook.test") {
this.logger.log("Waafi webhook.test ping received");
return;
}
const { payment } = payload;
const eventId = headers["x-webhook-event-id"];
const timestamp = headers["x-webhook-timestamp"];
const signature = headers["x-webhook-signature"];
const signatureValid =
this.isFresh(timestamp) &&
this.provider.verifyWebhookSignature(
rawBody,
signature,
timestamp,
eventId,
);
const mapped = this.provider.mapWebhookStatus(payment.status);
await this.processor.process({
provider: this.provider.method,
// X-Webhook-Event-Id is unique per event; fall back to a derived id if absent.
externalEventId: eventId ?? `${payment.transaction_id}_${payment.status}`,
merchantOrderId: payment.reference_id,
providerTxnId: payment.transaction_id,
signatureValid,
rawStatus: payment.status,
payload: payload as unknown as Record<string, unknown>,
result: {
status: mapped,
providerTxnId: payment.transaction_id,
paidAt: this.parseDate(payment.date),
failureCode: payment.status,
failureMessage: payment.description,
},
});
}
/** True when the webhook timestamp (unix seconds) is within the replay window. */
private isFresh(timestamp: string | undefined): boolean {
if (!timestamp) return false;
const ts = parseInt(timestamp, 10);
if (Number.isNaN(ts)) return false;
const now = Math.floor(Date.now() / 1000);
return Math.abs(now - ts) <= WAAFI_REPLAY_WINDOW_SECONDS;
}
/** Parse Waafi's "YYYY-MM-DD HH:mm:ss" payment date; undefined when unparseable. */
private parseDate(raw: string | undefined): Date | undefined {
if (!raw) return undefined;
const d = new Date(raw);
return Number.isNaN(d.getTime()) ? undefined : d;
}
}

View File

@@ -0,0 +1,44 @@
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { QueryFailedError, Repository } from "typeorm";
import { BaseRepository } from "@edr/api-common";
import { PaymentWebhookEvent } from "./entities/payment-webhook-event.entity";
const PG_UNIQUE_VIOLATION = "23505";
@Injectable()
export class WebhookEventsRepository extends BaseRepository<PaymentWebhookEvent> {
constructor(
@InjectRepository(PaymentWebhookEvent)
repository: Repository<PaymentWebhookEvent>,
) {
super(repository);
}
/**
* Insert the event, relying on the unique (provider, external_event_id) index for dedupe.
* Returns null when the event was already recorded (duplicate delivery / provider replay).
*/
async createDeduped(
data: Partial<PaymentWebhookEvent>,
): Promise<PaymentWebhookEvent | null> {
try {
return await this.create(data);
} catch (err) {
if (
err instanceof QueryFailedError &&
(err.driverError as { code?: string })?.code === PG_UNIQUE_VIOLATION
) {
return null;
}
throw err;
}
}
async markProcessed(id: string, processingError?: string): Promise<void> {
await this.update(id, {
processedAt: new Date(),
processingError: processingError ?? null,
});
}
}

View File

@@ -0,0 +1,112 @@
import { Injectable, Logger } from "@nestjs/common";
import {
MERCHANT_ORDER_PREFIX,
PaymentService,
ProviderMethod,
} from "@edr/types";
import { IntentsRepository } from "../intents/intents.repository";
import {
IntentsService,
ProviderResultInput,
} from "../intents/intents.service";
import { WebhookEventsRepository } from "./webhook-events.repository";
/** A provider webhook reduced to the fields the shared pipeline needs. */
export interface NormalizedWebhook {
provider: ProviderMethod;
/** Provider event id (or a deterministic derivation) — the dedupe key. */
externalEventId: string;
merchantOrderId: string;
providerTxnId?: string;
signatureValid: boolean;
/** Raw provider status string, stored for audit. */
rawStatus: string;
payload: Record<string, unknown>;
/** Mapped outcome to feed the intent state machine. */
result: ProviderResultInput;
}
/**
* The shared webhook pipeline every provider handler funnels into:
* persist+dedupe → signature gate → intent lookup → prefix/service cross-check →
* state machine → mark processed. Always returns (never throws) so controllers can
* ack 200 fast — providers like Waafi time out at 5s and do not retry.
*/
@Injectable()
export class WebhookProcessorService {
private readonly logger = new Logger(WebhookProcessorService.name);
constructor(
private readonly webhookEvents: WebhookEventsRepository,
private readonly intentsRepository: IntentsRepository,
private readonly intentsService: IntentsService,
) {}
async process(webhook: NormalizedWebhook): Promise<void> {
const { provider, merchantOrderId } = webhook;
const eventRow = await this.webhookEvents.createDeduped({
provider,
externalEventId: webhook.externalEventId,
merchantOrderId,
providerTxnId: webhook.providerTxnId ?? null,
signatureValid: webhook.signatureValid,
status: webhook.rawStatus,
payload: webhook.payload,
});
if (!eventRow) {
this.logger.log(
`${provider} webhook duplicate: ${webhook.externalEventId} — short-circuit OK`,
);
return;
}
if (!webhook.signatureValid) {
this.logger.warn(
`${provider} webhook signature invalid/stale for ref=${merchantOrderId}`,
);
await this.webhookEvents.markProcessed(eventRow.id, "signature-invalid");
return;
}
const intent =
await this.intentsRepository.findByMerchantOrderId(merchantOrderId);
if (!intent) {
// Tolerated: webhook may have raced the intent commit, or the reference is foreign.
// The provider gets a 200; retry/poll/reconciliation converges later.
this.logger.warn(
`${provider} webhook: no PaymentIntent for ref=${merchantOrderId}`,
);
await this.webhookEvents.markProcessed(eventRow.id, "intent-not-found");
return;
}
// Integrity guard (§10): the stateless prefix and the stored discriminator must agree.
const expectedPrefix =
MERCHANT_ORDER_PREFIX[intent.service as PaymentService];
if (expectedPrefix && !merchantOrderId.startsWith(expectedPrefix)) {
this.logger.error(
`${provider} webhook: merchantOrderId ${merchantOrderId} prefix does not match stored service ${intent.service} — refusing to process`,
);
await this.webhookEvents.markProcessed(
eventRow.id,
"service-prefix-mismatch",
);
return;
}
try {
await this.intentsService.applyProviderResult(intent.id, webhook.result);
await this.webhookEvents.markProcessed(eventRow.id);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(
`${provider} webhook processing failed for ${merchantOrderId}: ${message}`,
);
await this.webhookEvents.markProcessed(
eventRow.id,
`processing-error: ${message}`,
);
}
}
}

View File

@@ -0,0 +1,143 @@
import {
All,
Body,
Controller,
Headers,
HttpCode,
HttpStatus,
Logger,
Post,
Req,
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import {
CardWebhookPayload,
CbeBirrWebhookPayload,
DMoneyWebhookPayload,
EBirrWebhookPayload,
TelebirrWebhookPayload,
WaafiWebhookHeaders,
WaafiWebhookPayload,
} from "@edr/payment-providers";
import { TelebirrWebhookService } from "./handlers/telebirr-webhook.service";
import { CbeBirrWebhookService } from "./handlers/cbe-birr-webhook.service";
import { EBirrWebhookService } from "./handlers/ebirr-webhook.service";
import { CardWebhookService } from "./handlers/card-webhook.service";
import { WaafiWebhookService } from "./handlers/waafi-webhook.service";
import { DMoneyWebhookService } from "./handlers/dmoney-webhook.service";
/**
* The ONLY public surface of the payment service — the single registered webhook URL per
* provider for the whole platform. No service auth here (provider-facing); trust comes from
* signature verification inside each handler. Every route acks 2xx fast and never rethrows:
* Waafi times out at 5s and does NOT retry.
*/
@ApiTags("Provider Webhooks")
@Controller("webhooks")
export class WebhooksController {
private readonly logger = new Logger(WebhooksController.name);
constructor(
private readonly telebirr: TelebirrWebhookService,
private readonly cbeBirr: CbeBirrWebhookService,
private readonly eBirr: EBirrWebhookService,
private readonly card: CardWebhookService,
private readonly waafi: WaafiWebhookService,
private readonly dMoney: DMoneyWebhookService,
) {}
@All("telebirr")
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: "Telebirr payment notification callback (Ethiopia)",
})
async receiveTelebirr(@Body() payload: TelebirrWebhookPayload) {
this.logger.log("Telebirr webhook called");
try {
await this.telebirr.handle(payload);
} catch (err) {
this.logger.error(`Telebirr webhook handler threw: ${this.message(err)}`);
}
return { code: "0", message: "OK" };
}
@Post("cbe-birr")
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: "CBE Birr payment notification callback (Ethiopia)",
})
async receiveCbeBirr(@Body() payload: CbeBirrWebhookPayload) {
try {
await this.cbeBirr.handle(payload);
} catch (err) {
this.logger.error(`CBE Birr webhook handler threw: ${this.message(err)}`);
}
return { success: true };
}
@Post("ebirr")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "eBirr payment notification callback (Ethiopia)" })
async receiveEBirr(@Body() payload: EBirrWebhookPayload) {
try {
await this.eBirr.handle(payload);
} catch (err) {
this.logger.error(`eBirr webhook handler threw: ${this.message(err)}`);
}
return { code: "0000", message: "success" };
}
@Post("card")
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: "Card payment notification callback (International)",
})
async receiveCard(
@Body() payload: CardWebhookPayload,
@Headers("stripe-signature") signature: string,
) {
try {
await this.card.handle(payload, signature);
} catch (err) {
this.logger.error(`Card webhook handler threw: ${this.message(err)}`);
}
return { received: true };
}
@Post("waafi")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Waafi payment notification callback (Djibouti)" })
async receiveWaafi(
@Body() payload: WaafiWebhookPayload,
@Headers() headers: WaafiWebhookHeaders,
@Req() req: { rawBody?: Buffer },
) {
this.logger.log(
`Waafi webhook hit: event=${payload?.event ?? "unknown"} eventId=${headers["x-webhook-event-id"] ?? "n/a"}`,
);
try {
// HMAC verification must sign over the exact raw bytes Waafi sent, not re-serialized JSON.
const rawBody = req.rawBody?.toString("utf8") ?? "";
await this.waafi.handle(payload, rawBody, headers);
} catch (err) {
this.logger.error(`Waafi webhook handler threw: ${this.message(err)}`);
}
return { responseCode: "2001", responseMsg: "Success" };
}
@Post("dmoney")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "D-Money payment notification callback (Djibouti)" })
async receiveDMoney(@Body() payload: DMoneyWebhookPayload) {
try {
await this.dMoney.handle(payload);
} catch (err) {
this.logger.error(`D-Money webhook handler threw: ${this.message(err)}`);
}
return { success: true };
}
private message(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
}

View File

@@ -0,0 +1,34 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { IntentsModule } from "../intents/intents.module";
import { ProvidersModule } from "../providers/providers.module";
import { PaymentWebhookEvent } from "./entities/payment-webhook-event.entity";
import { WebhookEventsRepository } from "./webhook-events.repository";
import { WebhookProcessorService } from "./webhook-processor.service";
import { WebhooksController } from "./webhooks.controller";
import { TelebirrWebhookService } from "./handlers/telebirr-webhook.service";
import { CbeBirrWebhookService } from "./handlers/cbe-birr-webhook.service";
import { EBirrWebhookService } from "./handlers/ebirr-webhook.service";
import { CardWebhookService } from "./handlers/card-webhook.service";
import { WaafiWebhookService } from "./handlers/waafi-webhook.service";
import { DMoneyWebhookService } from "./handlers/dmoney-webhook.service";
@Module({
imports: [
TypeOrmModule.forFeature([PaymentWebhookEvent]),
IntentsModule,
ProvidersModule,
],
controllers: [WebhooksController],
providers: [
WebhookEventsRepository,
WebhookProcessorService,
TelebirrWebhookService,
CbeBirrWebhookService,
EBirrWebhookService,
CardWebhookService,
WaafiWebhookService,
DMoneyWebhookService,
],
})
export class WebhooksModule {}