HashCoreAI
← Blog
Game dev

Cum adaugi corect cumparaturile in aplicatie (Apple, Google, RevenueCat): lectii dintr-un portofel autoritativ pe server

Doing In-App Purchases the Right Way (Apple, Google, RevenueCat): Lessons From a Server-Authoritative Wallet

Publicat 20 aprilie 2026 · 8 min citire

Cand am inceput sa adaugam economia de jetoane in jocurile noastre din Neon Arcade (games.hashcoreai.com), prima decizie a fost si cea mai importanta: clientul nu are voie sa-si modifice niciodata propriul sold. Pare evident scris asa, dar majoritatea tutorialelor despre cumparaturi in aplicatie te incurajeaza exact spre opusul: cumperi un pachet, primesti un callback in app, scrii numarul de monede in stocarea locala. Functioneaza la demo si pica la prima persoana care deschide consola de developer.

Acest articol este un devlog onest despre cum am construit corect partea de monetizare: cumparaturi prin Apple, Google si RevenueCat, plus reclame recompensate care acorda jetoane virtuale. Totul se invarte in jurul jetoanelor din joc, o moneda pur virtuala folosita pentru a debloca clase de personaje, nave si upgrade-uri. Nu este vorba de bani reali, de minat sau de investitii.

Regula zero: portofelul traieste pe server, nu in client

Modelul nostru este simplu de descris: exista un singur document per utilizator in Firestore, iar acolo se afla soldul de jetoane si lista de deblocari. Regulile de securitate Firestore arata exact asa:

allow read: if request.auth != null && request.auth.uid == uid;
allow write: if false;

Clientul poate citi propriul portofel, dar nu poate scrie absolut nimic. Singura cale prin care soldul se schimba este o functie Cloud (Admin SDK), declansata fie de o cumparatura validata, fie de un milestone in joc, fie de o reclama verificata. Cand vrei sa cheltui jetoane, apelezi o functie spendTokens care, intr-o tranzactie, verifica soldul, refuza daca nu ajunge si scade costul corect din tabelul de preturi de pe server. Clientul trimite doar un id de obiect, niciodata o suma.

De ce conteaza: daca soldul sta in client, orice utilizator poate modifica un numar in memorie sau in localStorage si poate debloca tot continutul gratuit. Cu portofelul pe server, cel mai rau lucru pe care il poate face un client compromis este sa minta despre ce vede pe ecran, nu despre ce detine cu adevarat.

Cumparaturile pe web: RevenueCat Web Billing + Stripe

Pe web nu poti folosi billing-ul nativ Apple sau Google, asa ca pe hub-ul nostru cumparaturile trec prin RevenueCat Web Billing, care prezinta un checkout Stripe gazduit. Codul din browser este deliberat subtire: configureaza RevenueCat pentru uid-ul Firebase al utilizatorului, listeaza pachetele dintr-un offering numit store si prezinta checkout-ul. Atat. Nu atinge niciun sold.

Un detaliu pe care l-am invatat pe drum: app_user_id trebuie sa fie uid-ul Firebase. Clientul apeleaza Purchases.logIn(uid) inainte de cumparatura, altfel RevenueCat genereaza un id anonim de tipul $RCAnonymousID:... si webhook-ul ar credita un portofel pe care aplicatia, care cauta dupa auth.uid, nu il poate citi niciodata. Cheile publice SDK sunt sigure de pus in cod, fiindca o cheie publica nu poate face decat ce poate face un client autentificat oricum, iar portofelul ramane autoritativ pe server.

Webhook-ul: regulile care fac diferenta intre corect si dezastru

Toata greutatea cade pe webhook-ul care primeste evenimentele de la RevenueCat si crediteaza portofelul. Aici sunt patru reguli pe care le respectam strict, fiecare nascuta dintr-un mod concret de a esua.

1. Idempotenta pe identitatea tranzactiei, nu pe id-ul evenimentului

Retrasmiterile sunt normale: daca webhook-ul tau raspunde lent sau cu o eroare, RevenueCat (ca orice sistem serios) reincearca. Daca dedublezi pe id-ul evenimentului, te poti pacali singur, fiindca aceeasi cumparatura poate genera mai multe evenimente. Noi construim cheia de dedublare din identitatea imuabila a tranzactiei plus tipul evenimentului: transaction_id:type. Inainte de a scrie ceva, tranzactia verifica daca exista deja un marcaj cu aceasta cheie si, daca da, nu face nimic.

Esecul prevenit: creditarea dubla. Fara aceasta cheie, o singura retransmisie inseamna ca utilizatorul primeste pachetul de doua ori. Inmultit cu cateva mii de tranzactii, economia jocului devine fictiune.

2. Esueaza inchis pe environment: doar PRODUCTION misca sold real

RevenueCat trimite evenimente atat din PRODUCTION, cat si din SANDBOX (cumparaturi de test, gratuite, repetabile la infinit). Noi creditam portofelul real doar daca environment este exact PRODUCTION. In afara emulatorului, daca environment lipseste sau e altceva, tratam evenimentul ca non-productie si raspundem cu un ack care nu misca nimic.

