Skip to content
Merged
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
63 changes: 63 additions & 0 deletions docs/PAYMENT-DRY-RUN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Payment dry-run

`POST /v1/payments/dry-run` validates a payment request without creating a
payment or submitting anything to Stellar. The endpoint requires the same
`Authorization: Bearer <key>` authentication and uses the same request body as
`POST /v1/payments`.

The dry-run performs the checks that happen immediately before persistence:

- the sender wallet exists and is `ACTIVE`;
- self-payment policy permits the transfer;
- the receiver wallet exists; and
- configured per-transaction and daily wallet limits permit the amount.

Example request:

```http
POST /v1/payments/dry-run
Authorization: Bearer mux_test_example
Content-Type: application/json

{
"walletId": "123e4567-e89b-12d3-a456-426614174000",
"receiverWalletId": "123e4567-e89b-12d3-a456-426614174001",
"amount": 25,
"currency": "USD",
"description": "Invoice preview",
"fromId": 1,
"toId": 2
}
```

Successful response (`200 OK`):

```json
{
"dryRun": true,
"valid": true,
"preview": {
"senderWalletId": "123e4567-e89b-12d3-a456-426614174000",
"receiverWalletId": "123e4567-e89b-12d3-a456-426614174001",
"fromId": 1,
"toId": 2,
"amount": 25,
"currency": "USD",
"status": "PENDING"
},
"checks": {
"senderWallet": "ACTIVE",
"receiverWallet": "FOUND",
"paymentLimits": "PASSED"
}
}
```

Validation errors use the API's normal error envelope. Missing or invalid API
keys return `401`; malformed input and inactive senders return `400`; missing
wallets return `404`; and wallet-limit failures return `422`.

Dry-run does not reserve funds, guarantee later submission, query or return
custody key material, write a payment row, sign a transaction, submit to
Horizon, or emit payment domain events. A later create request is validated
again because wallet state and limits may have changed.
19 changes: 7 additions & 12 deletions src/payments/dto/create-payment.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
IsOptional,
IsInt,
Min,
ValidateBy,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';

