@@ -3045,6 +3045,232 @@ export async function googlePlayAcknowledgePurchase(env, req) {
30453045 }
30463046}
30473047
3048+ /**
3049+ * Consumes a Google Play one-time purchase if all conditions are met.
3050+ * Consumption can only be triggered within 30 days before expiry, or at any
3051+ * time after expiry provided the purchase has not already been fully consumed.
3052+ *
3053+ * @param {any } env - Worker environment.
3054+ * @param {Request } req - HTTP request containing purchase token and product ID for a one-time purchase to be consumed.
3055+ * @returns {Promise<Response> } - HTTP response indicating success or failure of the consumption.
3056+ */
3057+ export async function googlePlayConsumePurchase ( env , req ) {
3058+ let test = false ;
3059+ let purchasetoken = "" ;
3060+ let obstoken = "" ;
3061+ let cid = "" ;
3062+ let sku = "" ;
3063+
3064+ try {
3065+ if ( req . method !== "POST" ) {
3066+ return r400j ( { error : "method not allowed" } ) ;
3067+ }
3068+
3069+ const url = new URL ( req . url ) ;
3070+ purchasetoken =
3071+ url . searchParams . get ( "purchaseToken" ) ||
3072+ url . searchParams . get ( "purchasetoken" ) ;
3073+ cid = url . searchParams . get ( "cid" ) ;
3074+ sku =
3075+ url . searchParams . get ( "sku" ) ||
3076+ url . searchParams . get ( "productId" ) ||
3077+ url . searchParams . get ( "productid" ) ||
3078+ stdProductId ;
3079+ test = url . searchParams . has ( "test" ) ;
3080+
3081+ if ( emptyString ( purchasetoken ) ) {
3082+ return r400j ( { error : "missing purchase token" } ) ;
3083+ }
3084+ if ( emptyString ( sku ) ) {
3085+ return r400j ( { error : "missing product id" } ) ;
3086+ }
3087+ if ( ! cid || cid . length < mincidlength || ! / ^ [ a - f A - F 0 - 9 ] + $ / . test ( cid ) ) {
3088+ return r400j ( { error : "missing/invalid client id" } ) ;
3089+ }
3090+
3091+ // consume only applies to onetime purchases
3092+ if ( ! knownOnetimeProductsAndPlans . has ( sku ) ) {
3093+ return r400j ( {
3094+ error : "consume not applicable" ,
3095+ cid : cid ,
3096+ sku : sku ,
3097+ test : test ,
3098+ } ) ;
3099+ }
3100+
3101+ obstoken = await obfuscate ( purchasetoken ) ;
3102+
3103+ return await als . run ( new ExecCtx ( env , test , obstoken ) , async ( ) => {
3104+ const dbres = await dbx . playSub ( dbx . db ( env ) , purchasetoken ) ;
3105+ if ( dbres == null || dbres . results == null || dbres . results . length <= 0 ) {
3106+ return r400j ( {
3107+ error : "purchase not found" ,
3108+ cid : cid ,
3109+ sku : sku ,
3110+ purchaseId : test ? purchasetoken : obstoken ,
3111+ test : test ,
3112+ } ) ;
3113+ }
3114+
3115+ const entry = dbres . results [ 0 ] ;
3116+ const storedcid = entry . cid ;
3117+ // identifiers must be immutable for onetime purchases
3118+ if ( accountIdentifiersImmutable ( ) && storedcid !== cid ) {
3119+ return r400j ( {
3120+ error : "cid mismatch" ,
3121+ cid : cid ,
3122+ storedCid : test ? storedcid : undefined ,
3123+ purchaseId : test ? purchasetoken : obstoken ,
3124+ sku : sku ,
3125+ test : test ,
3126+ } ) ;
3127+ }
3128+
3129+ const purchase2 = await getOnetimeProductV2 ( env , purchasetoken ) ;
3130+ const testPurchase = isOnetimeTest2 ( purchase2 ) ;
3131+ const consumed = isOnetimeAllConsumed2 ( purchase2 ) ;
3132+ const productIds = allProducts2 ( purchase2 ) ;
3133+ const unconsumedProductIds = unconsumedProducts2 ( purchase2 ) ;
3134+ const paid = isOnetimePaid2 ( purchase2 ) ;
3135+ const pending = isOnetimeUnpaid2 ( purchase2 ) ;
3136+ const onetimeState = onetimePurchaseStateStr2 ( purchase2 ) ;
3137+
3138+ logi (
3139+ `onetime: ack/con ${ onetimeState } for ${ cid } / tok: ${ obstoken } sku=${ sku } ${ productIds } / consumed? ${ consumed } test? ${ test } ` ,
3140+ ) ;
3141+
3142+ // already fully consumed — after expiry this is fine; report success
3143+ if ( consumed ) {
3144+ logi (
3145+ `onetime: already consumed for ${ cid } / tok: ${ obstoken } ; test? ${ test } ` ,
3146+ ) ;
3147+ return r200j ( {
3148+ success : true ,
3149+ message : "onetime purchase already consumed" ,
3150+ cid : cid ,
3151+ state : onetimeState ,
3152+ allProducts : productIds ,
3153+ unconsumedProducts : unconsumedProductIds ,
3154+ purchaseId : test ? purchasetoken : obstoken ,
3155+ expiry : gent . expiryDate ,
3156+ sku : sku ,
3157+ expired : expired ,
3158+ test : test ,
3159+ } ) ;
3160+ }
3161+
3162+ if ( testPurchase !== test ) {
3163+ return r400j ( {
3164+ error : "test domain mismatch" ,
3165+ purchaseId : testPurchase ? purchasetoken : obstoken ,
3166+ state : onetimeState ,
3167+ sku : sku ,
3168+ allProducts : productIds ,
3169+ unconsumedProducts : unconsumedProductIds ,
3170+ test : test ,
3171+ } ) ;
3172+ } // else: test === testPurchase
3173+
3174+ if ( ! paid || pending ) {
3175+ return r400j ( {
3176+ error : "purchase not completed" ,
3177+ purchaseId : test ? purchasetoken : obstoken ,
3178+ state : onetimeState ,
3179+ sku : sku ,
3180+ allProducts : productIds ,
3181+ unconsumedProducts : unconsumedProductIds ,
3182+ test : test ,
3183+ } ) ;
3184+ }
3185+
3186+ const gent = onetimePlan ( purchase2 ) ;
3187+ if ( gent == null ) {
3188+ // such purchases can only be cancelled/refunded
3189+ return r400j ( {
3190+ error : "not a valid product; will be auto refunded" ,
3191+ purchaseId : test ? purchasetoken : obstoken ,
3192+ cid : cid ,
3193+ state : onetimeState ,
3194+ sku : sku ,
3195+ allProducts : productIds ,
3196+ unconsumedProducts : unconsumedProductIds ,
3197+ test : test ,
3198+ } ) ;
3199+ }
3200+
3201+ const now = Date . now ( ) ;
3202+ const expiryMs = gent . expiry . getTime ( ) ;
3203+ const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000 ;
3204+ const expired = now >= expiryMs ;
3205+ const withinConsumeWindow = now >= expiryMs - thirtyDaysMs ; // within 30d before or after expiry
3206+
3207+ // consume is only allowed within 30d of expiry or anytime after expiry
3208+ if ( ! withinConsumeWindow ) {
3209+ return r400j ( {
3210+ error : "too early to consume" ,
3211+ purchaseId : test ? purchasetoken : obstoken ,
3212+ cid : cid ,
3213+ state : onetimeState ,
3214+ sku : sku ,
3215+ allProducts : productIds ,
3216+ unconsumedProducts : unconsumedProductIds ,
3217+ expiry : gent . expiryDate ,
3218+ test : test ,
3219+ } ) ;
3220+ }
3221+
3222+ try {
3223+ await consumeOnetimePurchases (
3224+ env ,
3225+ cid ,
3226+ unconsumedProductIds ,
3227+ purchasetoken ,
3228+ ) ;
3229+ } catch ( e ) {
3230+ loge ( `onetime: err consume: ${ cid } / tok: ${ obstoken } : ${ e . message } ` ) ;
3231+ return r500j ( {
3232+ error : "failed to consume" ,
3233+ details : e . message ,
3234+ purchaseId : test ? purchasetoken : obstoken ,
3235+ cid : cid ,
3236+ sku : sku ,
3237+ allProducts : productIds ,
3238+ unconsumedProducts : unconsumedProductIds ,
3239+ expiry : gent . expiryDate ,
3240+ test : test ,
3241+ } ) ;
3242+ }
3243+
3244+ logi (
3245+ `onetime: ack/con done for ${ cid } / tok: ${ obstoken } ; expired? ${ expired } test? ${ test } ` ,
3246+ ) ;
3247+
3248+ return r200j ( {
3249+ success : true ,
3250+ message : "onetime purchase consumed" ,
3251+ cid : cid ,
3252+ state : onetimeState ,
3253+ allProducts : productIds ,
3254+ unconsumedProducts : unconsumedProductIds ,
3255+ purchaseId : test ? purchasetoken : obstoken ,
3256+ expiry : gent . expiryDate ,
3257+ sku : sku ,
3258+ test : test ,
3259+ } ) ;
3260+ } ) ;
3261+ } catch ( err ) {
3262+ return r500j ( {
3263+ error : "consume failed" ,
3264+ details : err . message ,
3265+ purchaseId : test ? purchasetoken : obstoken ,
3266+ cid : cid ,
3267+ sku : sku ,
3268+ allProducts : [ ] ,
3269+ test : test ,
3270+ } ) ;
3271+ }
3272+ }
3273+
30483274/**
30493275 * Retrieves stored WSEntitlement for the requesting client.
30503276 *
0 commit comments