forked from wangziqi/gongxue-base
feat: add reconciliation issue workflow
This commit is contained in:
@@ -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],
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user