Skip to content

Commit 70819c1

Browse files
chitcommitclaude
andcommitted
fix(mercury): forward idempotencyKey as Idempotency-Key HTTP header (PR #108 review C1)
Mercury's ACH/wire/check transaction API dedupes creations by the `Idempotency-Key` HTTP header, not by a field in the JSON body. Prior to this fix, `createPayment` sent the key only in the body, so a transport-level retry (network blip, edge timeout, etc.) could create a duplicate ACH transfer — money out twice. Changes: - `mercuryClient.post` accepts an `opts.idempotencyKey` and sets the `Idempotency-Key` HTTP header on the outbound fetch when present. - `createPayment` strips `idempotencyKey` from the request body and forwards it via `opts`, so the header is set on every Mercury POST. - Regression test in `tests/meta/executors/mercury-payment-failures.spec.ts` captures the outbound `Request` via the existing injected-fetch pattern and asserts `headers.get('Idempotency-Key')` equals the executor's computed idempotency key. No mocks beyond the FetchImpl injection already used by the file. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 64ecd41 commit 70819c1

2 files changed

Lines changed: 61 additions & 3 deletions

File tree

src/lib/integrations.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -599,12 +599,25 @@ export function mercuryClient(token: string, fetchImpl: FetchImpl = fetch) {
599599
* audit row can show what Mercury returned without leaking secrets (body is
600600
* Mercury's payment object — no token in it).
601601
*/
602-
async function post<T>(path: string, body: unknown): Promise<MercuryPostResult<T>> {
602+
async function post<T>(
603+
path: string,
604+
body: unknown,
605+
opts?: { idempotencyKey?: string },
606+
): Promise<MercuryPostResult<T>> {
603607
let res: Response;
604608
try {
609+
const headers: Record<string, string> = {
610+
Authorization: `Bearer ${token}`,
611+
'Content-Type': 'application/json',
612+
Accept: 'application/json',
613+
};
614+
// Mercury dedupes ACH/wire/check creations by the `Idempotency-Key` HTTP
615+
// header. Putting the key only in the JSON body lets a transport retry
616+
// create duplicate transfers — money out twice. Forward as header.
617+
if (opts?.idempotencyKey) headers['Idempotency-Key'] = opts.idempotencyKey;
605618
res = await fetchImpl(`${baseUrl}${path}`, {
606619
method: 'POST',
607-
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', Accept: 'application/json' },
620+
headers,
608621
body: JSON.stringify(body),
609622
});
610623
} catch (err) {
@@ -655,7 +668,16 @@ export function mercuryClient(token: string, fetchImpl: FetchImpl = fetch) {
655668
paymentMethod: 'ach' | 'wire' | 'check';
656669
idempotencyKey: string;
657670
note?: string;
658-
}) => post<{ id: string; status: string; amount: number }>(`/account/${accountId}/transactions`, payment),
671+
}) => {
672+
// Mercury expects `Idempotency-Key` as an HTTP header, not in the body.
673+
// Strip from body and forward via opts so transport retries dedupe.
674+
const { idempotencyKey, ...bodyWithoutKey } = payment;
675+
return post<{ id: string; status: string; amount: number }>(
676+
`/account/${accountId}/transactions`,
677+
bodyWithoutKey,
678+
{ idempotencyKey },
679+
);
680+
},
659681
};
660682
}
661683

tests/meta/executors/mercury-payment-failures.spec.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,42 @@ describe('chat-surface tool — refuses Mercury payments unconditionally', () =>
242242
});
243243
});
244244

245+
describe('mercury-payment — idempotency forwarded as HTTP header (PR #108 review C1)', () => {
246+
it('createPayment sends Idempotency-Key as a request header, not just in body', async () => {
247+
const captured: { url: string; headers: Headers; bodyText: string }[] = [];
248+
const idemKey = 'i'.repeat(64);
249+
250+
const capturingFetch: typeof fetch = (async (
251+
input: RequestInfo | URL,
252+
init?: RequestInit,
253+
) => {
254+
const req = new Request(input as RequestInfo, init);
255+
const bodyText = await req.text();
256+
captured.push({ url: req.url, headers: req.headers, bodyText });
257+
return new Response(
258+
JSON.stringify({ id: 'tx_hdr_001', status: 'sent', amount: 1.0 }),
259+
{ status: 200, headers: { 'Content-Type': 'application/json' } },
260+
);
261+
}) as unknown as typeof fetch;
262+
263+
const run = await runMercuryPayment({
264+
env: envFor(KV_WITH_TOKEN),
265+
payload: VALID_PAYLOAD,
266+
sovereignty: FRESH_ASSESSMENT,
267+
idempotencyKey: idemKey,
268+
fetchImpl: capturingFetch,
269+
});
270+
271+
expect(run.ok).toBe(true);
272+
expect(captured.length).toBeGreaterThan(0);
273+
const post = captured.find((c) => c.url.includes('/transactions'));
274+
expect(post, 'expected a POST to /transactions to be captured').toBeDefined();
275+
// The Idempotency-Key HTTP header is what Mercury uses to dedupe transfers
276+
// on transport retry. Without it, a retried POST creates a duplicate ACH.
277+
expect(post!.headers.get('Idempotency-Key')).toBe(idemKey);
278+
});
279+
});
280+
245281
// MERCURY_SOVEREIGNTY_FRESHNESS_MS import kept to ensure the constant remains
246282
// public (other tests / runbook docs reference it).
247283
void MERCURY_SOVEREIGNTY_FRESHNESS_MS;

0 commit comments

Comments
 (0)