diff --git a/pr.md b/pr.md
index 3feaece..436db3d 100644
--- a/pr.md
+++ b/pr.md
@@ -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
diff --git a/services/api-gateway/src/merchant-settings.test.ts b/services/api-gateway/src/merchant-settings.test.ts
index 7e1aab7..b4baa6e 100644
--- a/services/api-gateway/src/merchant-settings.test.ts
+++ b/services/api-gateway/src/merchant-settings.test.ts
@@ -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({
@@ -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');
@@ -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({
@@ -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({
@@ -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: ' Betta',
+ supportAddress: 'Main Rd',
+ tier: 'Gold',
+ 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, '<script>alert("xss")</script> Betta', 'businessName HTML is escaped');
+ t.equal(settings.supportAddress, '<a href="javascript:void(0)">Main Rd</a>', 'supportAddress HTML is escaped');
+ t.equal(settings.tier, '<b>Gold</b>', 'tier HTML is escaped');
+
+ const stored = await mockPrisma.merchant.findUnique({ where: { id: 'm1' } });
+ t.equal(stored.settings.businessName, '<script>alert("xss")</script> Betta', 'persisted businessName is escaped');
+
+ await app.close();
+ t.end();
+});
diff --git a/shared/validation/schemas.test.ts b/shared/validation/schemas.test.ts
index 2b0d129..f78e208 100644
--- a/shared/validation/schemas.test.ts
+++ b/shared/validation/schemas.test.ts
@@ -12,6 +12,8 @@ import {
PositiveAmountString,
StellarAddressSchema,
UpdateMerchantSettingsBody,
+ MerchantSettings,
+ CurrencyCode,
merchantSchema,
paymentSchema,
walletSchema,
@@ -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: ' Name',
+ supportAddress: ' Address',
+ tier: 'bold tier',
+ };
+
+ const parsed = UpdateMerchantSettingsBody.parse(maliciousPayload);
+ assert.strictEqual(parsed.businessName, '<script>alert("xss")</script> Name');
+ assert.strictEqual(parsed.supportAddress, '<img src=x onerror=alert(1)> Address');
+ assert.strictEqual(parsed.tier, '<b>bold</b> 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: '