Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/spt-retrieval-backoff.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@stripe/link-cli': patch
---

Retry delayed shared payment token retrieval in both interactive and continued MPP payment flows.
42 changes: 42 additions & 0 deletions packages/cli/src/commands/mpp/pay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ beforeEach(() => {
});

afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});

Expand Down Expand Up @@ -395,6 +396,47 @@ describe('payWithSpt', () => {
expect(refreshedResponse.bodyUsed).toBe(true);
});

it('waits for SPT propagation when continuing an approved spend request', async () => {
vi.useFakeTimers();
vi.setSystemTime(0);
const retrievedAt: number[] = [];
const repository = {
retrieve: vi.fn().mockImplementation(async () => {
retrievedAt.push(Date.now());
return {
id: 'lsrq_123',
status: 'approved',
credential_type: 'shared_payment_token',
...(retrievedAt.length === 8
? { shared_payment_token: { id: 'spt_test_123' } }
: {}),
};
}),
} as unknown as ISpendRequestResource;
const fetcher = vi
.fn()
.mockResolvedValueOnce(challengeResponse())
.mockResolvedValueOnce(new Response('paid'));
vi.stubGlobal('fetch', fetcher);

const resultPromise = runMppPayWithSpendRequest(
'https://merchant.example/challenge',
'lsrq_123',
'GET',
undefined,
undefined,
repository,
);
await vi.runAllTimersAsync();

await expect(resultPromise).resolves.toMatchObject({
status: 200,
body: 'paid',
});
expect(retrievedAt).toEqual([0, 1000, 2000, 3000, 5000, 7000, 9000, 11000]);
expect(fetcher).toHaveBeenCalledTimes(2);
});

it('rejects an explicit amount that conflicts with the challenge', async () => {
const repository = approvedRepository();
const fetcher = vi.fn().mockResolvedValueOnce(challengeResponse());
Expand Down
30 changes: 23 additions & 7 deletions packages/cli/src/commands/mpp/pay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,10 @@ export interface MppProbe extends MppRequest {
response: Response;
}

const SPT_RETRIEVAL_DELAYS_MS = [
0, 1000, 1000, 1000, 2000, 2000, 2000, 2000,
] as const;

export async function probeMppRequest(
initial: MppRequest,
fetcher: typeof fetch = fetch,
Expand Down Expand Up @@ -164,7 +168,7 @@ export async function runMppPayWithSpendRequest(
repository: ISpendRequestResource,
approvedChallengeHeader?: string,
): Promise<PayResult> {
const spendRequest = await repository.retrieve(spendRequestId, {
let spendRequest = await repository.retrieve(spendRequestId, {
include: ['shared_payment_token'],
});

Expand All @@ -182,8 +186,19 @@ export async function runMppPayWithSpendRequest(
`Spend request must be approved (current status: ${spendRequest.status})`,
);
}
for (const delayMs of SPT_RETRIEVAL_DELAYS_MS.slice(1)) {
if (spendRequest.shared_payment_token) break;
await new Promise((resolve) => setTimeout(resolve, delayMs));
const retrieved = await repository.retrieve(spendRequestId, {
include: ['shared_payment_token'],
});
if (!retrieved) {
throw new Error(`Spend request ${spendRequestId} not found`);
}
spendRequest = retrieved;
}
if (!spendRequest.shared_payment_token) {
throw new Error('Spend request does not have a shared payment token');
throw new Error('Failed to retrieve shared payment token');
}

return payWithSpt(
Expand Down Expand Up @@ -402,14 +417,15 @@ export async function runMppPayFullFlow(

// 6. Retrieve with SPT (retry briefly in case of propagation delay)
onStep?.('signing');
let withSpt = await repository.retrieve(spendRequest.id, {
include: ['shared_payment_token'],
});
for (let i = 0; i < 3 && withSpt && !withSpt.shared_payment_token; i++) {
await new Promise((r) => setTimeout(r, 1000));
let withSpt = null;
for (const delayMs of SPT_RETRIEVAL_DELAYS_MS) {
if (delayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
withSpt = await repository.retrieve(spendRequest.id, {
include: ['shared_payment_token'],
});
if (withSpt?.shared_payment_token) break;
}
if (!withSpt?.shared_payment_token) {
throw new Error('Failed to retrieve shared payment token');
Expand Down
Loading