@@ -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. */
205210export function resetTerminalPurchaseRejections ( ) : void {
206- terminallyRejectedPurchaseIds . clear ( ) ;
211+ terminallyRejectedPurchases . clear ( ) ;
207212}
208213
209214let 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 => ( {
0 commit comments