Skip to content

Commit e4f8cbc

Browse files
authored
Merge pull request #422 from Abdullahi-Code9/fix/anomaly-pagination-issue-247
fix(anomaly): align response envelope pagination structure and handle invalid shipmentId (#247)
2 parents 362ba2e + f5bb1f3 commit e4f8cbc

10 files changed

Lines changed: 330 additions & 11 deletions

File tree

docs/swagger.yaml

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2991,6 +2991,252 @@ paths:
29912991
'403':
29922992
$ref: '#/components/responses/Forbidden'
29932993

2994+
/api/settlements/{id}:
2995+
get:
2996+
summary: Get payments history (legacy — prefer /api/settlements)
2997+
description: Returns settlement records for the authenticated user's organization. Both `/api/payments` and `/api/settlements` resolve to the same router.
2998+
security:
2999+
- bearerAuth: []
3000+
parameters:
3001+
- in: query
3002+
name: status
3003+
schema:
3004+
type: string
3005+
enum: [Pending, Escrowed, Released, Failed, Disputed]
3006+
description: Filter payments by status.
3007+
- in: query
3008+
name: cursor
3009+
summary: Get settlement detail
3010+
description: Returns full settlement detail including escrowRelease subdocument.
3011+
security:
3012+
- bearerAuth: []
3013+
parameters:
3014+
- in: path
3015+
name: id
3016+
required: true
3017+
schema:
3018+
type: string
3019+
description: Settlement ObjectId.
3020+
responses:
3021+
'200':
3022+
description: Settlement retrieved successfully
3023+
content:
3024+
application/json:
3025+
schema:
3026+
type: object
3027+
properties:
3028+
success:
3029+
type: boolean
3030+
message:
3031+
type: string
3032+
data:
3033+
type: array
3034+
items:
3035+
$ref: '#/components/schemas/Settlement'
3036+
meta:
3037+
type: object
3038+
properties:
3039+
total:
3040+
type: integer
3041+
hasMore:
3042+
type: boolean
3043+
nextCursor:
3044+
type: string
3045+
nullable: true
3046+
$ref: '#/components/schemas/Settlement'
3047+
'401':
3048+
$ref: '#/components/responses/Unauthorized'
3049+
'403':
3050+
$ref: '#/components/responses/Forbidden'
3051+
'404':
3052+
description: Settlement not found
3053+
content:
3054+
application/json:
3055+
schema:
3056+
$ref: '#/components/schemas/ErrorResponse'
3057+
3058+
/api/settlements/{id}/dispute:
3059+
post:
3060+
summary: Dispute a settlement
3061+
description: |
3062+
Transitions the settlement status to `Disputed` and records dispute metadata
3063+
(`disputedAt`, `disputeReason`, `additionalNotes`) in `escrowRelease`.
3064+
Restricted to ADMIN and MANAGER roles.
3065+
security:
3066+
- bearerAuth: []
3067+
parameters:
3068+
- in: path
3069+
name: id
3070+
required: true
3071+
schema:
3072+
type: string
3073+
description: Settlement ObjectId.
3074+
requestBody:
3075+
required: true
3076+
content:
3077+
application/json:
3078+
schema:
3079+
type: object
3080+
required:
3081+
- reason
3082+
properties:
3083+
reason:
3084+
type: string
3085+
minLength: 1
3086+
description: Reason for the dispute.
3087+
notes:
3088+
type: string
3089+
description: Optional additional context.
3090+
responses:
3091+
'200':
3092+
description: Settlement disputed successfully
3093+
content:
3094+
application/json:
3095+
schema:
3096+
type: object
3097+
properties:
3098+
success:
3099+
type: boolean
3100+
message:
3101+
type: string
3102+
data:
3103+
$ref: '#/components/schemas/Settlement'
3104+
'400':
3105+
description: Validation error
3106+
content:
3107+
application/json:
3108+
schema:
3109+
$ref: '#/components/schemas/ErrorResponse'
3110+
'401':
3111+
$ref: '#/components/responses/Unauthorized'
3112+
'403':
3113+
description: Forbidden — VIEWER role cannot dispute
3114+
content:
3115+
application/json:
3116+
schema:
3117+
$ref: '#/components/schemas/ErrorResponse'
3118+
'404':
3119+
description: Settlement not found
3120+
content:
3121+
application/json:
3122+
schema:
3123+
$ref: '#/components/schemas/ErrorResponse'
3124+
3125+
/api/settlements:
3126+
get:
3127+
summary: List settlements
3128+
description: Returns settlement records for the authenticated user's organization (cursor-paginated).
3129+
security:
3130+
- bearerAuth: []
3131+
parameters:
3132+
- in: query
3133+
name: status
3134+
schema:
3135+
type: string
3136+
enum: [Pending, Escrowed, Released, Failed, Disputed]
3137+
description: Filter by status.
3138+
- in: query
3139+
name: cursor
3140+
schema:
3141+
type: string
3142+
description: Cursor for next-page pagination.
3143+
- in: query
3144+
name: limit
3145+
schema:
3146+
type: integer
3147+
minimum: 1
3148+
maximum: 100
3149+
default: 20
3150+
responses:
3151+
'200':
3152+
description: Settlements retrieved successfully
3153+
content:
3154+
application/json:
3155+
schema:
3156+
type: object
3157+
properties:
3158+
success:
3159+
type: boolean
3160+
message:
3161+
type: string
3162+
data:
3163+
type: array
3164+
items:
3165+
$ref: '#/components/schemas/Settlement'
3166+
meta:
3167+
type: object
3168+
properties:
3169+
total:
3170+
type: integer
3171+
hasMore:
3172+
type: boolean
3173+
nextCursor:
3174+
type: string
3175+
nullable: true
3176+
'401':
3177+
$ref: '#/components/responses/Unauthorized'
3178+
'403':
3179+
$ref: '#/components/responses/Forbidden'
3180+
3181+
/api/settlements/summary:
3182+
get:
3183+
summary: Get settlement summary with sparkline
3184+
description: |
3185+
Returns aggregated settlement totals and a per-day sparkline array for the chosen period.
3186+
Results are cached in Redis for 5 minutes.
3187+
security:
3188+
- bearerAuth: []
3189+
parameters:
3190+
- in: query
3191+
name: period
3192+
schema:
3193+
type: string
3194+
enum: [week, month, quarter]
3195+
default: week
3196+
description: Aggregation window. `week`=7 days, `month`=30 days, `quarter`=90 days.
3197+
responses:
3198+
'200':
3199+
description: Summary retrieved successfully
3200+
content:
3201+
application/json:
3202+
schema:
3203+
type: object
3204+
properties:
3205+
success:
3206+
type: boolean
3207+
message:
3208+
type: string
3209+
data:
3210+
type: object
3211+
properties:
3212+
totalReleased:
3213+
type: number
3214+
description: Sum of released settlement amounts in the period.
3215+
totalInEscrow:
3216+
type: number
3217+
description: Sum of escrowed settlement amounts in the period.
3218+
totalPending:
3219+
type: number
3220+
description: Sum of pending settlement amounts in the period.
3221+
sparkline:
3222+
type: array
3223+
items:
3224+
type: number
3225+
description: One entry per day in the period (released amounts per day).
3226+
period:
3227+
type: string
3228+
enum: [week, month, quarter]
3229+
'400':
3230+
description: Invalid period value
3231+
content:
3232+
application/json:
3233+
schema:
3234+
$ref: '#/components/schemas/ErrorResponse'
3235+
'401':
3236+
$ref: '#/components/responses/Unauthorized'
3237+
'403':
3238+
$ref: '#/components/responses/Forbidden'
3239+
29943240
/api/settlements/{id}:
29953241
get:
29963242
summary: Get settlement detail

src/app.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import YAML from 'yamljs';
88
import { requestId } from './shared/middleware/requestId.js';
99
import { notFound } from './shared/middleware/notFound.js';
1010
import { errorMiddleware } from './shared/http/errorMiddleware.js';
11+
import { standardLimiter, loginLimiter, strictLimiter, otpLimiter } from './shared/middleware/rateLimiter.js';
1112
import {
1213
standardLimiter,
1314
loginLimiter,

src/config/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ export const config = {
1414
allowedOrigins,
1515
redisUrl: env.REDIS_URL,
1616
corsOrigin: env.CORS_ORIGIN,
17+
18+
// SMTP (email)
1719
frontendUrl: env.FRONTEND_URL,
1820
smtp: {
1921
host: env.SMTP_HOST,
@@ -46,5 +48,10 @@ export const config = {
4648
sorobanRpcUrl: env.SOROBAN_RPC_URL,
4749
escrowContractId: env.ESCROW_CONTRACT_ID,
4850
sentryDsn: env.SENTRY_DSN,
51+
52+
// Frontend
53+
frontendUrl: env.FRONTEND_URL,
54+
55+
sendgridApiKey: env.SENDGRID_API_KEY,
4956
totpEncryptionKey: env.TOTP_ENCRYPTION_KEY,
5057
} as const;

src/env.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ const EnvSchema = z.object({
6868
// Frontend
6969
FRONTEND_URL: z.string().url('FRONTEND_URL must be a valid URL').default('http://localhost:3000'),
7070

71+
// Email (additional)
72+
SMTP_FROM: z.string().email('SMTP_FROM must be a valid email').optional(),
73+
SENDGRID_API_KEY: z.string().optional(),
7174
// TOTP 2FA — AES-256 encryption key for TOTP secrets stored in MongoDB.
7275
// Must be exactly 32 bytes (64 hex characters). Generate with:
7376
// node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

src/modules/payments/payments.controller.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ export const updatePaymentStatusController = asyncHandler(
110110
);
111111

112112
/**
113+
* GET /api/settlements/:id — full settlement detail including escrowRelease.
113114
* GET settlement detail including escrow/release fields.
114115
* Requires authentication. Used by `GET /:id` on the payments/settlements router.
115116
*
@@ -127,6 +128,8 @@ export const getSettlementByIdController = asyncHandler(
127128
);
128129

129130
/**
131+
* POST /api/settlements/:id/dispute — transition status to DISPUTED.
132+
* Restricted to ADMIN / MANAGER at route level.
130133
* Transitions a settlement to DISPUTED with a required reason.
131134
* Requires auth and ADMIN / MANAGER.
132135
*
@@ -148,6 +151,7 @@ export const disputeSettlementController = asyncHandler(
148151
);
149152

150153
/**
154+
* GET /api/settlements/summary — aggregated totals + sparkline.
151155
* Returns aggregated settlement totals and sparkline for a period.
152156
* Requires authentication.
153157
*
@@ -160,6 +164,10 @@ export const disputeSettlementController = asyncHandler(
160164
export const getSettlementSummaryController = asyncHandler(
161165
async (req: Request, res: Response): Promise<void> => {
162166
const period = (req.query as Record<string, string>).period ?? 'week';
167+
const summary = await getSettlementSummaryService(
168+
req.user?.organizationId ?? '',
169+
period
170+
);
163171
const summary = await getSettlementSummaryService(req.user?.organizationId ?? '', period);
164172
sendResponse(res, 200, true, 'Settlement summary retrieved successfully', summary);
165173
}

src/modules/payments/payments.service.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,3 +197,34 @@ export async function disputeSettlementService(id: string, input: DisputeSettlem
197197

198198
return augmentPayment(updated);
199199
}
200+
201+
/**
202+
* Transitions a settlement to DISPUTED status, recording dispute metadata.
203+
* Only ADMIN/MANAGER should be permitted to call this (enforced at route level).
204+
* @param {string} id - Settlement ObjectId.
205+
* @param {DisputeSettlementInput} input - Dispute reason and optional notes.
206+
* @returns {Promise<unknown>} Updated settlement document.
207+
* @throws {AppError} 404 when settlement not found.
208+
*/
209+
export async function disputeSettlementService(id: string, input: DisputeSettlementInput) {
210+
const payment = await paymentsRepo.getPaymentById(id);
211+
if (!payment) {
212+
throw new AppError(404, 'Settlement not found', ErrorCodes.PAYMENT_NOT_FOUND);
213+
}
214+
215+
const updated = await paymentsRepo.disputePayment(id, input.reason, input.notes);
216+
if (!updated) {
217+
throw new AppError(500, 'Failed to dispute settlement', ErrorCodes.INTERNAL_ERROR);
218+
}
219+
220+
emitPaymentStatusChange(updated.shipmentId.toString(), {
221+
paymentId: updated._id.toString(),
222+
shipmentId: updated.shipmentId.toString(),
223+
oldStatus: payment.status,
224+
newStatus: updated.status,
225+
amount: updated.amount,
226+
timestamp: new Date().toISOString(),
227+
});
228+
229+
return augmentPayment(updated);
230+
}

src/modules/payments/payments.validation.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,11 @@ export const DisputeSettlementBodySchema = z.object({
9595
notes: z.string().optional(),
9696
});
9797

98+
export const DisputeSettlementBodySchema = z.object({
99+
reason: z.string().min(1, 'Reason is required'),
100+
notes: z.string().optional(),
101+
});
102+
98103
export type CreatePaymentInput = z.infer<typeof CreatePaymentBodySchema>;
99104
export type UpdatePaymentStatusInput = z.infer<typeof UpdatePaymentStatusBodySchema>;
100105
export type GetPaymentsQuery = z.infer<typeof GetPaymentsQuerySchema>;

src/modules/telemetry/telemetryThreshold.service.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,15 @@ export async function updateOrgTelemetryThresholdsService(
5555
};
5656
}
5757

58+
import mongoose from 'mongoose';
59+
5860
export async function resolveTelemetryThresholdsForShipment(
5961
shipmentId: string
6062
): Promise<TelemetryThresholds> {
63+
if (!shipmentId || !mongoose.Types.ObjectId.isValid(shipmentId)) {
64+
return mergeWithDefaults(undefined);
65+
}
66+
6167
const shipment = await Shipment.findById(shipmentId)
6268
.select({ enterpriseId: 1, offChainMetadata: 1 })
6369
.lean<{

0 commit comments

Comments
 (0)