Skip to content

Commit 18a2516

Browse files
committed
fix(credits): bound recovery and surface stored refusal
1 parent 7dd7c18 commit 18a2516

6 files changed

Lines changed: 181 additions & 57 deletions

File tree

‎apps/mobile/src/components/credits/credit-native-iap-owner.mounted.test.tsx‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,60 @@ describe('CreditNativeIapOwner', () => {
544544
expect(handle.value?.errorMessageKey).toBeNull();
545545
});
546546

547+
it('gives up on a lookup that never answers and retries the next already-owned attempt', async () => {
548+
// The store accepts the first `AlreadyOwned` lookup and never answers it.
549+
// The pass must time out, release its single-flight slot, and let the next
550+
// attempt run a fresh lookup instead of pinning recovery for the life of
551+
// the mount.
552+
const hungLookup = Promise.withResolvers<Purchase[]>();
553+
mockedIap.getAvailablePurchases.mockReturnValueOnce(hungLookup.promise);
554+
mockedQuery.serverProductsData = {
555+
appAccountToken: APP_ACCOUNT_TOKEN,
556+
products: [{ appleProductId: APPLE_PRODUCT_ID, googleProductId: 'credits_usd10' }],
557+
};
558+
const alreadyOwned = { code: 'already-owned', message: 'Item already owned' };
559+
mockedIap.requestPurchase.mockRejectedValue(alreadyOwned);
560+
561+
// Only the deadline is faked: the mount helpers await real microtasks.
562+
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
563+
try {
564+
const { handle } = await mountOwner();
565+
566+
const firstAttempt = handle.value?.purchase(creditPack);
567+
await flushPromises();
568+
expect(mockedIap.getAvailablePurchases).toHaveBeenCalledTimes(1);
569+
570+
await vi.advanceTimersByTimeAsync(16_000);
571+
await flushPromises();
572+
573+
// The deadline gives up on the hung lookup: the attempt falls back to the
574+
// existing purchase-failure copy and completes nothing.
575+
expect(await firstAttempt).toBe(false);
576+
expect(mockedQuery.completePurchase).not.toHaveBeenCalled();
577+
expect(handle.value?.errorMessageKey).toBe('kiloPass.purchaseFailed');
578+
579+
// The slot is free, so the next attempt runs a fresh lookup, which answers.
580+
mockedIap.getAvailablePurchases.mockResolvedValue([createPurchase()]);
581+
const secondAttempt = handle.value?.purchase(creditPack);
582+
await flushPromises();
583+
584+
expect(await secondAttempt).toBe(false);
585+
expect(mockedIap.getAvailablePurchases).toHaveBeenCalledTimes(2);
586+
expect(mockedQuery.completePurchase).toHaveBeenCalledTimes(1);
587+
expect(handle.value?.completedPurchaseCount).toBe(1);
588+
expect(handle.value?.errorMessageKey).toBeNull();
589+
590+
// The timed-out lookup answers late: a settled pass must ignore it, so no
591+
// completion lands for the transaction it carried.
592+
hungLookup.resolve([createPurchase({ transactionId: 'tx-late' })]);
593+
await flushPromises();
594+
expect(mockedQuery.completePurchase).toHaveBeenCalledTimes(1);
595+
expect(handle.value?.completedPurchaseCount).toBe(1);
596+
} finally {
597+
vi.useRealTimers();
598+
}
599+
});
600+
547601
it('keeps the backend ownership refusal when both triggers recover one already-owned failure', async () => {
548602
mockedIap.getAvailablePurchases.mockResolvedValue([createPurchase()]);
549603
mockedQuery.serverProductsData = {

‎apps/mobile/src/components/credits/credit-native-iap-owner.tsx‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
} from '@/lib/credits/store-products';
2828
import { getCreditStorefront } from '@/lib/credits/storefront';
2929
import { fetchPendingStorePurchases } from '@/lib/iap/pending-store-purchases';
30+
import { withStoreDeadline } from '@/lib/iap/store-call-deadline';
3031
import {
3132
createStoreCreditPurchaseActions,
3233
getPurchaseCompletionId,
@@ -366,7 +367,13 @@ export function CreditNativeIapOwner({ children }: { children: ReactNode }) {
366367
// changes while the store answers, the new account's own pass recovers.
367368
let pendingPurchases: Purchase[] = [];
368369
try {
369-
pendingPurchases = await fetchPendingStorePurchases(storefront);
370+
// Bounded: a store that never answers must not hold this epoch's
371+
// single-flight slot open. The pass gives up, releases the slot, and
372+
// the next `AlreadyOwned` attempt retries the lookup.
373+
pendingPurchases = await withStoreDeadline(
374+
fetchPendingStorePurchases(storefront),
375+
'the pending purchase lookup'
376+
);
370377
} catch {
371378
// The store cannot answer; the retry re-runs this.
372379
return false;

‎apps/mobile/src/lib/credits/use-store-credit-purchase.test.tsx‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -714,6 +714,38 @@ describe('createStoreCreditPurchaseActions.recoverPurchases', () => {
714714
}
715715
);
716716

717+
it('reports a remembered terminal refusal on explicit recovery without reposting it', async () => {
718+
const purchase = createPurchase();
719+
const completeAppStorePurchase = vi.fn().mockRejectedValue({
720+
data: { code: 'BAD_REQUEST', message: STORE_PURCHASE_VERIFICATION_FAILED_MESSAGE },
721+
});
722+
const showError = vi.fn();
723+
const actions = createActions({
724+
completeAppStorePurchase,
725+
showError: message => {
726+
showError(message);
727+
},
728+
});
729+
730+
// The first automatic pass posts the receipt once and learns it is terminal.
731+
expect(await actions.recoverPurchases([purchase])).toEqual([]);
732+
expect(completeAppStorePurchase).toHaveBeenCalledTimes(1);
733+
// Background recovery stays silent.
734+
expect(showError).not.toHaveBeenCalled();
735+
736+
// The next automatic pass skips the remembered refusal silently.
737+
expect(await actions.recoverPurchases([purchase])).toEqual([]);
738+
expect(completeAppStorePurchase).toHaveBeenCalledTimes(1);
739+
expect(showError).not.toHaveBeenCalled();
740+
741+
// An explicit recovery surfaces the remembered refusal — never a silent
742+
// no-op — and still does not post the refused receipt again.
743+
expect(await actions.recoverPurchases([purchase], { notifyErrors: true })).toEqual([]);
744+
expect(completeAppStorePurchase).toHaveBeenCalledTimes(1);
745+
expect(showError).toHaveBeenCalledTimes(1);
746+
expect(showError).toHaveBeenCalledWith(CREDIT_PURCHASE_FAILED_KEY);
747+
});
748+
717749
it('retries after a session error, which is not about the receipt', async () => {
718750
const purchase = createPurchase();
719751
const completeAppStorePurchase = vi

‎apps/mobile/src/lib/credits/use-store-credit-purchase.ts‎

Lines changed: 56 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -193,17 +193,22 @@ const TERMINAL_PURCHASE_MESSAGES = {
193193
};
194194

195195
/**
196-
* Purchases the backend terminally refused in this process. The store keeps an
197-
* unfinished transaction until the app finishes it, so without this memory every
198-
* later recovery pass posts a payload the backend already rejected. Deliberately
199-
* in-memory: the next app launch retries once, so a server-side change is never
200-
* ignored forever.
196+
* Purchases the backend terminally refused in this process, with the catalog key
197+
* their refusal maps to. The store keeps an unfinished transaction until the app
198+
* finishes it, so without this memory every later recovery pass posts a payload
199+
* the backend already rejected. Deliberately in-memory: the next app launch
200+
* retries once, so a server-side change is never ignored forever.
201+
*
202+
* The key is kept, not just the id, because the silent background pass must skip
203+
* a remembered refusal without a word, while an explicit recovery (the store
204+
* reported the pack as already owned) must still surface why it can never
205+
* succeed — without posting the refused receipt again.
201206
*/
202-
const terminallyRejectedPurchaseIds = new Set<string>();
207+
const terminallyRejectedPurchases = new Map<string, string>();
203208

204209
/** Test seam: forget the recorded rejections, so a test starts the process over. */
205210
export function resetTerminalPurchaseRejections(): void {
206-
terminallyRejectedPurchaseIds.clear();
211+
terminallyRejectedPurchases.clear();
207212
}
208213

209214
let lastPurchaseErrorToast: { message: string; shownAt: number } | null = null;
@@ -353,13 +358,13 @@ export function createStoreCreditPurchaseActions(deps: StoreCreditPurchaseAction
353358
// remembering: an account or session refusal is payable after the user acts,
354359
// so it stays in the store queue and is posted again.
355360
const refusalMessage = readTrpcErrorField(error, 'message') ?? '';
356-
if (Object.hasOwn(TERMINAL_PURCHASE_MESSAGES, refusalMessage)) {
357-
terminallyRejectedPurchaseIds.add(getPurchaseCompletionId(purchase));
361+
const errorMessageKey = getStoreCreditPurchaseErrorMessageKey(error, deps.storefront);
362+
if (Object.hasOwn(TERMINAL_PURCHASE_MESSAGES, refusalMessage) && errorMessageKey) {
363+
// Remember the key the refusal maps to, so a later explicit recovery can
364+
// show it without posting this receipt again.
365+
terminallyRejectedPurchases.set(getPurchaseCompletionId(purchase), errorMessageKey);
358366
}
359-
return {
360-
completed: false,
361-
errorMessageKey: getStoreCreditPurchaseErrorMessageKey(error, deps.storefront),
362-
};
367+
return { completed: false, errorMessageKey };
363368
}
364369

365370
// The account can also change while the backend answers. The grant is the
@@ -489,28 +494,47 @@ export function createStoreCreditPurchaseActions(deps: StoreCreditPurchaseAction
489494
options.creditPackGoogleProductIds ?? deps.creditPackGoogleProductIds;
490495
// One completion per store transaction: a store that lists the same
491496
// transaction twice must not be completed twice. A payload the backend named
492-
// as defective is skipped too: posting it again can never succeed, and the
497+
// as defective is never posted again: posting it can never succeed, and the
493498
// store keeps re-delivering it. The next app launch retries it once.
494499
const seenCompletionIds = new Set<string>();
495-
const eligiblePurchases = purchases
496-
.filter(purchase => !terminallyRejectedPurchaseIds.has(getPurchaseCompletionId(purchase)))
497-
.filter(purchase => {
498-
if (
499-
!isRecoverableCreditPurchase(
500-
purchase,
501-
creditPackAppleProductIds,
502-
creditPackGoogleProductIds
503-
)
504-
) {
505-
return false;
506-
}
507-
const id = getPurchaseCompletionId(purchase);
508-
if (seenCompletionIds.has(id)) {
509-
return false;
500+
const rememberedRejections: string[] = [];
501+
const eligiblePurchases = purchases.filter(purchase => {
502+
const id = getPurchaseCompletionId(purchase);
503+
const rememberedErrorMessageKey = terminallyRejectedPurchases.get(id);
504+
if (rememberedErrorMessageKey !== undefined) {
505+
// The silent background pass skips a remembered refusal without a word.
506+
// An explicit recovery must still say why this purchase can never
507+
// complete, once per pass, without posting the refused receipt again.
508+
if ((options.notifyErrors ?? false) && !seenCompletionIds.has(id)) {
509+
seenCompletionIds.add(id);
510+
rememberedRejections.push(rememberedErrorMessageKey);
510511
}
511-
seenCompletionIds.add(id);
512-
return true;
513-
});
512+
return false;
513+
}
514+
if (
515+
!isRecoverableCreditPurchase(
516+
purchase,
517+
creditPackAppleProductIds,
518+
creditPackGoogleProductIds
519+
)
520+
) {
521+
return false;
522+
}
523+
if (seenCompletionIds.has(id)) {
524+
return false;
525+
}
526+
seenCompletionIds.add(id);
527+
return true;
528+
});
529+
530+
// A remembered refusal belongs to the account that asked for it: a session
531+
// change while this pass ran must not report the old account's refusal onto
532+
// the new one.
533+
if (deps.isAccountCurrent()) {
534+
for (const errorMessageKey of rememberedRejections) {
535+
deps.showError(errorMessageKey);
536+
}
537+
}
514538

515539
const recoveryResults = await Promise.all(
516540
eligiblePurchases.map(async purchase => ({
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/**
2+
* A store call must answer inside this window or the pass gives up.
3+
*
4+
* StoreKit and Play Billing both answer slowly on a cold connection, and a call
5+
* that never answers is worse than one that fails: the pass holds its in-flight
6+
* guard, so recovery is dead for the rest of the process and silent about it.
7+
* Measured on a simulator on 2026-09-29: the pass entered with 14 known product
8+
* ids, then `initConnection` never settled — no result, no failure, no further
9+
* recovery for the life of that app process.
10+
*/
11+
const STORE_CALL_DEADLINE_MS = 15_000;
12+
13+
/**
14+
* Bounds a store call so a hung SDK never holds a recovery pass open.
15+
*
16+
* The underlying call keeps running — the store SDK has no cancellation — but
17+
* the race settles on the deadline and a late answer is ignored, so the caller
18+
* can release its in-flight guard and the next attempt may retry.
19+
*/
20+
export async function withStoreDeadline<T>(work: Promise<T>, label: string): Promise<T> {
21+
const { promise: deadline, reject: failDeadline } = Promise.withResolvers<never>();
22+
const timer = setTimeout(() => {
23+
failDeadline(new Error(`${label} did not answer within ${STORE_CALL_DEADLINE_MS} ms`));
24+
}, STORE_CALL_DEADLINE_MS);
25+
try {
26+
return await Promise.race([work, deadline]);
27+
} finally {
28+
clearTimeout(timer);
29+
}
30+
}

‎apps/mobile/src/lib/iap/store-purchase-recovery-mount.tsx‎

Lines changed: 1 addition & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { useAppLifecycle } from '@/lib/hooks/use-app-lifecycle';
1313
import { getCreditStorefront } from '@/lib/credits/storefront';
1414
import { createStoreCreditPurchaseActions } from '@/lib/credits/use-store-credit-purchase';
1515
import { fetchPendingStorePurchases } from '@/lib/iap/pending-store-purchases';
16+
import { withStoreDeadline } from '@/lib/iap/store-call-deadline';
1617
import { createAppStoreKiloPassPurchaseActions } from '@/lib/kilo-pass/use-store-kilo-pass-purchase';
1718
import { useTRPC } from '@/lib/trpc';
1819

@@ -28,30 +29,6 @@ function logRecoveryError(message: string | null): void {
2829
}
2930
}
3031

31-
/**
32-
* A store call must answer inside this window or the pass gives up.
33-
*
34-
* StoreKit and Play Billing both answer slowly on a cold connection, and a call
35-
* that never answers is worse than one that fails: the pass holds its in-flight
36-
* guard, so recovery is dead for the rest of the process and silent about it.
37-
* Measured on a simulator on 2026-09-29: the pass entered with 14 known product
38-
* ids, then `initConnection` never settled — no result, no failure, no further
39-
* recovery for the life of that app process.
40-
*/
41-
const STORE_CALL_DEADLINE_MS = 15_000;
42-
43-
async function withStoreDeadline<T>(work: Promise<T>, label: string): Promise<T> {
44-
const { promise: deadline, reject: failDeadline } = Promise.withResolvers<never>();
45-
const timer = setTimeout(() => {
46-
failDeadline(new Error(`${label} did not answer within ${STORE_CALL_DEADLINE_MS} ms`));
47-
}, STORE_CALL_DEADLINE_MS);
48-
try {
49-
return await Promise.race([work, deadline]);
50-
} finally {
51-
clearTimeout(timer);
52-
}
53-
}
54-
5532
/** Purchase screens close the global connection on unmount; each pass reconnects. */
5633
async function connectStore(): Promise<void> {
5734
await withStoreDeadline(initConnection(), 'the store connection');

0 commit comments

Comments
 (0)