Exista un singur opt-in temporar, RC_ALLOW_SANDBOX, pe care il folosim in faza de testare cu carduri Stripe de test, si care trebuie sa fie oprit inainte de lansare publica. Daca uiti acel comutator pornit, oricine poate face cumparaturi sandbox gratuite si isi umple portofelul real.

Esecul prevenit: jetoane gratuite la scara. Un sandbox deschis catre productie inseamna ca economia ta plateste pentru carduri care nu au costat nimic.

3. Trateaza refund-urile: debit clamp la zero

Un refund nu vine ca un eveniment separat curat, ci ca o anulare (CANCELLATION) care poarta acelasi product_id ca achizitia. Asta inseamna ca nu poti decide credit vs. revocare dupa produs, ci dupa tipul evenimentului. Cand vine un refund pentru un pachet de jetoane, debitam ce am acordat, dar il limitam la soldul curent, astfel incat un sold deja cheltuit sa nu poata deveni negativ.

Diferenta neacoperita (utilizatorul a cheltuit jetoanele inainte de a cere refund) o inregistram ca owed pentru revizuire de frauda. Este un compromis acceptat si delimitat, nu un clawback dur care ar lasa portofelul cu numere negative bizare.

Esecul prevenit: solduri negative si exploit-uri de tipul cumpara-cheltuie-cere-refund care ar putea sparge logica de cheltuiala in jos.

4. Respinge id-urile care nu sunt uid Firebase

Daca app_user_id incepe cu $RCAnonymousID, contine slash sau e gol, ignoram evenimentul. Un portofel-fantoma creditat pe un id pe care aplicatia nu il poate citi niciodata este pur si simplu bani aruncati si suport client garantat.

RegulaCum o implementamEsecul evitat
IdempotentaCheie transaction_id:type, verificata in tranzactieCreditare dubla la retransmisie
Fail closed pe environmentDoar PRODUCTION crediteaza; sandbox e opt-in temporarJetoane gratuite din sandbox
Refund-uriDebit clamp la zero, restul ca owedSolduri negative / exploit refund
Identitate utilizatorRespinge id-uri non-FirebasePortofele-fantoma necitibile

Pe deasupra, scriem un ledger determinist: cate un rand per tranzactie si tip, cu sursa, suma si tip, ca sa putem reconcilia oricand. Tot ce misca portofelul lasa o urma.

Reclamele recompensate: niciodata nu crede clientul

Pe partea nativa, oferim jetoane pentru vizionarea unei reclame recompensate. Tentatia evidenta este: cand SDK-ul AdMob spune in client ca reclama s-a terminat, crediteaza jetoanele. Nu facem asta niciodata. In schimb folosim Server-Side Verification (SSV) de la AdMob: cand utilizatorul termina reclama, Google (nu clientul) trimite un callback GET semnat catre o functie Cloud a noastra.

Acel callback este semnat cu ECDSA P-256 peste SHA-256. Verificam semnatura impotriva cheilor publice ale Google (cache-uite cu TTL si reincarcate la o cheie necunoscuta) inainte de a misca un singur jeton. Daca semnatura nu e valida, raspundem 403 si nu se intampla nimic.

Trei detalii care inchid restul gaurilor:

Si aici, dedublam pe transaction_id: un replay al aceleiasi recompense deja procesate nu adauga nimic. Inregistram fiecare eveniment, fie ca a fost creditat, fie ca a fost limitat de cooldown sau plafon, pentru audit.

Esecul prevenit: un client modificat care striga ca a vazut o reclama si minteaza jetoane gratuit. Cu SSV, singura sursa care poate misca portofelul este un callback semnat de Google pentru o vizionare reala.

Checklist scurt inainte de lansare

  1. Soldul si deblocarile traiesc pe server; regulile clientului sunt write: false.
  2. Clientul trimite id-uri de produs/obiect, niciodata sume; sumele se cauta in tabele pe server.
  3. Webhook-ul dedubleaza pe identitatea tranzactiei plus tip, intr-o tranzactie de baza de date.
  4. Doar evenimentele PRODUCTION misca sold real; orice comutator de sandbox este oprit.
  5. Refund-urile debiteaza cu clamp la zero si inregistreaza diferenta pentru revizuire.
  6. Reclamele recompensate trec prin SSV semnat, cu suma de server, cooldown si plafon zilnic.
  7. Fiecare miscare lasa un rand in ledger, reconciliabil.
  8. Stergerea contului curata datele de pe server (cerinta Apple si Google Play).

Niciuna dintre aceste reguli nu este complicata individual. Greutatea sta in a le respecta pe toate in acelasi timp, fiindca fiecare gaura inchide un mod diferit de a pierde bani sau de a strica economia jocului. Acesta este, de fapt, intregul mesaj: monetizarea corecta nu inseamna cod sofisticat, inseamna sa nu ai incredere in client niciodata, nicaieri.

Daca construiesti monetizare pentru o aplicatie iOS, Android, Flutter sau web si vrei sa o faci corect din prima, povesteste-ne despre proiect la contact@hashcoreai.com, sau arunca o privire la cum arata in practica in jocurile noastre de pe games.hashcoreai.com.

Citește mai departe