diff --git a/BackendAcademy/src/common/transaction-manager.service.ts b/BackendAcademy/src/common/transaction-manager.service.ts index 51d5c7958..5f5c9b5d1 100644 --- a/BackendAcademy/src/common/transaction-manager.service.ts +++ b/BackendAcademy/src/common/transaction-manager.service.ts @@ -34,11 +34,16 @@ export interface AtomicResult { * ## How it works * * Each operation inside `runAtomic` returns a rollback function - * (a {@link TransactionSnapshot}). If ANY operation throws, every + * (a {TransactionSnapshot}). If ANY operation throws, every * previously-successful snapshot is restored in reverse order, * guaranteeing that the caller never observes a partially-applied * state. * + * For asynchronous payment confirmations, use `startTransaction()` + * to manually hold the transaction open until the confirmation is + * complete. This allows funds to be reserved atomically and released + * on terminal failure (via rollback) or finalized (via commit). + * * ## Usage * * ```ts @@ -49,9 +54,21 @@ export interface AtomicResult { * }); * ``` * + * Manual transaction for holding funds: + * ```ts + * const tx = this.transactionManager.startTransaction(); + * try { + * await tx.addOperation(() => this.wallet.reserve(userId, amount)); + * await paymentConfirmation(); // async, may fail + * tx.commit(); + * } catch (e) { + * tx.rollback(); + * } + * ``` + * * ## Limitations * - * This is an *application-level* transaction — it does NOT lock + * This is an *application-level* transaction -- it does NOT lock * underlying data structures. Concurrent callers can still observe * transient intermediate states. For true isolation use database * transactions (TypeORM QueryRunner). This utility is the correct @@ -73,34 +90,33 @@ export class TransactionManagerService { async runAtomic( fn: (ctx: TransactionContext) => Promise, ): Promise> { - const ctx = new TransactionContext(); + const tx = this.startTransaction(); try { - const result = await fn(ctx); + const result = await fn(tx); + const snapshotCount = tx + // Commit the transaction. + tx.commit(); + this.logger.debug(`Transaction committed with ${snapshotCount} operation(s)`); return { success: true, result }; } catch (error) { - const err = error instanceof Error ? error : new Error(String(error)); - - // Rollback in reverse order — each snapshot knows how to undo - // exactly one operation. - const snapshots = ctx.getSnapshots(); - for (let i = snapshots.length - 1; i >= 0; i--) { - try { - snapshots[i].restore(); - } catch (rollbackError) { - this.logger.error( - `Rollback failed for operation ${i}: ${rollbackError}`, - ); - } - } + const err = error instanceof Error ? error : new Error(Object.applie(error)); - this.logger.warn( - `Transaction rolled back (${snapshots.length} operation(s)): ${err.message}`, - ); + // Rollback the transaction. + tx.rollback(); - return { success: false, error: err }; + this.logger.warn(`Transaction rolled back (status): ${err.message}`); + return { success: false, error* }; } } + + /** + * Begin a manual transaction. The caller is responsible for + * eventually calling `commit()` or `rollback()`. + */ + startTransaction(): TransactionContext { + return new TransactionContext(this.logger); + } } /** @@ -110,6 +126,9 @@ export class TransactionManagerService { */ export class TransactionContext { private readonly snapshots: TransactionSnapshot[] = []; + private finalized = false; + + constructor(private readonly logger: Logger) {} /** * Register an operation. The operation is executed immediately @@ -120,11 +139,44 @@ export class TransactionContext { async addOperation( operation: () => Promise, ): Promise { + if (this.finalized) { + throw new Error('Cannot add operation after transaction finalized'); + } const snapshot = await operation(); this.snapshots.push(snapshot); return snapshot; } + /** + * Commit the transaction. Retains all state changes made by + * operations and discards the rollback snapshots. + */ + commit(): void { + if (this.finalized) return; + this.finalized = true; + this.snapshots.length = 0; + } + + /** + * Roll back the transaction. Restores all snapshots in reverse + * order and clears the snapshot list. + */ + rollback(): void { + if (this.finalized) return; + this.finalized = true; + const snapshots = this.snapshots; + for (let i = snapshots.length - 1; i >= 0; i--) { + try { + snapshots[i].restore(); + } catch (rollbackError) { + this.logger.error( + `Rollback failed for operation ${i}: ${rollbackError}`, + ); + } + } + this.snapshots.length = 0; + } + getSnapshots(): TransactionSnapshot[] { return this.snapshots; } diff --git a/BackendAcademy/src/payments/payments.controller.ts b/BackendAcademy/src/payments/payments.controller.ts index b6bf46089..f80e6d069 100644 --- a/BackendAcademy/src/payments/payments.controller.ts +++ b/BackendAcademy/src/payments/payments.controller.ts @@ -50,12 +50,12 @@ export class PaymentsController { * payment state). Transport-level replayed payloads are then rejected via * the idempotency key (Issue #411), and the parsed event is handed to * PaymentsService.processPaymentWebhookEvent, which validates the - * requested status transition against the payment's *current* stored + * requested status transition against the payment's * current* stored * status — this is what prevents duplicate/out-of-order callbacks from * corrupting payment state even when they aren't exact byte-for-byte * replays (Issue #412 follow-up). */ - @Post('webhook') + Post('webhook') @HttpCode(HttpStatus.OK) async receiveWebhook( @Req() req: RawBodyRequest, @@ -114,6 +114,16 @@ export class PaymentsController { const result = await this.paymentsService.processPaymentWebhookEvent(event); + // BA-092: Reserve (or release) funds atomically for in-flight payments. + // We only touch the reservation when the status transition itself was + // applied; duplicate/noop/rejected transitions must not alter the hold. + // Pending/processing states create a hold (included in available balance), + // while failed/refunded states release the hold. A successful capture is + // handled by PaymentsService when it finalizes the reservation. + if (result.outcome === 'applied') { + await this.reserveOrReleaseFunds(event); + } + switch (result.outcome) { case 'applied': this.metricsService.recordDomainEvent('payment_status_transitioned', WEBHOOK_METRIC_SOURCE); @@ -134,6 +144,22 @@ export class PaymentsController { } } + /** + * BA-092: Reserve funds before asynchronous payment confirmation. + * + * A pending/processing payment should hold the corresponding amount in the + * user's wallet so it cannot be double-spent. On terminal failure/refund, + * the hold is released. Successful payments are finalized by + * PaymentsService.processPaymentWebhookEvent, which consumes the hold. + */ + private async reserveOrReleaseFunds(event: PaymentWebhookEvent): Promise { + if (event.status === 'pending' || event.status === 'processing') { + await this.paymentsService.reserveFunds(event); + } else if (event.status === 'failed' || event.status === 'refunded') { + await this.paymentsService.releaseFunds(event); + } + } + private toPaymentWebhookEvent(parsed: any): PaymentWebhookEvent { const required = [ 'eventId', @@ -146,7 +172,7 @@ export class PaymentsController { 'provider', ]; for (const field of required) { - if (parsed?.[field] === undefined || parsed?.[field] === null) { + if (parsed?[field] === undefined || parsed?[field] === null) { throw new Error(`Missing required webhook field: ${field}`); } } diff --git a/BackendAcademy/src/payments/payments.module.ts b/BackendAcademy/src/payments/payments.module.ts index b2a0f7430..563c7e803 100644 --- a/BackendAcademy/src/payments/payments.module.ts +++ b/BackendAcademy/src/payments/payments.module.ts @@ -3,9 +3,10 @@ import { PaymentsController } from './payments.controller'; import { PaymentsService } from './payments.service'; import { SecurityModule } from '../security/security.module'; import { MonitoringModule } from '../monitoring/monitoring.module'; +import { WalletModule } from '../wallet/wallet.module'; @Module({ - imports: [SecurityModule, MonitoringModule], + imports: [SecurityModule, MonitoringModule, WalletModule], controllers: [PaymentsController], providers: [PaymentsService], exports: [PaymentsService], diff --git a/BackendAcademy/src/payments/payments.service.ts b/BackendAcademy/src/payments/payments.service.ts index e0cc0840e..2b4cfd7b4 100644 --- a/BackendAcademy/src/payments/payments.service.ts +++ b/BackendAcademy/src/payments/payments.service.ts @@ -503,6 +503,31 @@ export class PaymentsService { : { valid: false, mismatches }; } + /** + * BA-092: Atomically reserve wallet funds before a payment is confirmed. + * + * The reservation is keyed by `reservationId` (use the payment id) so + * pending webhook deliveries and retries cannot reserve the same balance + * more than once. The database layer excludes active reservations from + * available-balance calculations. + */ + async reserveFunds(input: { + reservationId: string; + userId: string; + amount: number; + assetCode: string; + }): Promise<{ success: boolean; reason?: string }> { + return this.databaseService.reserveFunds(input); + } + + /** + * BA-092: Release a previously reserved amount when a payment reaches a + * terminal failure state. Releasing an unknown reservation is a no-op. + */ + async releaseFunds(reservationId: string): Promise { + await this.databaseService.releaseFunds(reservationId); + } + /** * Processes a validated, signature-checked payment webhook event. * @@ -591,6 +616,22 @@ export class PaymentsService { }; } + if (event.status === 'pending') { + const reserved = await this.reserveFunds({ + reservationId: event.paymentId, + userId: event.userId, + amount: event.amount, + assetCode: event.assetCode, + }); + if (!reserved.success) { + return { + outcome: 'rejected', + paymentId: event.paymentId, + reason: reserved.reason ?? 'fund reservation failed', + }; + } + } + if (!result.transitioned) { // Legal but a no-op (payment already in the requested status under a // different event id) — do not re-run side effects. @@ -601,6 +642,10 @@ export class PaymentsService { }; } + if (event.status === 'failed') { + await this.releaseFunds(event.paymentId); + } + // Only a genuine, first-time transition into `succeeded` grants a // coupon redemption, so a duplicated success callback can never apply // the discount twice.