Expand All @@ -30,19 +29,15 @@ export class CreatePaymentDto {
receiverWalletId: string;

@ApiProperty({
example: 100.50,
description: 'Payment amount - must be positive with max 2 decimal places (e.g., 100.50)',
example: 100.5,
description:
'Payment amount - must be positive with max 2 decimal places (e.g., 100.50)',
})
@IsNumber({}, { message: 'amount must be a number' })
@IsPositive({ message: 'amount must be positive' })
@ValidateBy(
(value: any) => {
if (typeof value !== 'number') return false;
const decimalPlaces = (value.toString().split('.')[1] || '').length;
return decimalPlaces <= 2;
},
{ message: 'amount must have maximum 2 decimal places' },
@IsNumber(
{ maxDecimalPlaces: 2 },
{ message: 'amount must be a number with maximum 2 decimal places' },
)
@IsPositive({ message: 'amount must be positive' })
amount: number;

@ApiProperty({
Expand Down
53 changes: 53 additions & 0 deletions src/payments/dto/payment-dry-run-response.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { PaymentStatus } from '../entities/payment.entity';

export class PaymentDryRunPreviewDto {
@ApiProperty()
senderWalletId: string;

@ApiProperty()
receiverWalletId: string;

@ApiProperty()
fromId: number;

@ApiProperty()
toId: number;

@ApiProperty()
amount: number;

@ApiProperty()
currency: string;

@ApiPropertyOptional()
assetCode?: string;

@ApiProperty({ enum: PaymentStatus, example: PaymentStatus.PENDING })
status: PaymentStatus;
}

export class PaymentDryRunChecksDto {
@ApiProperty({ example: 'ACTIVE' })
senderWallet: 'ACTIVE';

@ApiProperty({ example: 'FOUND' })
receiverWallet: 'FOUND';

@ApiProperty({ example: 'PASSED' })
paymentLimits: 'PASSED';
}

export class PaymentDryRunResponseDto {
@ApiProperty({ example: true })
dryRun: true;

@ApiProperty({ example: true })
valid: true;

@ApiProperty({ type: PaymentDryRunPreviewDto })
preview: PaymentDryRunPreviewDto;

@ApiProperty({ type: PaymentDryRunChecksDto })
checks: PaymentDryRunChecksDto;
}
47 changes: 43 additions & 4 deletions src/payments/payments.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ describe('PaymentsController', () => {
beforeEach(async () => {
paymentsService = {
create: jest.fn(),
dryRun: jest.fn(),
findAll: jest.fn(),
findOne: jest.fn(),
update: jest.fn(),
Expand Down Expand Up @@ -42,6 +43,24 @@ describe('PaymentsController', () => {
expect(controller).toBeDefined();
});

describe('dryRun', () => {
it('delegates payment validation to the service', async () => {
const dto = {
walletId: 'sender-wallet',
receiverWalletId: 'receiver-wallet',
fromId: 1,
toId: 2,
amount: 25,
currency: 'USD',
};
const response = { dryRun: true, valid: true };
paymentsService.dryRun.mockResolvedValue(response);

await expect(controller.dryRun(dto)).resolves.toEqual(response);
expect(paymentsService.dryRun).toHaveBeenCalledWith(dto);
});
});

describe('update', () => {
it('should delegate to service and return updated payment', async () => {
const updated = { id: 1, status: PaymentStatus.CONFIRMED };
Expand Down Expand Up @@ -106,7 +125,14 @@ describe('PaymentsController', () => {

describe('swagger decorators', () => {
it('should have @ApiResponse decorators on all routes', () => {
const routes = ['create', 'findAll', 'findOne', 'update', 'remove'];
const routes = [
'create',
'dryRun',
'findAll',
'findOne',
'update',
'remove',
];

routes.forEach((route) => {
const descriptor = Object.getOwnPropertyDescriptor(
Expand All @@ -115,13 +141,23 @@ describe('PaymentsController', () => {
);
expect(descriptor).toBeDefined();

const metadata = Reflect.getMetadata('swagger/apiResponse', descriptor.value);
const metadata = Reflect.getMetadata(
'swagger/apiResponse',
descriptor.value,
);
expect(metadata).toBeDefined();
});
});

it('should have @ApiOperation on all routes', () => {
const routes = ['create', 'findAll', 'findOne', 'update', 'remove'];
const routes = [
'create',
'dryRun',
'findAll',
'findOne',
'update',
'remove',
];

routes.forEach((route) => {
const descriptor = Object.getOwnPropertyDescriptor(
Expand All @@ -130,7 +166,10 @@ describe('PaymentsController', () => {
);
expect(descriptor).toBeDefined();

const metadata = Reflect.getMetadata('swagger/apiOperation', descriptor.value);
const metadata = Reflect.getMetadata(
'swagger/apiOperation',
descriptor.value,
);
expect(metadata).toBeDefined();
});
});
Expand Down
37 changes: 37 additions & 0 deletions src/payments/payments.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
Delete,
Query,
UseGuards,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import {
ApiTags,
Expand All @@ -19,6 +21,7 @@ import {
} from '@nestjs/swagger';
import { PaymentsService } from './payments.service';
import { CreatePaymentDto } from './dto/create-payment.dto';
import { PaymentDryRunResponseDto } from './dto/payment-dry-run-response.dto';
import { BatchPaymentDto } from './dto/batch-payment.dto';
import { UpdatePaymentDto } from './dto/update-payment.dto';
import { PaymentsFilterDto } from './dto/payments-filter.dto';
Expand Down Expand Up @@ -83,6 +86,40 @@ export class PaymentsController {
return this.paymentsService.create(createPaymentDto);
}

@ApiOperation({
summary: 'Validate a payment without creating or submitting it',
description:
'Runs the same wallet-state, self-payment, receiver, and payment-limit checks as payment creation. No payment is persisted, no transaction is signed or submitted, and no domain event is emitted.',
})
@ApiBody({ type: CreatePaymentDto })
@ApiResponse({
status: 200,
description: 'The payment passed all pre-creation checks.',
type: PaymentDryRunResponseDto,
})
@ApiResponse({
status: 400,
description: 'Bad request - invalid input or inactive sender wallet.',
})
@ApiResponse({
status: 401,
description: 'Unauthorized - missing or invalid API key.',
})
@ApiResponse({
status: 404,
description: 'Sender or receiver wallet not found.',
})
@ApiResponse({
status: 422,
description: 'The payment exceeds a configured wallet limit.',
})
@Post('dry-run')
@HttpCode(HttpStatus.OK)
@SensitiveEndpoint()
dryRun(@Body() createPaymentDto: CreatePaymentDto) {
return this.paymentsService.dryRun(createPaymentDto);
}

@ApiOperation({
summary: 'Create a batch of payments',
description:
Expand Down
Loading
Loading