Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 74 additions & 22 deletions BackendAcademy/src/common/transaction-manager.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,16 @@ export interface AtomicResult<T> {
* ## 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
Expand All @@ -49,9 +54,21 @@ export interface AtomicResult<T> {
* });
* ```
*
* 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
Expand All @@ -73,34 +90,33 @@ export class TransactionManagerService {
async runAtomic<T>(
fn: (ctx: TransactionContext) => Promise<T>,
): Promise<AtomicResult<T>> {
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);
}
}

/**
Expand All @@ -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
Expand All @@ -120,11 +139,44 @@ export class TransactionContext {
async addOperation<T extends TransactionSnapshot>(
operation: () => Promise<T>,
): Promise<T> {
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;
}
Expand Down
32 changes: 29 additions & 3 deletions BackendAcademy/src/payments/payments.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Request>,
Expand Down Expand Up @@ -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);
Expand All @@ -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<void> {
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',
Expand All @@ -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}`);
}
}
Expand Down
3 changes: 2 additions & 1 deletion BackendAcademy/src/payments/payments.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
45 changes: 45 additions & 0 deletions BackendAcademy/src/payments/payments.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
await this.databaseService.releaseFunds(reservationId);
}

/**
* Processes a validated, signature-checked payment webhook event.
*
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down