mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 16:28:12 +00:00
feat: ( payment ) create payment microservice
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
21
apps/edr-payment-api/src/modules/intents/intents.module.ts
Normal file
21
apps/edr-payment-api/src/modules/intents/intents.module.ts
Normal 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 {}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
342
apps/edr-payment-api/src/modules/intents/intents.service.ts
Normal file
342
apps/edr-payment-api/src/modules/intents/intents.service.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user