feat(bookkeeping): correction-chain depth guard + Bedrock stream retry (#1581)
* feat(bookkeeping): bypassable chain-depth guard on corrections and stornos Correcting or reversing an entry that already sits 3+ links deep in a rattelse chain (correction_of_id/reverses_id walked in the DB, never description matching) now throws CORRECTION_CHAIN_TOO_DEEP, steering the caller to book ONE correction expressing the chain's net effect. Agents looped storno+rattelse 10 deep on a live company (63/193 vouchers noise). The guard is advisory, never a dead end: allow_deep_chain bypasses it on every surface (correctEntry/reverseEntry option, REST body, MCP tool arg staged through pending_operations, and confirm dialogs with Ratta anda / Aterfor anda in the web UI). MCP staging pre-flight fires the guard at stage time so the agent reconsiders in the same turn, and the executor re-checks at commit. tools/list payload ceiling bumped 59K -> 59.5K for the two bypass properties (trimmed to one sentence first). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(agent): retry the Bedrock stream once on transient failures A transient stream death (429/5xx, transport cut, or the two known stream-corruption signatures: 'Unexpected event order' and 'request ended without sending any chunks') killed the whole chat turn, stranding the user mid-answer. The turn now retries once per turn after a short backoff: safe because nothing is persisted until finalMessage() succeeds. A new stream_restart event carries the pre-attempt text snapshot so the chat client resets the partial bubble, drops uncompleted tool chips, and shows 'Forsoker igen...' until the retried stream produces text. Non-transient errors (403, 400) keep the existing immediate-error path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(api): regenerate accounted-api skill and wire allow_deep_chain through v1 apiskill:check failed: CorrectJournalEntrySchema gained allow_deep_chain, making references/journal-entries.md stale. Regenerated (hand-applied: the generator output is deterministic from the registry). While wiring: the v1 correct route validated allow_deep_chain but dropped it, and the v1 reverse route's strict body schema would have rejected it outright, leaving API clients no bypass when the chain-depth guard fires. Both now forward the flag to the engine and document CORRECTION_CHAIN_TOO_DEEP as a pitfall. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: re-trigger CI after Vercel infra hang The preview for e527e4044 compiled in 91s then hung 40 minutes in the TypeScript phase and was killed with no error output; a CLI redeploy of the identical code went Ready in 5m. Empty commit to refresh the git- triggered deployment status. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bookkeeping): address CodeRabbit review on the chain-depth guard - correction-chain: report rootVoucher only when the walk reached a genuine parentless root; a broken link, cycle, or hop-cap now yields null instead of presenting an intermediate voucher as the chain root. - recordate: propagate allow_deep_chain end-to-end (recordateEntry option, route schema, and a Flytta anda bypass confirm in the dialog); a date move is another storno+rattelse layer and carried the guard with no override path. - v1 correct/reverse: run the chain-depth guard before the dry-run return so a dry run gives the same verdict as the real execution. - dashboard reverse route: 400 on malformed JSON or a non-boolean allow_deep_chain instead of silently reversing without the override; empty body stays the supported no-body case. Tests added. - AgentChat stream_restart: discard the dead attempt's reasoning and re-arm the post-tool paragraph break so a retried turn doesn't render thinking twice or glue its continuation onto restored text. - v1 reverse route doc comment updated for allow_deep_chain. Not changed: the journal-list reverse flow (flagged as a dead end) can never receive CORRECTION_CHAIN_TOO_DEEP: the list renders Aterfor only for entries that are neither storno nor correction, and such entries have no backward chain links, so their depth is always 0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(bookkeeping): recordate route test expects the new options arg recordateEntry now takes { allowDeepChain } as a sixth argument; the route test's called-with assertion predates it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
e494662530
commit
05380ddf54
@@ -15,6 +15,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
if (!validation.success) return validation.response
|
||||
const result = await correctEntry(supabase, companyId, user.id, id, validation.data.lines, {
|
||||
description: validation.data.description,
|
||||
allowDeepChain: validation.data.allow_deep_chain,
|
||||
})
|
||||
return NextResponse.json({ data: result })
|
||||
},
|
||||
|
||||
@@ -109,7 +109,8 @@ describe('POST /api/bookkeeping/journal-entries/[id]/recordate', () => {
|
||||
'company-1',
|
||||
'user-1',
|
||||
'entry-1',
|
||||
'2025-07-03'
|
||||
'2025-07-03',
|
||||
{ allowDeepChain: undefined }
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -13,7 +13,9 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
const { id } = await params
|
||||
const validation = await validateBody(request, RecordateJournalEntrySchema)
|
||||
if (!validation.success) return validation.response
|
||||
const result = await recordateEntry(supabase, companyId, user.id, id, validation.data.new_entry_date)
|
||||
const result = await recordateEntry(supabase, companyId, user.id, id, validation.data.new_entry_date, {
|
||||
allowDeepChain: validation.data.allow_deep_chain,
|
||||
})
|
||||
return NextResponse.json({ data: result })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
|
||||
@@ -74,7 +74,69 @@ describe('POST /api/bookkeeping/journal-entries/[id]/reverse', () => {
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toEqual(reversalEntry)
|
||||
expect(mockReverseEntry).toHaveBeenCalledWith(expect.anything(), 'company-1', 'user-1', 'entry-1')
|
||||
expect(mockReverseEntry).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'company-1',
|
||||
'user-1',
|
||||
'entry-1',
|
||||
undefined,
|
||||
{ allowDeepChain: false },
|
||||
)
|
||||
})
|
||||
|
||||
it('returns 400 for malformed JSON instead of silently reversing without the override', async () => {
|
||||
// Raw Request: createMockRequest would JSON.stringify the body and turn
|
||||
// the malformed payload into a valid JSON string literal.
|
||||
const request = new Request('http://localhost:3000/api/bookkeeping/journal-entries/entry-1/reverse', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{ allow_deep_chain: tru',
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'entry-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('VALIDATION_ERROR')
|
||||
expect(mockReverseEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 400 for a non-boolean allow_deep_chain', async () => {
|
||||
const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/reverse', {
|
||||
method: 'POST',
|
||||
body: { allow_deep_chain: 'yes' },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'entry-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('VALIDATION_ERROR')
|
||||
expect(mockReverseEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forwards allow_deep_chain=true from the body (guard bypass)', async () => {
|
||||
const reversalEntry = makeJournalEntry({
|
||||
id: 'reversal-1',
|
||||
reverses_id: 'entry-1',
|
||||
source_type: 'storno',
|
||||
})
|
||||
mockReverseEntry.mockResolvedValue(reversalEntry)
|
||||
|
||||
const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/reverse', {
|
||||
method: 'POST',
|
||||
body: { allow_deep_chain: true },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'entry-1' }))
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(mockReverseEntry).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'company-1',
|
||||
'user-1',
|
||||
'entry-1',
|
||||
undefined,
|
||||
{ allowDeepChain: true },
|
||||
)
|
||||
})
|
||||
|
||||
it('maps a typed concurrent-reversal error to the canonical envelope (409)', async () => {
|
||||
|
||||
@@ -1,15 +1,68 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { reverseEntry } from '@/lib/bookkeeping/engine'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
|
||||
const ReverseJournalEntrySchema = z
|
||||
.object({
|
||||
allow_deep_chain: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'bookkeeping.journal-entry.reverse',
|
||||
async (_request, { supabase, companyId, user }, { params }) => {
|
||||
async (request, { supabase, companyId, user }, { params }) => {
|
||||
const { id } = await params
|
||||
const reversalEntry = await reverseEntry(supabase, companyId, user.id, id)
|
||||
// Body is optional (existing callers POST with none): the only accepted
|
||||
// field is the chain-depth guard override from the "Återför ändå" confirm.
|
||||
// An empty body is the supported no-body case; malformed JSON or a
|
||||
// non-boolean field is a caller bug and gets a 400 instead of silently
|
||||
// reversing without the override the caller thought they sent.
|
||||
let allowDeepChain = false
|
||||
const rawText = await request.text()
|
||||
if (rawText.trim()) {
|
||||
let parsedJson: unknown
|
||||
try {
|
||||
parsedJson = JSON.parse(rawText)
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Ogiltig JSON i förfrågan.',
|
||||
message_en: 'Body is not valid JSON.',
|
||||
},
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
const parsed = ReverseJournalEntrySchema.safeParse(parsedJson)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Ogiltigt fält i förfrågan.',
|
||||
message_en: 'Invalid request body.',
|
||||
details: {
|
||||
issues: parsed.error.issues.map((i) => ({
|
||||
field: i.path.join('.'),
|
||||
message: i.message,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
allowDeepChain = parsed.data.allow_deep_chain === true
|
||||
}
|
||||
const reversalEntry = await reverseEntry(supabase, companyId, user.id, id, undefined, {
|
||||
allowDeepChain,
|
||||
})
|
||||
return NextResponse.json({ data: reversalEntry })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
|
||||
@@ -6,11 +6,12 @@
|
||||
* posted with the new lines. All three remain in the verifikationsserie,
|
||||
* linked via reverses_id, reversed_by_id, and correction_of_id.
|
||||
*
|
||||
* Body: `{ lines: [...], description? }`: the new balanced lines. The
|
||||
* corrected entry inherits entry_date, fiscal_period_id, and voucher_series
|
||||
* from the original. Its description defaults to "Rättelse: <original>";
|
||||
* pass `description` to override it (e.g. when the original label named the
|
||||
* wrong account).
|
||||
* Body: `{ lines: [...], description?, allow_deep_chain? }`: the new balanced
|
||||
* lines. The corrected entry inherits entry_date, fiscal_period_id, and
|
||||
* voucher_series from the original. Its description defaults to
|
||||
* "Rättelse: <original>"; pass `description` to override it (e.g. when the
|
||||
* original label named the wrong account). `allow_deep_chain` bypasses the
|
||||
* correction-chain depth guard (CORRECTION_CHAIN_TOO_DEEP at 3+ levels).
|
||||
*
|
||||
* Idempotent (mandatory Idempotency-Key). Dry-runnable.
|
||||
*/
|
||||
@@ -25,7 +26,8 @@ import { checkPeriodLock } from '@/lib/api/v1/check-period-lock'
|
||||
import { CorrectJournalEntrySchema } from '@/lib/api/schemas'
|
||||
import { validateBalance } from '@/lib/bookkeeping/engine'
|
||||
import { correctEntry } from '@/lib/core/bookkeeping/storno-service'
|
||||
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
|
||||
import { correctionChainDepth, CORRECTION_CHAIN_GUARD_DEPTH } from '@/lib/core/bookkeeping/correction-chain'
|
||||
import { CorrectionChainTooDeepError, isBookkeepingError } from '@/lib/bookkeeping/errors'
|
||||
|
||||
const JournalEntryCorrected = z.object({
|
||||
reversal_id: z.string().uuid(),
|
||||
@@ -52,6 +54,7 @@ registerEndpoint({
|
||||
'The new lines must balance. JOURNAL_ENTRY_NOT_BALANCED if not.',
|
||||
'The original\'s entry_date and fiscal_period_id are inherited. If the original\'s period has been locked since posting, the call returns PERIOD_LOCKED.',
|
||||
'Three voucher numbers are advanced in this call: the original (already burned), the reversal, and the corrected. The series stays unbroken.',
|
||||
'A chain 3+ corrections deep returns CORRECTION_CHAIN_TOO_DEEP (409). Compute the net effect of the whole chain and book ONE correction, or pass allow_deep_chain=true to override.',
|
||||
],
|
||||
example: {
|
||||
request: {
|
||||
@@ -110,7 +113,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
details: { issues: parsed.error.issues.map((i) => ({ field: i.path.join('.'), message: i.message })) },
|
||||
})
|
||||
}
|
||||
const { lines, description } = parsed.data
|
||||
const { lines, description, allow_deep_chain } = parsed.data
|
||||
|
||||
const balance = validateBalance(lines)
|
||||
if (!balance.valid) {
|
||||
@@ -124,7 +127,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
// CANNOT_CORRECT_NON_POSTED otherwise but we want the structured envelope).
|
||||
const { data: original, error: fetchErr } = await ctx.supabase
|
||||
.from('journal_entries')
|
||||
.select('id, status, entry_date, voucher_series')
|
||||
.select('id, status, entry_date, voucher_series, correction_of_id, reverses_id')
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.eq('id', entryId)
|
||||
.maybeSingle()
|
||||
@@ -133,7 +136,14 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
if (!original) {
|
||||
return v1ErrorResponseFromCode('JOURNAL_ENTRY_NOT_FOUND', ctx.log, { requestId: ctx.requestId })
|
||||
}
|
||||
const typed = original as { id: string; status: string; entry_date: string; voucher_series: string }
|
||||
const typed = original as {
|
||||
id: string
|
||||
status: string
|
||||
entry_date: string
|
||||
voucher_series: string
|
||||
correction_of_id: string | null
|
||||
reverses_id: string | null
|
||||
}
|
||||
if (typed.status !== 'posted') {
|
||||
return v1ErrorResponseFromCode('CANNOT_CORRECT_NON_POSTED', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
@@ -141,6 +151,21 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
})
|
||||
}
|
||||
|
||||
// Chain-depth guard BEFORE the dry-run return: a dry run must give the
|
||||
// same verdict the real execution would, so a depth-3 chain without the
|
||||
// override reports CORRECTION_CHAIN_TOO_DEEP here too. correctEntry
|
||||
// re-checks on the real path.
|
||||
if (!allow_deep_chain) {
|
||||
const chain = await correctionChainDepth(ctx.supabase, ctx.companyId!, typed)
|
||||
if (chain.depth >= CORRECTION_CHAIN_GUARD_DEPTH) {
|
||||
return v1ErrorResponse(
|
||||
new CorrectionChainTooDeepError(chain.depth, chain.rootVoucher),
|
||||
ctx.log,
|
||||
{ requestId: ctx.requestId },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Period-lock pre-check on the INHERITED entry_date. /reverse already has
|
||||
// this guard against its `reversal_date`; /correct must match because
|
||||
// both the storno and the corrected entry land on typed.entry_date and
|
||||
@@ -180,7 +205,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
ctx.userId,
|
||||
entryId,
|
||||
lines,
|
||||
{ description },
|
||||
{ description, allowDeepChain: allow_deep_chain },
|
||||
)
|
||||
return ok(
|
||||
{
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
* the reversal carries `reverses_id` back to it and the original is annotated
|
||||
* with `reversed_by_id`. Both entries remain visible in the verifikationsserie.
|
||||
*
|
||||
* Optional body: `{ reversal_date?: ISO date }`. Defaults to today.
|
||||
* Optional body: `{ reversal_date?: ISO date, allow_deep_chain?: boolean }`.
|
||||
* reversal_date defaults to today; allow_deep_chain overrides the
|
||||
* correction-chain depth guard (CORRECTION_CHAIN_TOO_DEEP at 3+ levels).
|
||||
*
|
||||
* Idempotent (mandatory Idempotency-Key).
|
||||
*/
|
||||
@@ -19,11 +21,16 @@ import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import { checkPeriodLock } from '@/lib/api/v1/check-period-lock'
|
||||
import { reverseEntry } from '@/lib/bookkeeping/engine'
|
||||
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
|
||||
import { correctionChainDepth, CORRECTION_CHAIN_GUARD_DEPTH } from '@/lib/core/bookkeeping/correction-chain'
|
||||
import { CorrectionChainTooDeepError, isBookkeepingError } from '@/lib/bookkeeping/errors'
|
||||
|
||||
const ReverseRequest = z
|
||||
.object({
|
||||
reversal_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'reversal_date must be ISO YYYY-MM-DD').optional(),
|
||||
// Explicit override of the correction-chain depth guard: reversing an
|
||||
// entry 3+ links deep in a rättelse chain returns
|
||||
// CORRECTION_CHAIN_TOO_DEEP without it.
|
||||
allow_deep_chain: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
@@ -51,6 +58,7 @@ registerEndpoint({
|
||||
'Idempotency-Key is mandatory.',
|
||||
'reversal_date defaults to today; the reversal is posted in the fiscal period covering that date. If today\'s period is locked the call returns PERIOD_LOCKED.',
|
||||
'You cannot reverse a draft (status must be posted). Use /correct after commit if the original needs replacing.',
|
||||
'Reversing an entry 3+ links deep in a correction chain returns CORRECTION_CHAIN_TOO_DEEP (409). Book ONE net-effect correction instead, or pass allow_deep_chain=true to override.',
|
||||
],
|
||||
example: {
|
||||
request: { reversal_date: '2026-05-13' },
|
||||
@@ -85,6 +93,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
const entryId = idParse.data
|
||||
|
||||
let bodyReversalDate: string | undefined
|
||||
let bodyAllowDeepChain = false
|
||||
let rawBody: unknown = null
|
||||
try {
|
||||
const text = await request.text()
|
||||
@@ -104,6 +113,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
})
|
||||
}
|
||||
bodyReversalDate = parsed.data.reversal_date
|
||||
bodyAllowDeepChain = parsed.data.allow_deep_chain === true
|
||||
}
|
||||
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
@@ -122,7 +132,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
// Pre-flight: confirm the original exists, is posted, and not already reversed.
|
||||
const { data: original, error: fetchErr } = await ctx.supabase
|
||||
.from('journal_entries')
|
||||
.select('id, status, reversed_by_id, voucher_series, voucher_number')
|
||||
.select('id, status, reversed_by_id, voucher_series, voucher_number, correction_of_id, reverses_id')
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.eq('id', entryId)
|
||||
.maybeSingle()
|
||||
@@ -131,7 +141,13 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
if (!original) {
|
||||
return v1ErrorResponseFromCode('JOURNAL_ENTRY_NOT_FOUND', ctx.log, { requestId: ctx.requestId })
|
||||
}
|
||||
const typed = original as { id: string; status: string; reversed_by_id: string | null }
|
||||
const typed = original as {
|
||||
id: string
|
||||
status: string
|
||||
reversed_by_id: string | null
|
||||
correction_of_id: string | null
|
||||
reverses_id: string | null
|
||||
}
|
||||
if (typed.status !== 'posted') {
|
||||
return v1ErrorResponseFromCode('CANNOT_REVERSE_NON_POSTED', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
@@ -145,6 +161,20 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
})
|
||||
}
|
||||
|
||||
// Chain-depth guard BEFORE the dry-run return: a dry run must give the
|
||||
// same verdict the real execution would. reverseEntry re-checks on the
|
||||
// real path.
|
||||
if (!bodyAllowDeepChain) {
|
||||
const chain = await correctionChainDepth(ctx.supabase, ctx.companyId!, typed)
|
||||
if (chain.depth >= CORRECTION_CHAIN_GUARD_DEPTH) {
|
||||
return v1ErrorResponse(
|
||||
new CorrectionChainTooDeepError(chain.depth, chain.rootVoucher),
|
||||
ctx.log,
|
||||
{ requestId: ctx.requestId },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (ctx.dryRun) {
|
||||
return dryRunPreview(
|
||||
{
|
||||
@@ -157,7 +187,9 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
}
|
||||
|
||||
try {
|
||||
const reversal = await reverseEntry(ctx.supabase, ctx.companyId!, ctx.userId, entryId, reversalDate)
|
||||
const reversal = await reverseEntry(ctx.supabase, ctx.companyId!, ctx.userId, entryId, reversalDate, {
|
||||
allowDeepChain: bodyAllowDeepChain,
|
||||
})
|
||||
return ok(
|
||||
{
|
||||
reversal_id: reversal.id,
|
||||
|
||||
Reference in New Issue
Block a user