From b297cd1ecc854e8f51a062e6fad4f81ab534e8c0 Mon Sep 17 00:00:00 2001 From: Ramiz Kichibekov Date: Sat, 30 Aug 2025 19:45:36 +0400 Subject: [PATCH] add subscription helpers, group lookups, and DocC; refresh entitlement handling - Add StoreProduct.subscriptionGroupID - Add PurchasesProtocol helpers: - hasEntitlement(for:) - entitlementProductIDs() - activeSubscriptions() - activeSubscription(inGroup:) - Implement helpers in PurchasesManager (Swift Concurrency, actor-safe) - Refactor entitlement refresh: - new refreshEntitlements() used after fetch/restore - requestProducts(includingCache: true) now refreshes entitlements in background when returning cache - DocC: comments + updated Getting Started, RKPurchaseKit index, new sections for subscriptions - No breaking changes; purely additive API --- .../UserInterfaceState.xcuserstate | Bin 21200 -> 47667 bytes .../Manager/RKPurchasesManager.swift | 182 ++++++++++++++---- .../PurchaseKit/Models/RKStoreProduct.swift | 24 ++- .../Protocols/RKPurchasesProtocol.swift | 38 ++++ .../RKPurchaseKit.docc/GettingStarted.md | 61 +++++- .../RKPurchaseKit.docc/RKPurchaseKit.md | 22 +++ .../SymbolReferences/PurchasesManager.md | 40 +++- .../SymbolReferences/PurchasesProtocol.md | 18 +- .../SymbolReferences/StoreProduct.md | 20 +- 9 files changed, 346 insertions(+), 59 deletions(-) diff --git a/.swiftpm/xcode/package.xcworkspace/xcuserdata/ramizkichibekov.xcuserdatad/UserInterfaceState.xcuserstate b/.swiftpm/xcode/package.xcworkspace/xcuserdata/ramizkichibekov.xcuserdatad/UserInterfaceState.xcuserstate index 3ae32fa9aee4ab764455edcb40e66adb7a052fb5..be7d372d33ba2463c4dbcb587a6c9f171489ada6 100644 GIT binary patch literal 47667 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%0AS&1y;trT5D`=NlhN)Y_eea2j;+XFt``eIRz|N zpr4roE6_E-6MVEqfGGM&G)xn$U3&{-rcFKgEs*w{wxk}jnA4uu>gkNIZMw0tuEf$S z7UQ(JNx+`(3avC0{JK?OG@HPjHT^-PsQ@fug%%)TSzD?ttH&y{TU!njDz8CSZDz4+ z`_uL@D{!O9HaNnXyft=W+BASar44q}8nhMK$_DI=+hP}Om9|EEf!5QnMd#G~p>9P$ zQPB2H1_>P?O=Io0V2|qY=@lmW{f<=&gL{jtO%>%vSWO5SOI`7YF&LBB3wwjNH)Qna zit^I&bw*R6!SH^;^`5IIWX3&n;xTT**L^GjwbW_Z{i(g(Cm zKlizQn`iDVnE#6GwKBu+xr^Fr@8t4j4E@hLIn4Z={a)Vni|x2#TC+U*VouEz;f+t8J(7WZ0EEKUM9 zSPD)>3*fH|^o({!+oSE(HUep#(RTlVv~V`gX(p|m+NQfm3l{>vx?AFcCW_7iOj4W^B96Zo$LvNurG_@)I;Lfy4m74S0 zdS8>>%3yzq=Ww-|=iayv&*3^V&+W4C+_VSDbGI3svH;+)EcB00+-$U)?rN-N{=*Z^ zT=!X9KOH882B4`iOrn5U^BFv}k?y2gr?$#@n}8P+htQ}9&qz&?Vf z<45rfJX71Jy`}Bf4rp&{?`ZF8?`iLA2OIHh3zdPJ_Hl4}_~ZFNWgj$A*%9pv=)1+* zseh@=ys8*t0(O2-REC!WmEn5r!zL=jD}l=JD!iJ0v_slq^C!_@Yw)u6?aO!r)r0Hs zdhH|aXgz)fZ`3~4KBFA*AL;5(>CRff7QCI3(d&3C-llz`9n(H-z&r38Kt{*46aPFJ z?E^A-t0kFyJiO;;2iB#HnYnD~>CNq8!+?zN+aUT*OVPz^zn>VeF!#n(qkQ!G-k-N> ztF4dzKK_ul?LnaN&nb-`0vbPg4;r_{`@MI5Z(%5F+GF^Hg`bWCKb@xhbO=(OY+(Wb zjoeE`x1^CXKqF`IS3o0Y@OktML3Y3+KWS&R4fM~ee%5hh+oc5QDBs@3Y9v zBH#Qui?F*fsun6m9W|1E(EHe3j%_4tjsUxVFK-hL>0{OAn@Q+u@ z;ss^B*bYDqtT*e!`m%m(d)A)~U<278Hdy;vyQW>&e$jr_ZfL)0ziT(OTa9dpg&Nq7 z7HVLl86Sce3pWtNS;&DP!@unC;Msvq2XEI^Q>g$C@KInC-VYo2wm z^%hCz|3Db4DPa)g@_-m)6J-p7+TD*bUZ<2nkf((+rv9%vTh*-XO@k*zo_0 znl+Kf9`+z5kGBr0=;g+93Ba&5B zUtxcw`uzv?DnSthMb@)Fu|E?OMNsDlHrHTpu(v25{Ko#y-XtiRpcsNWHE;}vC?CWU z6!(ww0Vgo6II*RYAGUQ|#G)U%41VKyME}SmORfSRa0-a3Ek%25+^uac$o_W8bMDNQ z%GKMN`G7NUu+h36%bcCIj3BxXgL9yK(9&~g%RSxHGhAEF%}gSkDIrGdJ$rzA_Hl0k_w3=`rrh&9jW&Ii_%5wVhEZ~&;o*^U2ii7=z38OSOOfN%cmT$_#Pb4;_!)i z1BGr7Y^u|N6|hW8Ijk#%>X+%tVFkTxH0%Jcs%x?2P0(_J_TN*7>xStn{-DF5QuX)I z;ks&F&21h2B0&vz>2Tc`(BZnVx^cSkx(T|81g#)w6+vqVdV!$EW*x43*rLN%{<#j< zO||Io)elC8>tLyrGRNA#qQfmVv~H1Z1!zy*V%@X4=X6VS&+C@zmg$!3>U9kSy-d(L zg4PoRboUBD8wuJ((5nP(Cg`>viww-X&-UK|87Dd?5RdbRX)DQ0&XP!_)Lamfxd33K<}k%zX8#2 zTZ-Oa*Iv?Hv1rThskVIUPg=~pDM)uscinQ7{4OmBzKOCC^}1i^t{{5OyyZ>s-&=PE z7hCRHd*CBSAZ~!K^iW5B z$>9HD00M|4!Tf-KUTPL0sBAl}IX zc9}D3K8C^j@Bwh5jQ8dJ`1ZU%L7xx=*7>Id9bdr*@@EiE}WKZt$8FLON)!*qxl%V6G1TOKPTuUL0_x@ zm3kHqKKXza?FL+H-l^%*3r^+GBYMzI#<6rCx2b!{SW8d<0PWp(n~n_^l~vck#ygL@ zZkm%<*Vd&QVS8jn4NRwC<71nR5rt|p_{D9BkGx;n&o6q5M`7V4OL8l2i zL(rE5oh9fig3b~2H9_asSSSHbgPQlD!tQp<-cuU>!152qRS<0)>&d@z*!5?st~Ap2 z$*U->r6+@`=)+o%^Uzob_mu8oJuY##eBk}$yXK_#fO~gk9(+IHd}Cb+Od(6|7N_R| z?p+sXvAad@TkWFJrKa(4G-~AC@}c+38g@I-1LAa3`rRq+TX*ZJv^&ME-8q;q1)%dK z1bw@lFC*x?KLh$u3iM%o1wj`H`u;Z1EBTsxA#W4_-9*qO3iQjGS2ISulgX*OkUNfl z=w9h3LHfxAU7_iJ(7bL#%R71MFmncQ62XG9Vv4^W)n@QnAMFty4xzrxPv;-yXYe!m zS^RANF@6p|m!HQ!&OgCF$%7I96G1-{bd8|v1pPwLuLRv7=r@8u@!cfo7Qqa`h+zCO z{}lf;{|vv7Im<5wh4LJ~gnyo2$S>oUgOP(-f)&DEBrI(HAnXsIYG6fz{fTfqTq2wU z;o4ZB>(t#?)@-d>w|m1@aljprt@)^&MG>3#Gn+E2Yw67amIxa~Wy6d#8i;bN3RHoUVc7iVco` ztTr9O<_fp6o*ZeeNf-?jLC+sn8o|AO`}Clt2~uIRf4Mm~cn~0+_r;RjjzU)lj{+2Cb9;6s<}Z#T&yOQqyu!;JrVrJB5w<kUUT3g{4HQrFC;1)xDgHEnhTjS! z`78b$|26kEKF@FGf_Sjuy`bWr{IGVFj&W27gB>#E zFY%Z8@A)hI5BydBNB$@NXZ{+0o&SaZmA}FN#{bUWB-oAMP6T%+xQgJJ1g|7`7r~zq z{0m|22^&J#JOX=V*eQg4jE6zT0O^=eaWfOrrO?u2QJ<}q0cZ_zG}mhzq8bm)IuepAqtJ4c<*1*X)cRa(fjB_!8@b()%)q&>;3fs`ape< zK3LyDA40G@!R-k4AlQ>&FM_=Z2GhisU_XM}H|oPICb&M*vb#m!i5f0V_Bg?TO}krg z^uNBD2k)EFXEFzt>$3*)qbEb9k?Zw80d>x;oy#o#<|xw*q^O6kkZVKS_B9I77yhP!^4zCu5o;BbOF z5**Q>uhfqOqQQ{_M_C*-|M&A=fB7ice>xbZuVY&2N4NC8wA}AU(PV+oEUxHG}= z1Sb%jNN^It$pohmoZ6^=q6xrHA@f;I{UQqBG|M!a;0()YPJ+As3*f&2z^f^M*ASd; z1@Kw`aLe9 zyx|{C8d|HhPY?dy2K`(5{rUq0cOf{B;QR*tJNkDiUJD2={Kp-udI%k%KhhF}_c+L@ z{}@C+X(`(B%%=7EkL$s)4CgcTpHaMa`wK1hrT!9y>skF*`g8iP_2=~$^xx>e)qkhI zNH8eo9t8I!7}Roag8LBMm*9Q`_a}HjqyBOeT(36q?KKM5fmXNMr zAl?!GIBJECOe-OxB@|DqBUY@Np0Q+0*4C(t?Q5QarDq`;L}OZtz7QE+X&RF~?Af>1 zUo)KiWl{6evk)gFm{BXlQ`C<9lSZ?)Sej4(s1?$M3?Wm<60(IHAy?=k9mgj!Fv5~A|?Dq z%lWYI1mN(=mN>jJ`O`BO`ee6yY5Wd(#N7446tYi&=+iAl)wFrDf2_>f^4f3ifw_y; zj=K%nMZ$9y$UaLUJLAt<&Dv!3!b=ph4Z;dxrLam^Evyk<5E_NG!ixmYB6v2zj}bhF z;JE~Ysr)#>PZ0bh!Sfr1mzyyAN)u)`Q_L=~V)hv;W*h#+?B9Udy%e+i2!6_n*#k|O z72btFCxBRjpEeV%jkTjcgpely+e5-(f)^6Js9yL;I7;whf|pT9Jdgw8!g1jw1>gzc zGvRZBpC$M?f|pPLo)S(|06tIf(tjF&UjwSnw?tK^)+@%0j!dmyu&jPe_YaPnC;-0& z(eGM{F8K7s8y)-PU4OjwY1ZxF^jo(9cv<*?HuCqdT?;RV?OMWBeKEoHbb9c|$$>3O zvUbfc!fzJX-k`8uL1BBc z7crUO*C}kb($fAS!~G5HdRWY4T8UXLfp{z6RLZfRGEdG(Jh<|e$`y`)SuqzxyR;PD zXX|D7Wk&L&^B>=~`tX=2hi1%*1!6afK3VKaO^xkKTBmzQX*DT%$AB}V!3D(hloSPVPb_iTpU60Zi3$=cn`sQ2?n+P7Qy=o24}(B z1i#ZLj%>p0s3y#grkH)#irIr!%pU(2vws6-r&7#LBltZlW*=?BtT@X`weOp$*52CD z^Ta19Y#$fFR{DV859`JG;sSyX5&RJa(gWGwCoU4rww^3LONZ@YIsk!P!9qULa@eA| z)~Yp#t0~x5h$}^)3E=;a3I3!(TmxVSsyIf${wb~F|J`-`A8qQts6P&i>jA+VS|WJG z&j*hF{!_-~m#3wF;vO|~3@{n`rKZ>_Ms_AhMz2H1W{VGCVx-U{2V0k-0K@dEu24F7*) z`3c@Af5TvFZ+|adrP#e9{y^||1YfKde-wWr_!7Za9vpUm6@Rx-&TkaEm+2_HWybFJ zEwL-FYpR;WNdjP3(n-9eCm8AnM&geRl89X-FgJgq*!}q*#;#<`w36&v0`mDWzvt{& zpC>=-sVtqDo6!i^l^j5{O-s>|llvwn49#0G|Jb^mS4tVC8M~6RrB|T zgmodTD`DLlrN=B(D?M(ZT4@1=tGgAh9#*&p{|nc@0j~8Ft_=htnpvXmN~`{CRx7^!&cN}!e%}&=*mtq*m(`Iv)on&sHPA$ zm9S|IvYYHqp_@+FjDH-uvMEPc_d6ITvzxHl_d4^7k9wO=WWWz=7j=?5 zn=vfMQ4Dth49npZ_}MLhZ%vUR=K+T0R5?vfmowx{IZMu#bL3pP3t{sKTR_-C!geJr zSXbQ%1P)}uw(3dPUX60T1;cVT3x?&M6vMr(81C1M;UWKG_;0{)8O3lpVf$DyJhX{+ z<>5_^DYmcKG39CP>1w%_j;tDa6k+=lc0j#slIsXNkg$Uvn4v6>mmi{qOrXPe5FNgg zU=4(YWssJZvMoMmt=d%iQ40EL@+0ze!j=%Wl(1#6awN}`XHiQTp3_L{_@LZO|92PH zVfk@D@)IqQys@fR=&tI5)LEsI7xec~11abifap^#MRhmV^jWtp#e3~ukBklNzgc-3 z^b6%@EwEioVLKFzWqA@b88qB|al2f8k>a*qZje{VE9F)4YI%+Pg4`&tB`i!Zh7)!K zVJitclCV{TttM;@VWHz|8|9apaJ#{BWL$og;?`uvErdgPmY4%>u-d=C{TqP0hXQvm zVe72G-4DRMcfbuRJPyi-D5yS=KP2oJ!j7$%56edgJC3ju9vpIy$tUO-l;z{pW*iS= zP?t}~;636+SnG9K{))o(jQpj1mar2E`w(F#HOS`xxaSG`Fh%ZUTEV~Q{QV2~{=a&r z56hPTi}ko*9>amVdFp_Bw?v`72F!OOXJ!3ZozeE3CpPI)zvC zilB&uol4kggnfjtpaUNz>VLZhhhnfqtLK8?6Uw`vv~roc0aCF1}eiSXa_09 z%3!5LDOJjpa>b|&QHBzB31Ocn>{7yl(p*m1dcuM)WCdYY5_VOiQqct3kxih5-d1%}vq!fqh!D-8-5$O;Uz zjTEw*{%Odr1suNE5{HvlPF~gBCt3IUw8d`5!04yJJXY3$==zqT9~bXi_vq*O%ws|M zy6>+BbZRz_m5s_~XfzzAyh?H7dIV+HLQ0PVe_l3L+$LHUki>Ko--!tNvNTlLCC;b~QP1tuDlVvb6533B*N<}S!)iTWJ zVU+_>T}#oH!AqMOSrt^7Vo+9L`HQdz0fXFn^YmcE{V=H7s`PXR4pZ$ZY(M-fN!qHu z09(~XbyeL|ceS1Bp?a!bs<#RlJWN=a^n66vqlEpKu%8h27-2sp>~X@LXjJ_yuvG&s zOsj@a*nVb(?H5+2{q|qbK6uco@c>#C+>D<$fmTfh(5fk@1N{*8q&CR>1Ki26*720B zc7eg6=BT-ZJw@2l^=h7)PuMerJ^#Q!t9Dm=0ch1CwTB8m%P$Fgmaty|ZK{3LzLYi3 z5%z0Z+CS)5Qi}nJgIgkTW}K7C?x{EnNi+W>K@WguGKQgrDvueRyes^FK# zWsJ6JOWOm@#HkKdhg(2fK|y-~5U5s~iSwHlptTh%tz9!pt+U|PL~;AwpQW-UnV^E( zZ@CICfJ@8ONrb%&F~%U65{)(kv6>*p@11Y%J_L_3g|Ju5fz;G#>Lc)0$O3pPa=khY z-U>;h$EdK&ZaHJCVF(FhIfSpyR_8!y4|;96I+w6NY5^9>Cm=+nSqReYARYCA{=mZBSPb_E*B*s8?61s|ouXVQ*>y1Mdpfa%VFh zR$qjcdP#kmzKfT>x7<5rfB1@<*D^c^`@5xW*Q*<#ZQ(`D#(H%VCM)hU0q+Zx^**EVf>>)N;4_CPYRmD9RsQM}v-8 z226vxTiwHq?^94@`EIS|J{1hRQVJ$^%M0N;q-(P2qzLwBAiS(g>Wk2YzWut1;$4`!T75u)RXEL z>M8X!L|}tk23XJJ41}{KoE_op3D+9P$*W(5DWkGtXazhsgj0ey8w@o+I zs%u9=bhtY&=a_m|zzdfl8X3H)({iIIy^A@1TosMTbf>KKrSTB5%X0b7uvC4U6GN!N zJ5!khV$j#bwkR2bPL_{1@Fn2wt0O90D)AEiW#CDB}aHxrKKM>DVvJ zSY@oOD61$evf%qF^TD#_sAuZW>J5n6r(RR9tG}qf63&ruPK0wNT-z1uZ|d*rO%=fH zLO55#xe?Btj*T_|#=GNI^{A+$t?ks}MlWNn$^7c&mbVIP>fjEvd02~7O(C=3cG2jO z@V-rW9X!=|MaH4#m%H~&9bHjb-X}adEh;RcV~Jt7`MGRF=7RG zeoUKIHug+#gN?z))&|Ck2jM&k=ha}-+Qvcsjd0$C^MR+N4@Wi4-4AzH;azJhs%y=G z$7sC2KM_oYpesejnv&WQ2uN6L2>5epOBv?ihu+CeOOV|>nhY%6r@R*uYcz z5w1Pq{Ivk@q^1`omN6c_nDIDl6Uel(39<>M>Wqf{E40PB(wfQ&Q(aitM+mc=j@L_l zeBj;Iw3GV!(6Iw=9k1=-Q&(Nn$EUiKMrJeFF=+ZSvpN9{3lYnjgboQ}kJ{?$It?|- zihXNWH($Tt(4Ib3Ks!fNn^p!}yJk((%crce#AJfE8mH7jPnM2`w;BTzi5lCtXmnYb z(O7OQKjP5F#?je@R&|7Nys01jZYqDdlg2G?tLZcWG;bzEY5T~%orjI5m$wgmFRGYe zgmQ*tR+bDkQPu-~^iKA(G@-3v(0C0ZjkO6$p>J|6GDj%vnL{&41!a1;y?;PpKTGAR zAYmWNpAeDKQc!(B5aeBGZU&I;5CTt-UI7vItE*s8)^`Yki(%oQ3+{L@2=GY9YJJC` zdtPS*#+GRdkt73{j!X=b!{jmDm?CBnQ_hTF>X?bl3}zPd7&Dhy$Sh@6Gp{h4n9a-< zW-GIu*~7dCtJ0q_Uozh@mzgWfRiq$W4gW)_!Ih^Gf2In{?ij&02A`z#GkBE{S&|jlV_|?@6~1yOIZrNz z1={KI0(m*C>b)qxEU%Yek+;b^{K*eH?U$jw#qlmexz(p~8f&dOr& zQI>&!@*(AMJZOKiGr zfo*SF&Gu2-^>z++?sk!Oo$UtMjj*e+tF^1M8*ew!Zj#+(yVZ8@*?nqv#_p`$IlJ?A z-`HKX`^oN_-7j`G?0&azZSQZNVn4vX!hX2@2>X%tGwh$TUueJBeu@23`{nlA?f2QA zu|I2nuJyC6SG3;HdUNY7t+%$`*?M>DJ+1e(zU*M{;OgM!(9Xf%Au}EDyu&vR-#J`z z_}<|Mho2m-JKS)%*@kK3-KJ}s(l#U8Jli(+H=Jyo0-b`LLY%^!Iyyx4MWGXVtl_v#Ybavxjq(bBuGk zbEb2)bFOnQ=Q3xb^HAps=TXikXU%zv^EBt_&QCfobzbZIs`G2kuRCvR+o5fA+q|~j z+74}7({^IpNo^;$-QD(`wja0syzPayH(WTEP?t!TVwYhqBV0zhRJ%OnGSg+Y%N&<^ zE>F13cX`UC-erZ$Dwh{r*1EjxvfkwtmmMyLT)uYs$rZVJx_Y~YxkkChxW>9Bx+c4( zx~9AKb**$A?K;JEn(K7e8LqQk=ej=OI^T7Z>xZsCy76v4Zk^oH-Adic-G;aga~tkf z=~m@d<5uf7+ii~9Jhvy@=DR)R_Ke#yx3z9Bxvg{C;I`52RkzpN_PV|0cEIf&x3AoO zaQo5iXSeI_jJxXI%H7u8-rd99%iYJ_&ppCD%00$C);+_$yL%7!UhaL|_q%`O{+auE z_ix<4bHCJXV7nphD%*`}_h`G<+U;%kX}c5cK5zF$yVLEyZ1+{WuiIT{_iek29@vBP z;5`Hn$wTq5@i2JUd9?Ouo*#N%^%e%~bnD=n+O7BtLChyVSW4$MNKjyvKd#m>z@8jNIc>nC<=;Q3;;^XGi z&d1Zo+sD_Zy-%`Fs!zI4rcbs{u1}s%PoGksa-ShS!+eJORQgo;)c8#Dne0P+rur=Q zS?%+J&sv|Ce0KS~@AHArA)g~Y=Y4+ix#shW&kbL}SMpVSZG2sP-F(~mdir+ojq{E7 zP4rFn-S7L6@5jE!e2@E;_)YPf=eNRdmERh_M!y&RUiMq>_ln;pzs-JI{NDHb!0(XX z5x=8;pZI<1cf#*;zc2hw`+eD-Y2U7WT6<&rneErLKhpjOe_Q_${}}%`|9JmI|1AHm z{@wk1`1kVf5&kv)wf=SfZt3 zv;OD&&j;8Bqy>}*j0~6%FehMMz`}r)0b2sL2J8=bFW_LnhXF?eJ_-0V;6%VT0T%;) z4rBv`Kq*iOv<||^b1S~928g*SQcmu92!^=SQ%IqSQ9uiaCYFFzA3psqpPgL(v&1eFCDgN6o~g0!G1LDPb!2MfUt!H&Vs!7jl|f*XT( z2Ja5u6TB~YfAHJE?*_jg{6X-c;3L83gTD>F7<@VSO7M@tKL=kA{x$gb;9DI~2loyM z9ZEV(?XbGT`yH-?7(zlr@dITvy@zOch#N5hVVod`P_b~@~A*txK)VLyjm z5BoLT5bhT45$+Z46CM+u9-bAR6W%3!aQMjZ>hMwFrf@BMO8B(!>EZLkpAKIbzBqhU z_{Q+f;akGDc1-S=-?6Y`w~jqJ9`E>d$KN{Miog-N2q8j_u!*pZXdU4g(KaG9qGLp4 zM07-_h|UoS5lIm#5$O?`5!n&Oh-ndPBHoF(6xlkmQ)KVRF_H5lpNm`?Ss%F~@}39*aB?c{1|L$a9ewBEOBYi3*D96qOW}8kG^19aR|BJ*sC^ z@2I{}Q=&FS9gR8}^=;J6XeL^WZWA349TeRuIw3kKIyE{gIwv|Wx*&RB^x)``(Y4Wa z(PN_@iheknL{E)=GI~w)y66w1k3=7hJ{El<`t#^h(O*V?6@4=X#c(lvj1VKmC@~H( z9x>iAelYB~;nJKgAXGZw{ivHDmkR*f~p z+Q+tu4T=qk4U6p<8yOoD8yA}pn-rTGn;x4PTNXPdc2(?w*ze-(;-cev#?{3=5w|#Q zN!+ry`na`mo8n%J+ZwkcZdcr!aeL$5iaQc_Ebc_y$+$1$&c$7b`?j-9=b+A=Iwy5b z?VQm$yK`aZ?wxyf?%la>=P8{xbw1kpWan=?-;8JC#rQVy0r5fco#GSXljBq4v*L5( z^WqEQ2gVPMuZSNJKQ4Y!{N(s4@sGyOjDIYCZv2w?4GBRBc?pFH-4c2v^iJrTFd(5g zp(J5K!lVQ(VM@ZZgy{)05}rtSE@5dxeZtCwH3@4IUP{=Sup?n-!tR8R5>6+aO*ofu zKH>L7DN#wZNi-z7CALfSO!Q6+ON>a2N{mTNOYEB1J+Vh(uf(!MW8%=n;fbFmUP!!| zgp#-gpA?Qk*g)Woycbl(Q*UQn^$RoMuR~Pjg6fN^?nbOY=zcPV-GmOiM{iPs>cpPRmWpOY507IIS#gNLoc& zWmh#+5y7V#W0L&pOHQ*eRcX<>EC218DSZ{GA3j!%Gi=|G~;B(>5Q`(=Q1v5T+g_XaWfNTa+!Rl zeWp{UOQw6Kccx!vKxR;8cIJr8aha1cCudH{d^B@r=3|+2GnZsGW^Tybl=)ib*38|R zdo%ZEzMc7Q=8su^Sy@?Kvj%3>WYuO(%$kw)Y}S&j#;kQ&8?rWKy`Hr#>y4~kSs!E_ z%lba+r))L5U3PGGXm-c!sO(PJaoGjgy|V{o7iX7dmuHX49+y2a`{8VoJtKQ|_MGg; zv!BdfmihTRAAlKF2l3Jtrt9GAAY{E+-); zIVUG4FQ+i4drr@s-Z_JFhUbjVnUk|Pry*xm&I>s&=B&%vlCw2ud(Im&lIhFH! zu19WcZf0)p+^Fuq`Z!ODVT1(yr17pjGZLi<98LZ`yEg|3C|3cU(_3;hcN z3u6jn3p*Dk7OpScQMj{k_y1SZnSVD?C~R1vf|XjJYz0KTAiHc8C`;Kc2nbY~G}|

fzNY1-!Jd; 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 delta 10553 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 NFEs55T+i0-{|84{_ap!S diff --git a/Sources/PurchaseKit/Manager/RKPurchasesManager.swift b/Sources/PurchaseKit/Manager/RKPurchasesManager.swift index 154b719..fccf1fd 100644 --- a/Sources/PurchaseKit/Manager/RKPurchasesManager.swift +++ b/Sources/PurchaseKit/Manager/RKPurchasesManager.swift @@ -8,12 +8,17 @@ import Foundation import StoreKit -/// ``PurchasesManager`` – entry point to `RKPurchaseKit` -/// See for an overview and API map. +/// ``PurchasesManager`` – entry point to `RKPurchaseKit`. +/// +/// The manager is an `actor` and is therefore safe to use from concurrent contexts. +/// It holds a product cache, listens for `Transaction.updates`, and exposes a simple +/// API for fetching products, purchasing, restoring, and querying current entitlements. +/// See for the overview. public actor PurchasesManager: PurchasesProtocol { // MARK: Properties + /// Global singleton configured via ``configure(identifiers:)``. public nonisolated static var shared: PurchasesManager { guard let instance else { fatalError("❗️ PurchasesActor.configure(identifiers:) must be called before first use.") @@ -21,6 +26,9 @@ public actor PurchasesManager: PurchasesProtocol { return instance } + /// Async stream of purchase events emitted when a product becomes entitled. + /// + /// You can `for await` this stream to reactively update UI or unlock features. public nonisolated let purchasedProducts: AsyncStream private let identifiers: [String] private var productsCache: [String: StoreProduct] = [:] @@ -43,10 +51,11 @@ public actor PurchasesManager: PurchasesProtocol { } // MARK: Public methods - /// Creates the singleton and starts the `StoreKit` listener. - /// - SeeAlso: ``PurchasesManager/shared`` - /// - Parameters: - /// - identifiers: Product IDs registered in App Store Connect. + + /// Creates the singleton and starts the StoreKit transaction listener. + /// + /// - Parameter identifiers: Product IDs registered in App Store Connect. + /// - Returns: The configured singleton instance. @discardableResult public nonisolated static func configure(identifiers: [String]) -> PurchasesManager { precondition(instance == nil, "PurchasesActor.configure(_:) has already been called. Double configuration is not allowed.") @@ -58,17 +67,15 @@ public actor PurchasesManager: PurchasesProtocol { return instance } - /// Fetches products from `StoreKit` (optionally returns cache first). - /// - See - /// - Parameters: - /// - includingCache: If `true`, cached products are returned immediately. - /// - Returns: Array of ``StoreProduct``. - /// - Throws: ``PurchasesError`` - /// - Note: Uses `Product.products(for:)` under the hood. + /// Fetches products from StoreKit (optionally returns cache first). + /// + /// If cache is used, entitlements are refreshed asynchronously so the + /// `isPurchased` flag remains accurate. public func requestProducts(includingCache: Bool = true) async throws -> [StoreProduct] { if includingCache { let cachedProducts = productsCache.values.map { $0 } if !cachedProducts.isEmpty { + Task { await self.refreshEntitlements() } return cachedProducts } } @@ -80,12 +87,7 @@ public actor PurchasesManager: PurchasesProtocol { return productsCache.values.map { $0 } } - /// Performs a purchase flow for the given product ID. - /// - SeeAlso: - /// - Throws: ``PurchasesError/purchaseCancelled``, - /// ``PurchasesError/purchasePending``, - /// ``PurchasesError/verificationFailed`` - /// - Returns: Verified ``StoreProduct`` just bought. + /// Performs a purchase flow for the given product identifier. public func purchase(productID: String) async throws -> StoreProduct { guard let product = try await Product.products(for: [productID]).first else { throw PurchasesError.invalidProductID(productID) @@ -107,11 +109,85 @@ public actor PurchasesManager: PurchasesProtocol { throw PurchasesError.unknown(PurchasesError.unknown(NSError(domain: "unknown", code: -1))) } } - /// Syncs with the App Store to restore previous purchases. - /// - Throws: ``PurchasesError`` - /// - See + /// Syncs with the App Store and refreshes current entitlements. public func restore() async throws { try await AppStore.sync() + await refreshEntitlements() + } + /// Returns `true` if the user currently has an active entitlement for `productID`. + /// + /// Uses `Transaction.currentEntitlements` under the hood and falls back to the cache + /// if available for fast checks. + public func hasEntitlement(for productID: String) async -> Bool { + if let cached = productsCache[productID] { + return cached.isPurchased + } + for await result in Transaction.currentEntitlements { + if let transaction = try? checkVerified(result), transaction.productID == productID { + return true + } + } + return false + } + /// Returns the set of product identifiers for which the user has an active entitlement. + public func entitlementProductIDs() async -> Set { + var ids: Set = [] + for await result in Transaction.currentEntitlements { + if let transaction = try? checkVerified(result) { + ids.insert(transaction.productID) + } + } + return ids + } + /// Returns all active **auto-renewable** subscriptions mapped to ``StoreProduct``. + /// + /// If a product is not yet cached, it will be fetched from StoreKit and cached. + public func activeSubscriptions() async -> [StoreProduct] { + if productsCache.isEmpty { + _ = try? await requestProducts(includingCache: true) + } + var result: [StoreProduct] = [] + for await resultTransaction in Transaction.currentEntitlements { + guard let transaction = try? checkVerified(resultTransaction) else { continue } + + if let product = productsCache[transaction.productID], product.type == .autoRenewable { + result.append(product) + } else if productsCache[transaction.productID] == nil { + if let fetched = try? await Product.products(for: [transaction.productID]).first { + cache(fetched, purchased: true) + if let storeProduct = productsCache[transaction.productID], storeProduct.type == .autoRenewable { + result.append(storeProduct) + } + } + } + } + return result + } + /// Returns the best active subscription for the given subscription group. + /// + /// If multiple entitlements from the same group are present, the one with the latest + /// expiration date is returned. + /// - Parameter groupID: Subscription group identifier as configured in App Store Connect. + public func activeSubscription(inGroup groupID: String) async -> StoreProduct? { + var best: (product: StoreProduct, expires: Date?)? + if productsCache.isEmpty { + _ = try? await requestProducts(includingCache: true) + } + for await resultTransaction in Transaction.currentEntitlements { + guard let transaction = try? checkVerified(resultTransaction) else { continue } + // Проверяем, что это подписка в нужной группе + if let product = try? await storeProduct(for: transaction.productID), + product.type == .autoRenewable, + product.subscriptionGroupID == groupID + { + // У auto-renewable подписок у транзакции обычно есть expirationDate + let expiration = transaction.expirationDate + if best == nil || compare(expiration, isLaterThan: best?.expires) { + best = (product, expiration) + } + } + } + return best?.product } // MARK: Private methods @@ -129,14 +205,14 @@ public actor PurchasesManager: PurchasesProtocol { private func map(_ product: Product, _ isPurchased: Bool) -> StoreProduct { StoreProduct(product: product, isPurchased: isPurchased) } - + /// Verifies StoreKit's `VerificationResult` and returns the signed value. private func checkVerified(_ result: VerificationResult) throws -> T { switch result { case .verified(let signed): signed case .unverified: throw PurchasesError.verificationFailed } } - + /// Marks the given product as purchased and emits a ``PurchasedProductEvent``. private func markPurchased(productID: String) async throws { guard let cached = productsCache[productID] else { return } @@ -146,19 +222,9 @@ public actor PurchasesManager: PurchasesProtocol { } private func updateCustomerProductStatus() async { - for await result in Transaction.currentEntitlements { - guard - let transaction = try? checkVerified(result), - let products = try? await Product.products(for: [transaction.productID]), - let product = products.first - else { - continue - } - - try? await markPurchased(productID: product.id) - } + await refreshEntitlements() } - + /// Listens to live transaction updates and finishes them. private func listenForTransactions() async { for await result in Transaction.updates { guard let transaction = try? checkVerified(result) else { continue } @@ -167,4 +233,48 @@ public actor PurchasesManager: PurchasesProtocol { await transaction.finish() } } + /// Rebuilds the entitlement state: + /// 1) Clears `isPurchased` on all cached products. + /// 2) Sets it to `true` for anything present in `Transaction.currentEntitlements`. + private func refreshEntitlements() async { + // 1) Clear all flags + if !productsCache.isEmpty { + for (index, storeProduct) in productsCache where storeProduct.isPurchased { + productsCache[index] = storeProduct.setPurchasingFlag(false) + } + } + // 2) Apply current entitlements + for await result in Transaction.currentEntitlements { + guard let transaction = try? checkVerified(result) else { continue } + + if productsCache[transaction.productID] == nil { + if let fetched = try? await Product.products(for: [transaction.productID]).first { + cache(fetched, purchased: true) + continue + } + } + try? await markPurchased(productID: transaction.productID) + } + } + /// Ensures a ``StoreProduct`` for `productID`, fetching it if needed. + private func storeProduct(for productID: String) async throws -> StoreProduct { + if let cached = productsCache[productID] { + return cached + } + guard let fetched = try await Product.products(for: [productID]).first else { + throw PurchasesError.invalidProductID(productID) + } + + cache(fetched) + + return productsCache[productID]! + } + + private func compare(_ lhs: Date?, isLaterThan rhs: Date?) -> Bool { + switch (lhs, rhs) { + case let (l?, r?): l > r + case (.some, .none): true + default: false + } + } } diff --git a/Sources/PurchaseKit/Models/RKStoreProduct.swift b/Sources/PurchaseKit/Models/RKStoreProduct.swift index 40dc044..550f665 100644 --- a/Sources/PurchaseKit/Models/RKStoreProduct.swift +++ b/Sources/PurchaseKit/Models/RKStoreProduct.swift @@ -13,19 +13,34 @@ import StoreKit public struct StoreProduct: Sendable { // MARK: Properties - + /// The raw StoreKit product object. public let product: Product + /// The product identifier from App Store Connect. public let productID: String + /// High-level product type (mapped from `Product.ProductType`). public let type: ProductType + /// Localized display name as presented by App Store. public let displayName: String + /// Localized description as presented by App Store. public let description: String + /// Numeric price (ISO currency via `product.priceFormatStyle.currencyCode`). public let price: Decimal + /// Localized formatted price (e.g. `"$4.99"`). public let displayPrice: String + /// Whether family sharing is allowed. public let isFamilyShareable: Bool + /// Convenience flag reflecting whether the user currently holds an entitlement + /// for this product (derived from `Transaction.currentEntitlements`). public let isPurchased: Bool + /// Subscription group identifier for auto-renewable subscriptions. + /// + /// `nil` for non-subscription products and non-grouped items. + public let subscriptionGroupID: String? // MARK: Initial method + /// Designated initializer. You don't create `StoreProduct` manually in apps — + /// it is produced by the kit from `StoreKit.Product`. init(product: Product, isPurchased: Bool) { self.product = product productID = product.id @@ -36,12 +51,15 @@ public struct StoreProduct: Sendable { displayPrice = product.displayPrice isFamilyShareable = product.isFamilyShareable self.isPurchased = isPurchased + subscriptionGroupID = product.subscription?.subscriptionGroupID } // MARK: Internal methods - /// Convenience copy-initializer that toggles the `isPurchased` flag. - /// - Returns: New ``StoreProduct`` instance. + /// Returns a copy with an updated `isPurchased` flag. + /// + /// - Parameter isPurchased: New entitlement state. + /// - Returns: A new ``StoreProduct`` instance. func setPurchasingFlag(_ isPurchased: Bool) -> StoreProduct { StoreProduct(product: product, isPurchased: isPurchased) } diff --git a/Sources/PurchaseKit/Protocols/RKPurchasesProtocol.swift b/Sources/PurchaseKit/Protocols/RKPurchasesProtocol.swift index 565f3e5..82e6397 100644 --- a/Sources/PurchaseKit/Protocols/RKPurchasesProtocol.swift +++ b/Sources/PurchaseKit/Protocols/RKPurchasesProtocol.swift @@ -10,9 +10,47 @@ import Foundation /// Protocol abstraction to allow mocking in tests. /// Full spec: public protocol PurchasesProtocol: Sendable { + /// Fetches products from StoreKit (optionally returns cached values first). + /// + /// Internally uses `Product.products(for:)` for the identifiers passed to + /// ``PurchasesManager/configure(identifiers:)``. + /// + /// - Parameter includingCache: If `true`, returns cached products immediately + /// and refreshes entitlements in the background. + /// - Returns: Array of ``StoreProduct``. + /// - Throws: ``PurchasesError`` if StoreKit lookup fails. func requestProducts(includingCache: Bool) async throws -> [StoreProduct] + /// Starts a purchase flow for the given product. + /// + /// - Parameter productID: A product identifier registered in App Store Connect. + /// - Returns: A verified ``StoreProduct`` that has just been purchased. + /// - Throws: ``PurchasesError/purchaseCancelled``, ``PurchasesError/purchasePending``, + /// ``PurchasesError/verificationFailed``, or ``PurchasesError/invalidProductID(_:)``. func purchase(productID: String) async throws -> StoreProduct + /// Synchronizes with the App Store and re-evaluates the current entitlements. + /// + /// You typically call this from a "Restore Purchases" button. + /// + /// - Throws: ``PurchasesError`` on sync failure. func restore() async throws + /// Returns `true` if the user currently has an active entitlement for `productID`. + /// + /// Uses `Transaction.currentEntitlements` under the hood. + /// - Parameter productID: Product identifier to check. + func hasEntitlement(for productID: String) async -> Bool + /// Returns the set of all product identifiers for which the user has an active entitlement. + /// + /// The result reflects **current** rights only (including grace period). + func entitlementProductIDs() async -> Set + /// Returns all active **auto-renewable** subscriptions mapped to your ``StoreProduct`` model. + /// + /// If a product is not cached yet, it will be fetched from StoreKit on demand. + func activeSubscriptions() async -> [StoreProduct] + /// Returns the active subscription within a specific subscription group, if any. + /// + /// If multiple are present, the subscription with the latest expiration date is returned. + /// - Parameter groupID: The subscription group identifier from App Store Connect. + func activeSubscription(inGroup groupID: String) async -> StoreProduct? } /// Default wrapper that keeps source compatibility. diff --git a/Sources/PurchaseKit/RKPurchaseKit.docc/GettingStarted.md b/Sources/PurchaseKit/RKPurchaseKit.docc/GettingStarted.md index ceacd3e..ab5384d 100644 --- a/Sources/PurchaseKit/RKPurchaseKit.docc/GettingStarted.md +++ b/Sources/PurchaseKit/RKPurchaseKit.docc/GettingStarted.md @@ -14,7 +14,9 @@ import RKPurchaseKit PurchasesManager.configure(identifiers: [ "com.myapp.pro", - "com.myapp.consumable.coin" + "com.myapp.consumable.coin", + "com.myapp.sub.premium.monthly", + "com.myapp.sub.premium.yearly" ]) ``` @@ -76,6 +78,63 @@ try await PurchasesManager.shared.restore() Provide an explicit “Restore Purchases” button if required by App Store Review. +--- +## 6 Gate features by entitlement + +Use current entitlements to unlock features (no receipt parsing needed): + +```swift +let hasPro = await PurchasesManager.shared.hasEntitlement(for: "com.myapp.pro") + +if hasPro { + // unlock premium UI +} +``` +Or quickly check all entitled product identifiers: +```swift +let ids = await PurchasesManager.shared.entitlementProductIDs() +``` +See: ``PurchasesManager/hasEntitlement(for:)``, ``PurchasesManager/entitlementProductIDs()`` +--- +## 7 Work with subscriptions (groups) + +List all active auto-renewable subscriptions: +```swift +let active = await PurchasesManager.shared.activeSubscriptions() +``` +Select the best (latest-expiring) subscription in a group: +```swift +if let subscription = await PurchasesManager.shared.activeSubscription(inGroup: "com.myapp.subscriptions.premium") { + print("Active plan:", subscription.displayName) +} +``` + +Where to get groupID? +It’s available on the product as +StoreProduct/subscriptionGroupID (derived from Product.subscription.subscriptionGroupID) and configured in App Store Connect. + +See: ``PurchasesManager/activeSubscriptions()``, ``PurchasesManager/activeSubscription(inGroup:)``, +``StoreProduct/subscriptionGroupID``. +--- +## 8 Testing & mocking + +Depend on PurchasesProtocol in your app code and inject a mock in tests: +```swift +struct PurchasesMock: PurchasesProtocol { + func requestProducts(includingCache: Bool) async throws -> [StoreProduct] { [] } + func purchase(productID: String) async throws -> StoreProduct { throw PurchasesError.purchaseCancelled } + func restore() async throws { } + func hasEntitlement(for productID: String) async -> Bool { productID == "com.myapp.pro" } + func entitlementProductIDs() async -> Set { ["com.myapp.pro"] } + func activeSubscriptions() async -> [StoreProduct] { [] } + func activeSubscription(inGroup groupID: String) async -> StoreProduct? { nil } +} +``` +--- +## 9 Concurrency notes + +PurchasesManager is an actor, so its API is thread-safe by design. +Update UI on the main actor when reacting to events. --- ## Next steps diff --git a/Sources/PurchaseKit/RKPurchaseKit.docc/RKPurchaseKit.md b/Sources/PurchaseKit/RKPurchaseKit.docc/RKPurchaseKit.md index 133d325..4701092 100644 --- a/Sources/PurchaseKit/RKPurchaseKit.docc/RKPurchaseKit.md +++ b/Sources/PurchaseKit/RKPurchaseKit.docc/RKPurchaseKit.md @@ -16,6 +16,17 @@ ## Features +- Actor-based, Swift Concurrency–first API +- Product caching with instant repeat calls +- Purchase & restore flows with typed errors +- Live transaction listening via `AsyncStream` +- **Entitlement helpers**: + - ``PurchasesManager/hasEntitlement(for:)`` + - ``PurchasesManager/entitlementProductIDs()`` + - ``PurchasesManager/activeSubscriptions()`` + - ``PurchasesManager/activeSubscription(inGroup:)`` +- Simple value model: ``StoreProduct`` (with ``StoreProduct/subscriptionGroupID``) + @Links(visualStyle: detailedGrid) { - - @@ -25,3 +36,14 @@ - - } + +## Requirements + +- Swift 6, StoreKit 2 +- iOS 15.0 / macOS 12.0 / tvOS 15.0 / watchOS 8.0 / visionOS 1.0+ + +## Notes + +- Entitlement state is derived from `Transaction.currentEntitlements` and reflected in ``StoreProduct/isPurchased``. +- The manager refreshes entitlements after fetching products and after ``PurchasesManager/restore()``. +- When you update UI from callbacks or streams, hop to `MainActor`. diff --git a/Sources/PurchaseKit/RKPurchaseKit.docc/SymbolReferences/PurchasesManager.md b/Sources/PurchaseKit/RKPurchaseKit.docc/SymbolReferences/PurchasesManager.md index e5452e1..543b8dc 100644 --- a/Sources/PurchaseKit/RKPurchaseKit.docc/SymbolReferences/PurchasesManager.md +++ b/Sources/PurchaseKit/RKPurchaseKit.docc/SymbolReferences/PurchasesManager.md @@ -2,16 +2,44 @@ The central `actor` that drives all StoreKit 2 operations. +It handles product fetching, purchases, restoration, entitlement evaluation, and continuous transaction updates. Being an `actor`, it is safe to use from concurrent contexts (Swift Concurrency). + ## Topics ### Configuration -* ``PurchasesManager/configure(identifiers:)`` -* ``PurchasesManager/shared`` +- ``configure(identifiers:)`` +- ``shared`` ### Operations -* ``PurchasesManager/requestProducts(includingCache:)`` -* ``PurchasesManager/purchase(productID:)`` -* ``PurchasesManager/restore()`` +- ``requestProducts(includingCache:)`` +- ``purchase(productID:)`` +- ``restore()`` + +### Entitlements & Subscriptions +- ``hasEntitlement(for:)`` +- ``entitlementProductIDs()`` +- ``activeSubscriptions()`` +- ``activeSubscription(inGroup:)`` ### Events -* ``PurchasesManager/purchasedProducts`` +- ``purchasedProducts`` + +## Usage + +```swift +// Configure once at app launch +let purchases = PurchasesManager.configure(identifiers: [ + "com.myapp.sub.premium.monthly", + "com.myapp.sub.premium.yearly", + "com.myapp.tip.small" +]) + +// Check entitlement +let hasPro = await purchases.hasEntitlement(for: "com.myapp.sub.premium.yearly") + +// List active subscriptions +let active = await purchases.activeSubscriptions() + +// Pick the active subscription in a group +let best = await purchases.activeSubscription(inGroup: "com.myapp.subscriptions.premium") +``` diff --git a/Sources/PurchaseKit/RKPurchaseKit.docc/SymbolReferences/PurchasesProtocol.md b/Sources/PurchaseKit/RKPurchaseKit.docc/SymbolReferences/PurchasesProtocol.md index 37e1101..7a8f83c 100644 --- a/Sources/PurchaseKit/RKPurchaseKit.docc/SymbolReferences/PurchasesProtocol.md +++ b/Sources/PurchaseKit/RKPurchaseKit.docc/SymbolReferences/PurchasesProtocol.md @@ -1,16 +1,24 @@ # ``PurchasesProtocol`` -An abstraction that lets you swap `PurchasesManager` for a mock implementation in unit-tests or previews. +An abstraction that lets you swap ``PurchasesManager`` for a mock implementation in unit tests or previews. + +The protocol mirrors the public surface of the manager and adds high-level helpers for entitlements and subscriptions. ## Topics ### Core Methods -* ``PurchasesProtocol/requestProducts(includingCache:)`` -* ``PurchasesProtocol/purchase(productID:)`` -* ``PurchasesProtocol/restore()`` +- ``requestProducts(includingCache:)`` +- ``purchase(productID:)`` +- ``restore()`` + +### Entitlements & Subscriptions +- ``hasEntitlement(for:)`` +- ``entitlementProductIDs()`` +- ``activeSubscriptions()`` +- ``activeSubscription(inGroup:)`` ### Default Implementations -`PurchasesProtocol` ships with a default parameter for `includingCache` so most callers can write just: +`PurchasesProtocol` ships with a default parameter for `includingCache` so most callers can write: ```swift let products = try await manager.requestProducts() diff --git a/Sources/PurchaseKit/RKPurchaseKit.docc/SymbolReferences/StoreProduct.md b/Sources/PurchaseKit/RKPurchaseKit.docc/SymbolReferences/StoreProduct.md index a12b8ea..42c17b6 100644 --- a/Sources/PurchaseKit/RKPurchaseKit.docc/SymbolReferences/StoreProduct.md +++ b/Sources/PurchaseKit/RKPurchaseKit.docc/SymbolReferences/StoreProduct.md @@ -1,13 +1,17 @@ # ``StoreProduct`` -Value-type wrapper around `StoreKit.Product`. +Value-type wrapper around `StoreKit.Product`, enriched with a few convenience fields for UI and entitlement state. -### Properties -* ``StoreProduct/productID`` -* ``StoreProduct/type`` -* ``StoreProduct/displayName`` -* ``StoreProduct/price`` -* ``StoreProduct/displayPrice`` -* ``StoreProduct/isPurchased`` +## Properties + +- ``StoreProduct/productID`` +- ``StoreProduct/type`` +- ``StoreProduct/displayName`` +- ``StoreProduct/price`` +- ``StoreProduct/displayPrice`` +- ``StoreProduct/isPurchased`` +- ``StoreProduct/subscriptionGroupID`` Use ``StoreProduct/setPurchasingFlag(_:)`` to create a copy with an updated purchase state. + +> Tip: For auto-renewable subscriptions, `subscriptionGroupID` helps you select the “best” active subscription within a group (see ``PurchasesManager/activeSubscription(inGroup:)``).