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
66 changes: 17 additions & 49 deletions pr.md
Original file line number Diff line number Diff line change
@@ -1,62 +1,30 @@
# fix: settlement batching, supported assets, fee caps, and retry mechanism (#319, #320, #321, #322)
# fix: validate, limit, and escape merchant settings business data (#552)

Closes #319, Closes #320, Closes #321, Closes #322
Closes #552

## Summary

- **#319** — Added `SupportedAsset` model with admin CRUD endpoints (`GET /api/assets`, `POST /admin/assets`, `PATCH /admin/assets/:code`, `DELETE /admin/assets/:code`). Settlement creation now validates assets against this table, returning 422 for unsupported/inactive assets. Initial seed includes USDC, EURT, and XLMS.
- **#320** — Implemented settlement batching via BullMQ repeatable job. Job runs every `BATCH_INTERVAL_SECONDS` (default 300s), queries pending settlements, groups by asset, and creates `SettlementBatch` records for assets with ≥ `BATCH_MIN_COUNT` settlements (default 2). Individual settlements are linked via `batchId` and marked completed.
- **#321** — Added maximum fee cap support. Merchants can configure `maxFeeBps` and `maxFeeThreshold` in settings. When `grossAmount > maxFeeThreshold`, fee is capped at `grossAmount * maxFeeBps / 10000`. Logs when cap is applied. Fee audit snapshot includes `capApplied` flag and `uncappedFee`.
- **#322** — Implemented retry mechanism for failed settlements. Added `POST /api/settlements/:id/retry` endpoint that clones failed settlements with same amounts/asset/merchant. Original marked as superseded via `supersededById`. Max 3 retries enforced per chain. Superseded settlements excluded from default listings.
- **Length Caps & Constraints**: Enforced length boundaries and validation on merchant settings business data fields inside Zod schemas:
- `businessName`: limited to max 100 characters.
- `supportEmail`: validated format and limited to max 255 characters.
- `supportAddress`: limited to max 255 characters.
- `tier`: limited to max 50 characters.
- **XSS Mitigation**: Implemented an HTML escaping function `escapeHtml` that transforms HTML special characters (`&`, `<`, `>`, `"`, `'`, `/`) inside free-text input fields to neutralize potential XSS payloads.
- **Race Condition Fix**: Resolved a test suite race condition in `merchant-settings.test.ts` where `generateTestJwt` was called before the Fastify app finished loading asynchronous decorators (fixed by calling `await app.ready()`).
- **Tests**: Added validation schema unit tests in `schemas.test.ts` and integration route tests in `merchant-settings.test.ts`.

## Files changed

**Schema & Migrations:**
- `prisma/schema.prisma` — added `SupportedAsset`, `SettlementBatch` models, `supersededById` field to Settlement
- `prisma/migrations/20260728110538_add_supported_assets_and_batching/migration.sql` — migration with seed data
- `prisma/migrations/20260728111000_add_settlement_retry/migration.sql` — migration for retry mechanism

**Backend Services:**
- `services/settlement-engine/src/index.ts` — batching job, retry route, fee cap support, listing filters
- `services/settlement-engine/src/settlement-amounts.ts` — FeeConfig interface, cap logic with audit trail
- `services/api-gateway/src/index.ts` — asset validation, new asset CRUD routes

