feat: add reconciliation issue workflow

This commit is contained in:
Codex
2026-06-29 19:05:18 +08:00
parent 7258e4a7d5
commit d696fc38b0
14 changed files with 924 additions and 26 deletions

View File

@@ -18,11 +18,15 @@ import {
updateRefundStatusRoute,
} from './routes.js';
import {
createReconciliationIssueRoute,
importReconciliationRoute,
previewReconciliationRoute,
reconciliationAnomaliesRoute,
reconciliationBatchesRoute,
reconciliationIssueEventsRoute,
reconciliationIssuesRoute,
reconciliationItemsRoute,
updateReconciliationIssueStatusRoute,
} from './reconciliation.js';
export const commerceRoutes: RouteDefinition[] = [
@@ -48,6 +52,10 @@ export const commerceRoutes: RouteDefinition[] = [
['GET', '/api/commerce/reconciliation/batches', reconciliationBatchesRoute],
['GET', '/api/commerce/reconciliation/items', reconciliationItemsRoute],
['GET', '/api/commerce/reconciliation/anomalies', reconciliationAnomaliesRoute],
['POST', '/api/commerce/reconciliation/issues/create', createReconciliationIssueRoute],
['GET', '/api/commerce/reconciliation/issues', reconciliationIssuesRoute],
['POST', '/api/commerce/reconciliation/issues/status', updateReconciliationIssueStatusRoute],
['GET', '/api/commerce/reconciliation/issues/events', reconciliationIssueEventsRoute],
['POST', '/api/commerce/activation-codes/check', checkActivationCodeRoute],
['POST', '/api/commerce/activation-codes/redeem', redeemActivationCodeRoute],
['POST', '/api/commerce/coupons/claim', claimCouponRoute],

View File

@@ -19,9 +19,37 @@ type MatchStatus =
| 'duplicate'
| 'ignored';
type Severity = 'info' | 'warning' | 'error' | 'critical';
type ReconciliationIssueStatus = 'open' | 'investigating' | 'resolved' | 'ignored' | 'escalated';
type ReconciliationResolutionType =
| 'none'
| 'provider_confirmed'
| 'local_corrected'
| 'manual_adjustment'
| 'false_positive'
| 'duplicate'
| 'write_off';
type ReconciliationIssueAction = 'start' | 'assign' | 'resolve' | 'ignore' | 'escalate' | 'reopen';
const BILL_TYPES = new Set<BillType>(['payment', 'refund', 'combined']);
const SOURCES = new Set<ReconciliationSource>(['manual_upload', 'provider_download', 'api', 'worker']);
const ACTIONABLE_MATCH_STATUSES = new Set<MatchStatus>([
'amount_mismatch',
'status_mismatch',
'missing_local',
'missing_provider',
'duplicate',
]);
const ISSUE_STATUSES = new Set<ReconciliationIssueStatus>(['open', 'investigating', 'resolved', 'ignored', 'escalated']);
const RESOLUTION_TYPES = new Set<ReconciliationResolutionType>([
'none',
'provider_confirmed',
'local_corrected',
'manual_adjustment',
'false_positive',
'duplicate',
'write_off',
]);
const ISSUE_ACTIONS = new Set<ReconciliationIssueAction>(['start', 'assign', 'resolve', 'ignore', 'escalate', 'reopen']);
const PROVIDER_SUCCESS_STATUSES = new Set([
'success',
'succeeded',
@@ -180,6 +208,32 @@ function objectValue(value: unknown): RawBillRow {
return value && typeof value === 'object' && !Array.isArray(value) ? value as RawBillRow : {};
}
function nullableString(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function jsonBodyValue(value: unknown) {
return JSON.stringify(objectValue(value));
}
function optionalChoice<T extends string>(value: unknown, allowed: Set<T>, fallback: T, code = 'INVALID_FIELD_VALUE') {
const candidate = (nullableString(value) || fallback) as T;
if (!allowed.has(candidate)) {
throw new HttpError(400, `Invalid value: ${candidate}`, code);
}
return candidate;
}
function timestampValue(value: unknown, fieldName: string) {
const raw = nullableString(value);
if (!raw) return null;
const date = new Date(raw);
if (Number.isNaN(date.getTime())) {
throw new HttpError(400, `${fieldName} must be a valid timestamp`, 'INVALID_TIMESTAMP');
}
return date.toISOString();
}
function optionalRowString(row: RawBillRow, keys: string[]) {
for (const key of keys) {
const value = row[key];
@@ -843,6 +897,59 @@ function itemPayload(row: Record<string, unknown>) {
};
}
function issuePayload(row: Record<string, unknown>) {
return {
id: row.id,
issueNo: row.issueNo,
batchId: row.batchId,
itemId: row.itemId,
provider: row.provider,
transactionType: row.transactionType,
issueCode: row.issueCode,
matchStatus: row.matchStatus,
severity: row.severity,
status: row.status,
resolutionType: row.resolutionType,
orderId: row.orderId,
paymentId: row.paymentId,
refundRequestId: row.refundRequestId,
orderNo: row.orderNo,
refundNo: row.refundNo,
providerTradeNo: row.providerTradeNo,
providerRefundNo: row.providerRefundNo,
amountCents: row.amountCents,
refundAmountCents: row.refundAmountCents,
assignedTo: row.assignedTo,
assignedToName: row.assignedToName,
createdBy: row.createdBy,
createdByName: row.createdByName,
resolvedBy: row.resolvedBy,
resolvedByName: row.resolvedByName,
resolvedAt: row.resolvedAt,
dueAt: row.dueAt,
summary: row.summary,
resolutionNote: row.resolutionNote,
metadata: row.metadata || {},
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
function issueEventPayload(row: Record<string, unknown>) {
return {
id: row.id,
issueId: row.issueId,
fromStatus: row.fromStatus,
toStatus: row.toStatus,
eventType: row.eventType,
actorUserId: row.actorUserId,
actorName: row.actorName,
note: row.note,
details: row.details || {},
createdAt: row.createdAt,
};
}
async function recordReconciliationAudit(client: pg.PoolClient, auth: TenantAdminAuth, action: string, targetId: string | null, details: Record<string, unknown>) {
await client.query(
`
@@ -853,6 +960,99 @@ async function recordReconciliationAudit(client: pg.PoolClient, auth: TenantAdmi
);
}
async function recordReconciliationIssueEvent(
client: pg.PoolClient,
auth: TenantAdminAuth,
input: {
issueId: string;
fromStatus?: string | null;
toStatus?: string | null;
eventType: string;
note?: string | null;
details?: Record<string, unknown>;
},
) {
await client.query(
`
insert into public.commerce_reconciliation_issue_events (
tenant_id, issue_id, from_status, to_status, event_type, actor_user_id, note, details
)
values ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)
`,
[
auth.tenantId,
input.issueId,
input.fromStatus || null,
input.toStatus || null,
input.eventType,
auth.userId,
input.note || null,
JSON.stringify(input.details || {}),
],
);
}
async function ensureAssignableTenantMember(client: pg.PoolClient, tenantId: string, userId: string | null) {
if (!userId) return null;
const result = await client.query<{ id: string }>(
`
select tm.user_id as id
from public.tenant_memberships tm
where tm.tenant_id = $1
and tm.user_id = $2
and tm.status = 'active'
and tm.role in ('tenant_owner', 'tenant_admin', 'tenant_operator', 'teacher', 'sales', 'agent')
limit 1
`,
[tenantId, userId],
);
if (!result.rows[0]) {
throw new HttpError(400, 'Assignee must be an active tenant staff member', 'RECONCILIATION_ASSIGNEE_INVALID');
}
return userId;
}
function nextIssueNo() {
const dateKey = new Date().toISOString().slice(0, 10).replace(/-/g, '');
return `RC-${dateKey}-${crypto.randomInt(100000, 999999)}`;
}
function issueSummaryForItem(item: Record<string, unknown>, summary: string | null) {
if (summary) return summary;
const identity = item.orderNo || item.refundNo || item.providerTradeNo || item.providerRefundNo || item.id;
return `${item.issueCode || item.matchStatus || 'RECONCILIATION_ISSUE'} ${identity}`;
}
async function fetchIssueForTenant(client: pg.PoolClient, tenantId: string, issueId: string, lock = false) {
const result = await client.query<Record<string, unknown>>(
`
select i.id, i.issue_no as "issueNo", i.batch_id as "batchId", i.item_id as "itemId",
i.provider, i.transaction_type as "transactionType", i.issue_code as "issueCode",
i.match_status as "matchStatus", i.severity, i.status,
i.resolution_type as "resolutionType", i.order_id as "orderId",
i.payment_id as "paymentId", i.refund_request_id as "refundRequestId",
i.order_no as "orderNo", i.refund_no as "refundNo",
i.provider_trade_no as "providerTradeNo", i.provider_refund_no as "providerRefundNo",
i.amount_cents as "amountCents", i.refund_amount_cents as "refundAmountCents",
i.assigned_to as "assignedTo", assignee.name as "assignedToName",
i.created_by as "createdBy", creator.name as "createdByName",
i.resolved_by as "resolvedBy", resolver.name as "resolvedByName",
i.resolved_at as "resolvedAt", i.due_at as "dueAt", i.summary,
i.resolution_note as "resolutionNote", i.metadata,
i.created_at as "createdAt", i.updated_at as "updatedAt"
from public.commerce_reconciliation_issues i
left join public.platform_users assignee on assignee.id = i.assigned_to
left join public.platform_users creator on creator.id = i.created_by
left join public.platform_users resolver on resolver.id = i.resolved_by
where i.tenant_id = $1 and i.id = $2
limit 1
${lock ? 'for update of i' : ''}
`,
[tenantId, issueId],
);
return result.rows[0] || null;
}
async function authorizeRead(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireTenantPermission(auth, 'tenant:reconciliation:read');
@@ -1163,3 +1363,394 @@ export async function reconciliationAnomaliesRoute(ctx: RequestContext) {
paymentEventErrors,
};
}
export async function createReconciliationIssueRoute(ctx: RequestContext) {
const auth = await authorizeWrite(ctx);
const body = await readJsonBody(ctx);
const itemId = requiredString(body, 'itemId');
const assignedTo = nullableString(body.assignedTo) || nullableString(body.assignedToUserId);
const dueAt = timestampValue(body.dueAt, 'dueAt');
const summary = nullableString(body.summary);
const note = nullableString(body.note);
const result = await transaction(async client => {
const itemResult = await client.query<Record<string, unknown>>(
`
select id, batch_id as "batchId", provider, transaction_type as "transactionType",
provider_trade_no as "providerTradeNo", provider_refund_no as "providerRefundNo",
order_no as "orderNo", refund_no as "refundNo",
amount_cents as "amountCents", refund_amount_cents as "refundAmountCents",
order_id as "orderId", payment_id as "paymentId", refund_request_id as "refundRequestId",
match_status as "matchStatus", severity, issue_code as "issueCode", details
from public.commerce_reconciliation_items
where tenant_id = $1 and id = $2
limit 1
`,
[auth.tenantId, itemId],
);
const item = itemResult.rows[0];
if (!item) throw new HttpError(404, 'Reconciliation item not found', 'RECONCILIATION_ITEM_NOT_FOUND');
if (!ACTIONABLE_MATCH_STATUSES.has(item.matchStatus as MatchStatus)) {
throw new HttpError(400, 'Reconciliation item is not actionable', 'RECONCILIATION_ITEM_NOT_ACTIONABLE');
}
const existing = await client.query<Record<string, unknown>>(
`
select i.id, i.issue_no as "issueNo", i.batch_id as "batchId", i.item_id as "itemId",
i.provider, i.transaction_type as "transactionType", i.issue_code as "issueCode",
i.match_status as "matchStatus", i.severity, i.status,
i.resolution_type as "resolutionType", i.order_id as "orderId",
i.payment_id as "paymentId", i.refund_request_id as "refundRequestId",
i.order_no as "orderNo", i.refund_no as "refundNo",
i.provider_trade_no as "providerTradeNo", i.provider_refund_no as "providerRefundNo",
i.amount_cents as "amountCents", i.refund_amount_cents as "refundAmountCents",
i.assigned_to as "assignedTo", assignee.name as "assignedToName",
i.created_by as "createdBy", creator.name as "createdByName",
i.resolved_by as "resolvedBy", resolver.name as "resolvedByName",
i.resolved_at as "resolvedAt", i.due_at as "dueAt", i.summary,
i.resolution_note as "resolutionNote", i.metadata,
i.created_at as "createdAt", i.updated_at as "updatedAt"
from public.commerce_reconciliation_issues i
left join public.platform_users assignee on assignee.id = i.assigned_to
left join public.platform_users creator on creator.id = i.created_by
left join public.platform_users resolver on resolver.id = i.resolved_by
where i.tenant_id = $1
and i.item_id = $2
and i.status in ('open', 'investigating', 'escalated')
order by i.created_at desc
limit 1
`,
[auth.tenantId, itemId],
);
if (existing.rows[0]) return { issue: existing.rows[0], idempotent: true };
const assignee = await ensureAssignableTenantMember(client, auth.tenantId, assignedTo);
const issue = await client.query<Record<string, unknown>>(
`
insert into public.commerce_reconciliation_issues (
tenant_id, batch_id, item_id, issue_no, provider, transaction_type,
issue_code, match_status, severity, status, resolution_type,
order_id, payment_id, refund_request_id, order_no, refund_no,
provider_trade_no, provider_refund_no, amount_cents, refund_amount_cents,
assigned_to, created_by, due_at, summary, metadata
)
values (
$1, $2::uuid, $3::uuid, $4, $5, $6,
$7, $8, $9, 'open', 'none',
$10::uuid, $11::uuid, $12::uuid, $13, $14,
$15, $16, $17, $18,
$19::uuid, $20::uuid, $21::timestamptz, $22, $23::jsonb
)
returning id, issue_no as "issueNo", batch_id as "batchId", item_id as "itemId",
provider, transaction_type as "transactionType", issue_code as "issueCode",
match_status as "matchStatus", severity, status,
resolution_type as "resolutionType", order_id as "orderId",
payment_id as "paymentId", refund_request_id as "refundRequestId",
order_no as "orderNo", refund_no as "refundNo",
provider_trade_no as "providerTradeNo", provider_refund_no as "providerRefundNo",
amount_cents as "amountCents", refund_amount_cents as "refundAmountCents",
assigned_to as "assignedTo", created_by as "createdBy", resolved_by as "resolvedBy",
resolved_at as "resolvedAt", due_at as "dueAt", summary,
resolution_note as "resolutionNote", metadata, created_at as "createdAt",
updated_at as "updatedAt"
`,
[
auth.tenantId,
item.batchId,
item.id,
nextIssueNo(),
item.provider,
item.transactionType,
item.issueCode,
item.matchStatus,
item.severity,
item.orderId,
item.paymentId,
item.refundRequestId,
item.orderNo,
item.refundNo,
item.providerTradeNo,
item.providerRefundNo,
item.amountCents,
item.refundAmountCents,
assignee,
auth.userId,
dueAt,
issueSummaryForItem(item, summary),
jsonBodyValue(body.metadata),
],
);
const created = await fetchIssueForTenant(client, auth.tenantId, String(issue.rows[0].id));
await recordReconciliationIssueEvent(client, auth, {
issueId: String(issue.rows[0].id),
toStatus: 'open',
eventType: 'created',
note,
details: {
itemId,
assignedTo: assignee,
dueAt,
matchStatus: item.matchStatus,
issueCode: item.issueCode,
},
});
await recordReconciliationAudit(client, auth, 'commerce.reconciliation_issue.created', String(issue.rows[0].id), {
itemId,
batchId: item.batchId,
assignedTo: assignee,
dueAt,
matchStatus: item.matchStatus,
issueCode: item.issueCode,
});
return { issue: created || issue.rows[0], idempotent: false };
});
return { item: issuePayload(result.issue), idempotent: result.idempotent };
}
export async function reconciliationIssuesRoute(ctx: RequestContext) {
const auth = await authorizeRead(ctx);
const limit = intParam(ctx, 'limit', 50, 200);
const status = stringParam(ctx, 'status');
const severity = stringParam(ctx, 'severity');
const assignedTo = stringParam(ctx, 'assignedTo') || stringParam(ctx, 'assignedToUserId');
const batchId = stringParam(ctx, 'batchId');
const orderNo = stringParam(ctx, 'orderNo');
const params: unknown[] = [auth.tenantId, limit];
const where = ['i.tenant_id = $1'];
if (status) {
if (!ISSUE_STATUSES.has(status as ReconciliationIssueStatus)) {
throw new HttpError(400, 'status is invalid', 'RECONCILIATION_ISSUE_STATUS_INVALID');
}
params.push(status);
where.push(`i.status = $${params.length}`);
}
if (severity) {
params.push(severity);
where.push(`i.severity = $${params.length}`);
}
if (assignedTo) {
params.push(assignedTo);
where.push(`i.assigned_to = $${params.length}::uuid`);
}
if (batchId) {
params.push(batchId);
where.push(`i.batch_id = $${params.length}::uuid`);
}
if (orderNo) {
params.push(orderNo);
where.push(`i.order_no = $${params.length}`);
}
const items = await query<Record<string, unknown>>(
`
select i.id, i.issue_no as "issueNo", i.batch_id as "batchId", i.item_id as "itemId",
i.provider, i.transaction_type as "transactionType", i.issue_code as "issueCode",
i.match_status as "matchStatus", i.severity, i.status,
i.resolution_type as "resolutionType", i.order_id as "orderId",
i.payment_id as "paymentId", i.refund_request_id as "refundRequestId",
i.order_no as "orderNo", i.refund_no as "refundNo",
i.provider_trade_no as "providerTradeNo", i.provider_refund_no as "providerRefundNo",
i.amount_cents as "amountCents", i.refund_amount_cents as "refundAmountCents",
i.assigned_to as "assignedTo", assignee.name as "assignedToName",
i.created_by as "createdBy", creator.name as "createdByName",
i.resolved_by as "resolvedBy", resolver.name as "resolvedByName",
i.resolved_at as "resolvedAt", i.due_at as "dueAt", i.summary,
i.resolution_note as "resolutionNote", i.metadata,
i.created_at as "createdAt", i.updated_at as "updatedAt"
from public.commerce_reconciliation_issues i
left join public.platform_users assignee on assignee.id = i.assigned_to
left join public.platform_users creator on creator.id = i.created_by
left join public.platform_users resolver on resolver.id = i.resolved_by
where ${where.join(' and ')}
order by
case i.status
when 'open' then 1
when 'investigating' then 2
when 'escalated' then 3
when 'resolved' then 4
else 5
end,
i.severity desc,
i.created_at desc
limit $2
`,
params,
);
return { items: items.map(issuePayload) };
}
export async function updateReconciliationIssueStatusRoute(ctx: RequestContext) {
const auth = await authorizeWrite(ctx);
const body = await readJsonBody(ctx);
const issueId = requiredString(body, 'issueId');
const action = optionalChoice(body.action, ISSUE_ACTIONS, 'start', 'RECONCILIATION_ISSUE_ACTION_INVALID');
const note = nullableString(body.note);
const dueAt = timestampValue(body.dueAt, 'dueAt');
const assignedTo = nullableString(body.assignedTo) || nullableString(body.assignedToUserId);
const resolutionTypeInput = nullableString(body.resolutionType);
const result = await transaction(async client => {
const issue = await fetchIssueForTenant(client, auth.tenantId, issueId, true);
if (!issue) throw new HttpError(404, 'Reconciliation issue not found', 'RECONCILIATION_ISSUE_NOT_FOUND');
const currentStatus = String(issue.status) as ReconciliationIssueStatus;
let nextStatus = currentStatus;
let nextResolutionType = String(issue.resolutionType || 'none') as ReconciliationResolutionType;
let resolvedBy: string | null = String(issue.resolvedBy || '') || null;
let resolvedAt: string | null = String(issue.resolvedAt || '') || null;
let resolutionNote = nullableString(body.resolutionNote) || nullableString(body.note) || String(issue.resolutionNote || '') || null;
const assignee = assignedTo ? await ensureAssignableTenantMember(client, auth.tenantId, assignedTo) : null;
const details: Record<string, unknown> = {
action,
previousStatus: currentStatus,
};
if (action === 'start') {
if (!['open', 'escalated'].includes(currentStatus)) {
throw new HttpError(409, 'Only open or escalated issues can be started', 'RECONCILIATION_ISSUE_STATUS_CONFLICT');
}
nextStatus = 'investigating';
} else if (action === 'assign') {
if (!assignee) {
throw new HttpError(400, 'assignedTo is required for assign action', 'RECONCILIATION_ASSIGNEE_REQUIRED');
}
details.assignedTo = assignee;
} else if (action === 'resolve') {
if (['resolved', 'ignored'].includes(currentStatus)) {
throw new HttpError(409, 'Closed reconciliation issues must be reopened first', 'RECONCILIATION_ISSUE_STATUS_CONFLICT');
}
nextResolutionType = optionalChoice(
resolutionTypeInput,
RESOLUTION_TYPES,
'manual_adjustment',
'RECONCILIATION_RESOLUTION_TYPE_INVALID',
);
if (nextResolutionType === 'none') {
throw new HttpError(400, 'resolutionType is required when resolving an issue', 'RECONCILIATION_RESOLUTION_REQUIRED');
}
nextStatus = 'resolved';
resolvedBy = auth.userId;
resolvedAt = new Date().toISOString();
details.resolutionType = nextResolutionType;
} else if (action === 'ignore') {
if (['resolved', 'ignored'].includes(currentStatus)) {
throw new HttpError(409, 'Closed reconciliation issues must be reopened first', 'RECONCILIATION_ISSUE_STATUS_CONFLICT');
}
nextResolutionType = optionalChoice(
resolutionTypeInput,
RESOLUTION_TYPES,
'false_positive',
'RECONCILIATION_RESOLUTION_TYPE_INVALID',
);
if (nextResolutionType === 'none') nextResolutionType = 'false_positive';
nextStatus = 'ignored';
resolvedBy = auth.userId;
resolvedAt = new Date().toISOString();
details.resolutionType = nextResolutionType;
} else if (action === 'escalate') {
if (['resolved', 'ignored'].includes(currentStatus)) {
throw new HttpError(409, 'Closed reconciliation issues must be reopened first', 'RECONCILIATION_ISSUE_STATUS_CONFLICT');
}
nextStatus = 'escalated';
} else if (action === 'reopen') {
if (!['resolved', 'ignored'].includes(currentStatus)) {
throw new HttpError(409, 'Only closed reconciliation issues can be reopened', 'RECONCILIATION_ISSUE_STATUS_CONFLICT');
}
nextStatus = 'open';
nextResolutionType = 'none';
resolvedBy = null;
resolvedAt = null;
resolutionNote = null;
}
const update = await client.query<Record<string, unknown>>(
`
update public.commerce_reconciliation_issues
set status = $3,
resolution_type = $4,
resolution_note = $5,
assigned_to = coalesce($6::uuid, assigned_to),
due_at = coalesce($7::timestamptz, due_at),
resolved_by = $8::uuid,
resolved_at = $9::timestamptz,
metadata = metadata || $10::jsonb,
updated_at = now()
where tenant_id = $1 and id = $2
returning id
`,
[
auth.tenantId,
issueId,
nextStatus,
nextResolutionType,
resolutionNote,
assignee,
dueAt,
resolvedBy,
resolvedAt,
jsonBodyValue(body.metadata),
],
);
if (!update.rows[0]) throw new HttpError(404, 'Reconciliation issue not found', 'RECONCILIATION_ISSUE_NOT_FOUND');
await recordReconciliationIssueEvent(client, auth, {
issueId,
fromStatus: currentStatus,
toStatus: nextStatus,
eventType: action,
note,
details: {
...details,
assignedTo: assignee || issue.assignedTo || null,
dueAt: dueAt || issue.dueAt || null,
},
});
await recordReconciliationAudit(client, auth, 'commerce.reconciliation_issue.status_updated', issueId, {
action,
fromStatus: currentStatus,
toStatus: nextStatus,
assignedTo: assignee || issue.assignedTo || null,
dueAt: dueAt || issue.dueAt || null,
resolutionType: nextResolutionType,
noteProvided: Boolean(note),
});
const nextIssue = await fetchIssueForTenant(client, auth.tenantId, issueId);
return nextIssue || issue;
});
return { item: issuePayload(result) };
}
export async function reconciliationIssueEventsRoute(ctx: RequestContext) {
const auth = await authorizeRead(ctx);
const issueId = stringParam(ctx, 'issueId');
if (!issueId) throw new HttpError(400, 'issueId is required', 'RECONCILIATION_ISSUE_ID_REQUIRED');
const limit = intParam(ctx, 'limit', 100, 300);
const issue = await query<Record<string, unknown>>(
'select id from public.commerce_reconciliation_issues where tenant_id = $1 and id = $2 limit 1',
[auth.tenantId, issueId],
);
if (!issue[0]) throw new HttpError(404, 'Reconciliation issue not found', 'RECONCILIATION_ISSUE_NOT_FOUND');
const events = await query<Record<string, unknown>>(
`
select e.id, e.issue_id as "issueId", e.from_status as "fromStatus",
e.to_status as "toStatus", e.event_type as "eventType",
e.actor_user_id as "actorUserId", actor.name as "actorName",
e.note, e.details, e.created_at as "createdAt"
from public.commerce_reconciliation_issue_events e
left join public.platform_users actor on actor.id = e.actor_user_id
where e.tenant_id = $1 and e.issue_id = $2
order by e.created_at asc
limit $3
`,
[auth.tenantId, issueId, limit],
);
return { items: events.map(issueEventPayload) };
}