**Test Files:**
- `services/settlement-engine/src/settlement-batching.test.ts` — batching test suite
- `services/settlement-engine/src/settlement-retry.test.ts` — retry test suite
- [merchant-settings.test.ts](file:///c:/Users/SHATTER/.vscode/BettaPay-Backend/services/api-gateway/src/merchant-settings.test.ts) — added route integration tests for invalid input validation and XSS neutralization, fixed async boot hook race.

**Shared Libraries:**
- `shared/validation/index.ts` — added `BATCH_INTERVAL_SECONDS`, `BATCH_MIN_COUNT` env vars
- `shared/validation/schemas.ts``SupportedAsset` schemas, `maxFeeBps`, `maxFeeThreshold` in MerchantSettings
- [schemas.ts](file:///c:/Users/SHATTER/.vscode/BettaPay-Backend/shared/validation/schemas.ts) — added `escapeHtml` utility and bounded the settings properties inside Zod schemas.
- [schemas.test.ts](file:///c:/Users/SHATTER/.vscode/BettaPay-Backend/shared/validation/schemas.test.ts)added unit tests for the settings validation and escaping rules.

## Test Coverage

**#319 - Supported Assets:**
- ✅ GET /api/assets returns seeded assets
- ✅ Settlement creation with unsupported asset returns 422
- ✅ Admin can add/update/delete assets

**#320 - Settlement Batching:**
- ✅ 2 USDC + 1 EURT pending → USDC batch created, EURT stays pending
- ✅ 1 pending settlement → no batch (below min count)
- ✅ 0 pending settlements → no batches created

**#321 - Fee Caps:**
- ✅ Gross $100K, feeBps 1000, maxFeeBps 200, threshold $10K → fee = $2K (capped)
- ✅ Gross $5K below threshold → uncapped fee $500
- ✅ No maxFee configured → uncapped behavior
- ✅ Fee audit snapshot tracks cap application

**#322 - Retry Mechanism:**
- ✅ Retry failed settlement → new settlement created
- ✅ Retry completed settlement → 422 error
- ✅ Max 3 retries enforced per chain
- ✅ Superseded settlements hidden from default listing

## Deployment Notes

- Run migrations: `npx prisma migrate deploy`
- Configure env vars: `BATCH_INTERVAL_SECONDS`, `BATCH_MIN_COUNT`
- Initial assets seeded automatically (USDC, EURT, XLMS)
- Merchants can configure fee caps via PATCH /api/merchants/:id/settings
- Failed settlements can be retried via POST /api/settlements/:id/retry
- ✅ Validation allows valid fields and parses them successfully
- ✅ Validation rejects invalid formats (e.g. invalid emails) returning `400 Bad Request`
- ✅ Validation rejects fields exceeding length boundaries returning `400 Bad Request`
- ✅ HTML inputs are escaped in free-text fields, returned in JSON payload, and persisted to DB safely
85 changes: 84 additions & 1 deletion services/api-gateway/src/merchant-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ test('updating feeBps merges into existing settings and persists in DB', async (
const { app, mockPrisma } = createTestApp({}, {
merchants: [{ id: 'm1', settings: { tier: 'silver', autoSettle: true } }],
});
await app.ready();
const token = generateTestJwt(app);

const res = await app.inject({
Expand All @@ -31,7 +32,7 @@ test('updating feeBps merges into existing settings and persists in DB', async (
});

t.equal(res.statusCode, 200, 'returns 200');
const settings = JSON.parse(res.body as string).merchant.settings;
const settings = JSON.parse(res.body as string).data.merchant.settings;
t.equal(settings.feeBps, 75, 'feeBps is set');
t.equal(settings.autoSettle, true, 'unrelated settings are preserved');

Expand All @@ -44,6 +45,7 @@ test('updating feeBps merges into existing settings and persists in DB', async (

test('updating a missing merchant returns 404', async (t) => {
const { app } = createTestApp({}, { merchants: [] });
await app.ready();
const token = generateTestJwt(app);

const res = await app.inject({
Expand All @@ -62,6 +64,7 @@ test('an out-of-range feeBps is rejected', async (t) => {
const { app } = createTestApp({}, {
merchants: [{ id: 'm1', settings: {} }],
});
await app.ready();
const token = generateTestJwt(app);

const res = await app.inject({
Expand All @@ -75,3 +78,83 @@ test('an out-of-range feeBps is rejected', async (t) => {
await app.close();
t.end();
});

test('invalid business settings formats are rejected', async (t) => {
const { app } = createTestApp({}, {
merchants: [{ id: 'm1', settings: {} }],
});
await app.ready();
const token = generateTestJwt(app);

// 1. Invalid email format
const resEmail = await app.inject({
method: 'PATCH',
url: '/api/merchants/m1/settings',
headers: { authorization: `Bearer ${token}` },
payload: { supportEmail: 'not-an-email' },
});
t.equal(resEmail.statusCode, 400, 'should reject invalid supportEmail format');

// 2. businessName exceeds length cap (100 characters)
const resName = await app.inject({
method: 'PATCH',
url: '/api/merchants/m1/settings',
headers: { authorization: `Bearer ${token}` },
payload: { businessName: 'a'.repeat(101) },
});
t.equal(resName.statusCode, 400, 'should reject overly long businessName');

// 3. supportAddress exceeds length cap (255 characters)
const resAddress = await app.inject({
method: 'PATCH',
url: '/api/merchants/m1/settings',
headers: { authorization: `Bearer ${token}` },
payload: { supportAddress: 'a'.repeat(256) },
});
t.equal(resAddress.statusCode, 400, 'should reject overly long supportAddress');

// 4. tier exceeds length cap (50 characters)
const resTier = await app.inject({
method: 'PATCH',
url: '/api/merchants/m1/settings',
headers: { authorization: `Bearer ${token}` },
payload: { tier: 'a'.repeat(51) },
});
t.equal(resTier.statusCode, 400, 'should reject overly long tier');

await app.close();
t.end();
});

test('XSS HTML payload in settings free-text fields is escaped and neutralized', async (t) => {
const { app, mockPrisma } = createTestApp({}, {
merchants: [{ id: 'm1', settings: {} }],
});
await app.ready();
const token = generateTestJwt(app);

const res = await app.inject({
method: 'PATCH',
url: '/api/merchants/m1/settings',
headers: { authorization: `Bearer ${token}` },
payload: {
businessName: '<script>alert("xss")</script> Betta',
supportAddress: '<a href="javascript:void(0)">Main Rd</a>',
tier: '<b>Gold</b>',
supportEmail: 'support@betta.com',
},
});

t.equal(res.statusCode, 200, 'accepts valid payload structure');

const settings = JSON.parse(res.body as string).data.merchant.settings;
t.equal(settings.businessName, '&lt;script&gt;alert(&quot;xss&quot;)&lt;&#x2F;script&gt; Betta', 'businessName HTML is escaped');
t.equal(settings.supportAddress, '&lt;a href=&quot;javascript:void(0)&quot;&gt;Main Rd&lt;&#x2F;a&gt;', 'supportAddress HTML is escaped');
t.equal(settings.tier, '&lt;b&gt;Gold&lt;&#x2F;b&gt;', 'tier HTML is escaped');

const stored = await mockPrisma.merchant.findUnique({ where: { id: 'm1' } });
t.equal(stored.settings.businessName, '&lt;script&gt;alert(&quot;xss&quot;)&lt;&#x2F;script&gt; Betta', 'persisted businessName is escaped');

await app.close();
t.end();
});
86 changes: 86 additions & 0 deletions shared/validation/schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import {
PositiveAmountString,
StellarAddressSchema,
UpdateMerchantSettingsBody,
MerchantSettings,
CurrencyCode,
merchantSchema,
paymentSchema,
walletSchema,
Expand Down Expand Up @@ -476,3 +478,87 @@ test('StellarAddressSchema validation', async (t) => {
}).merchantId, VALID_STELLAR_PUBLIC_KEY);
});
});

test('MerchantSettings and UpdateMerchantSettingsBody validation', async (t) => {
await t.test('accepts valid merchant settings and does not modify clean fields', () => {
const validPayload = {
businessName: 'Valid Merchant LLC',
supportEmail: 'support@valid.com',
supportAddress: '123 Main St, Springfield',
tier: 'premium',
feeBps: 150,
autoSettle: true,
preferredAsset: 'USDC',
};

const parsed = UpdateMerchantSettingsBody.parse(validPayload);
assert.strictEqual(parsed.businessName, 'Valid Merchant LLC');
assert.strictEqual(parsed.supportEmail, 'support@valid.com');
assert.strictEqual(parsed.supportAddress, '123 Main St, Springfield');
assert.strictEqual(parsed.tier, 'premium');
assert.strictEqual(parsed.feeBps, 150);
assert.strictEqual(parsed.autoSettle, true);
assert.strictEqual(parsed.preferredAsset, 'USDC');
});

await t.test('escapes HTML in free-text fields to prevent XSS', () => {
const maliciousPayload = {
businessName: '<script>alert("xss")</script> Name',
supportAddress: '<img src=x onerror=alert(1)> Address',
tier: '<b>bold</b> tier',
};

const parsed = UpdateMerchantSettingsBody.parse(maliciousPayload);
assert.strictEqual(parsed.businessName, '&lt;script&gt;alert(&quot;xss&quot;)&lt;&#x2F;script&gt; Name');
assert.strictEqual(parsed.supportAddress, '&lt;img src=x onerror=alert(1)&gt; Address');
assert.strictEqual(parsed.tier, '&lt;b&gt;bold&lt;&#x2F;b&gt; tier');
});

await t.test('rejects invalid email formats', () => {
assert.throws(
() => UpdateMerchantSettingsBody.parse({ supportEmail: 'not-an-email' }),
/Invalid supportEmail format/
);
});

await t.test('rejects values exceeding length caps', () => {
const longName = 'A'.repeat(101);
const longAddress = 'B'.repeat(256);
const longTier = 'C'.repeat(51);
const longEmail = 'D'.repeat(250) + '@example.com';

assert.throws(
() => UpdateMerchantSettingsBody.parse({ businessName: longName }),
/businessName must be at most 100 characters/
);

assert.throws(
() => UpdateMerchantSettingsBody.parse({ supportAddress: longAddress }),
/supportAddress must be at most 255 characters/
);

assert.throws(
() => UpdateMerchantSettingsBody.parse({ tier: longTier }),
/tier must be at most 50 characters/
);

assert.throws(
() => UpdateMerchantSettingsBody.parse({ supportEmail: longEmail }),
/supportEmail must be at most 255 characters/
);
});

await t.test('MerchantSettings behaves similarly with validation, length caps and escaping', () => {
const maliciousPayload = {
businessName: '<h1>Company</h1>',
supportEmail: 'support@company.com',
supportAddress: '<div class="addr">123 Lane</div>',
tier: '<script>tier</script>',
};

const parsed = MerchantSettings.parse(maliciousPayload);
assert.strictEqual(parsed.businessName, '&lt;h1&gt;Company&lt;&#x2F;h1&gt;');
assert.strictEqual(parsed.supportAddress, '&lt;div class=&quot;addr&quot;&gt;123 Lane&lt;&#x2F;div&gt;');
assert.strictEqual(parsed.tier, '&lt;script&gt;tier&lt;&#x2F;script&gt;');
});
});
23 changes: 21 additions & 2 deletions shared/validation/schemas.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
import { z } from 'zod';
import { CurrencyCode } from './currency.js';
export { CurrencyCode };
import { validateStellarAddress } from '@bettapay/stellar-utils';
import { WebhookUrlSchema } from './webhookSchema.js';

function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;')
.replace(/\//g, '&#x2F;');
}

// Entity schemas
export const idSchema = z.string().min(1);
Expand Down Expand Up @@ -311,11 +321,15 @@ export const MerchantSettings = z.object({
maxFeeBps: z.number().int().min(0).max(10000).optional(),
maxFeeThreshold: z.string().regex(/^\d+(\.\d+)?$/, 'maxFeeThreshold must be a numeric string').optional(),
webhookUrl: WebhookUrlSchema.optional(),
preferredAsset: z.string().optional(),
preferredAsset: CurrencyCode.optional(),
autoSettle: z.boolean().optional(),
maxSettlementAmount: z.number().positive().optional(),
minSettlementAmount: z.number().positive().optional(),
dailySettlementLimit: z.number().positive().optional(),
businessName: z.string().max(100, 'businessName must be at most 100 characters').transform(escapeHtml).optional(),
supportEmail: z.string().email('Invalid supportEmail format').max(255, 'supportEmail must be at most 255 characters').optional(),
supportAddress: z.string().max(255, 'supportAddress must be at most 255 characters').transform(escapeHtml).optional(),
tier: z.string().max(50, 'tier must be at most 50 characters').transform(escapeHtml).optional(),
});

export type MerchantSettings = z.infer<typeof MerchantSettings>;
Expand Down Expand Up @@ -442,11 +456,16 @@ export const UpdateMerchantSettingsBody = z.object({
feeBps: z.number().int().min(0).max(10000).optional(),
maxFeeBps: z.number().int().min(0).max(10000).optional(),
maxFeeThreshold: z.string().regex(/^\d+(\.\d+)?$/, 'maxFeeThreshold must be a numeric string').optional(),
tier: z.string().optional(),
tier: z.string().max(50, 'tier must be at most 50 characters').transform(escapeHtml).optional(),
minSettlementAmount: z.string().regex(/^\d+(\.\d+)?$/, 'minSettlementAmount must be a numeric string').optional(),
maxSettlementAmount: z.string().regex(/^\d+(\.\d+)?$/, 'maxSettlementAmount must be a numeric string').optional(),
dailySettlementLimit: z.string().regex(/^\d+(\.\d+)?$/, 'dailySettlementLimit must be a numeric string').optional(),
webhookUrl: WebhookUrlSchema.optional(),
preferredAsset: CurrencyCode.optional(),
autoSettle: z.boolean().optional(),
businessName: z.string().max(100, 'businessName must be at most 100 characters').transform(escapeHtml).optional(),
supportEmail: z.string().email('Invalid supportEmail format').max(255, 'supportEmail must be at most 255 characters').optional(),
supportAddress: z.string().max(255, 'supportAddress must be at most 255 characters').transform(escapeHtml).optional(),
});

export const UpdateMerchantNameBody = z.object({
Expand Down