Compras In-App para iOS y Android

Por: Artiko
defoldiapcomprasiosandroidmonetización

Compras In-App para iOS y Android

Las compras in-app son una forma crucial de monetización en juegos móviles. Esta guía te enseñará a implementar un sistema completo de IAP para ambas plataformas.

Configuración Base de IAP

1. Extensión y Dependencias

game.project Setup

[project]
dependencies = https://github.com/defold/extension-iap/archive/main.zip

[android]
package = com.miestudio.mijuego
minimum_sdk_version = 21
target_sdk_version = 34

[ios]
bundle_identifier = com.miestudio.mijuego

Permisos Android (AndroidManifest.xml)

<uses-permission android:name="com.android.vending.BILLING" />
<uses-permission android:name="android.permission.INTERNET" />

<application>
    <!-- Google Play Billing -->
    <service
        android:name="com.android.billingclient.api.BillingService"
        android:enabled="true"
        android:exported="false">
        <intent-filter>
            <action android:name="com.android.vending.billing.InAppBillingService.BIND" />
        </intent-filter>
    </service>
</application>

2. IAP Manager Base

Sistema de Gestión de Compras

-- iap_manager.script
local M = {}

function init(self)
    self.initialized = false
    self.products = {}
    self.pending_purchases = {}
    self.purchase_history = {}

    -- Estados de IAP
    self.state = {
        connected = false,
        processing = false,
        last_error = nil
    }

    -- Configuración
    self.config = {
        auto_finish_transactions = true,
        validate_receipts = true,
        retry_failed_purchases = true,
        max_retries = 3
    }

    -- Callbacks
    self.purchase_callbacks = {}
    self.product_callbacks = {}

    print("IAP Manager initialized")
end

function M.initialize(self, product_list, callback)
    if self.initialized then
        if callback then callback(true) end
        return
    end

    -- Configurar callback de IAP
    iap.set_callback(function(self, message_id, message)
        M.handle_iap_callback(self, message_id, message)
    end)

    -- Obtener lista de productos
    iap.list(product_list, function(self, result)
        if result.response == iap.RESPONSE_RESULT_OK then
            self:process_product_list(result.products)
            self.initialized = true
            self.state.connected = true

            print("IAP system initialized successfully")
            if callback then callback(true) end
        else
            print("IAP initialization failed: " .. (result.error or "Unknown error"))
            self.state.last_error = result.error
            if callback then callback(false, result.error) end
        end
    end)
end

function M.process_product_list(self, products)
    for _, product in ipairs(products) do
        self.products[product.ident] = {
            id = product.ident,
            title = product.title,
            description = product.description,
            price = product.price,
            price_string = product.price_string,
            currency_code = product.currency_code,
            type = product.type or "inapp"
        }

        print(string.format("Product loaded: %s - %s (%s)",
              product.ident, product.title, product.price_string))
    end
end

function M.handle_iap_callback(self, message_id, message)
    if message_id == iap.MSG_PURCHASE then
        self:handle_purchase_message(message)
    elseif message_id == iap.MSG_RESTORE then
        self:handle_restore_message(message)
    end
end

function M.handle_purchase_message(self, message)
    local product_id = message.ident
    local callback = self.purchase_callbacks[product_id]

    if message.state == iap.TRANS_STATE_PURCHASING then
        print("Purchase started for: " .. product_id)
        self.state.processing = true

    elseif message.state == iap.TRANS_STATE_PURCHASED then
        print("Purchase completed for: " .. product_id)
        self:process_successful_purchase(message, callback)

    elseif message.state == iap.TRANS_STATE_FAILED then
        print("Purchase failed for: " .. product_id .. " - " .. (message.error or "Unknown"))
        self:process_failed_purchase(message, callback)

    elseif message.state == iap.TRANS_STATE_RESTORED then
        print("Purchase restored for: " .. product_id)
        self:process_restored_purchase(message)
    end
end

return M

Configuración de Productos

1. Product Configuration

Catálogo de Productos

-- product_catalog.lua
local M = {}

M.PRODUCT_TYPES = {
    CONSUMABLE = "consumable",       -- Monedas, power-ups
    NON_CONSUMABLE = "non_consumable", -- Contenido permanente
    SUBSCRIPTION = "subscription"     -- Suscripciones
}

M.PRODUCTS = {
    -- Monedas
    {
        id = "coins_100",
        type = M.PRODUCT_TYPES.CONSUMABLE,
        category = "currency",
        base_price = 0.99,
        reward = {type = "coins", amount = 100},
        bonus = 0,
        popular = false
    },
    {
        id = "coins_500",
        type = M.PRODUCT_TYPES.CONSUMABLE,
        category = "currency",
        base_price = 3.99,
        reward = {type = "coins", amount = 500},
        bonus = 50,  -- 10% bonus
        popular = true
    },
    {
        id = "coins_1000",
        type = M.PRODUCT_TYPES.CONSUMABLE,
        category = "currency",
        base_price = 6.99,
        reward = {type = "coins", amount = 1000},
        bonus = 200, -- 20% bonus
        popular = false
    },

    -- Power-ups
    {
        id = "power_up_bundle",
        type = M.PRODUCT_TYPES.CONSUMABLE,
        category = "power_ups",
        base_price = 1.99,
        reward = {
            type = "bundle",
            items = {
                {type = "power_up", id = "shield", amount = 3},
                {type = "power_up", id = "speed", amount = 3},
                {type = "power_up", id = "magnet", amount = 3}
            }
        },
        popular = false
    },

    -- Contenido permanente
    {
        id = "remove_ads",
        type = M.PRODUCT_TYPES.NON_CONSUMABLE,
        category = "premium",
        base_price = 2.99,
        reward = {type = "feature", id = "no_ads"},
        popular = true
    },
    {
        id = "unlock_all_levels",
        type = M.PRODUCT_TYPES.NON_CONSUMABLE,
        category = "content",
        base_price = 4.99,
        reward = {type = "feature", id = "all_levels"},
        popular = false
    },

    -- Suscripciones
    {
        id = "premium_monthly",
        type = M.PRODUCT_TYPES.SUBSCRIPTION,
        category = "subscription",
        base_price = 4.99,
        period = "monthly",
        reward = {
            type = "subscription_benefits",
            benefits = {
                "no_ads",
                "daily_coins_500",
                "exclusive_skins",
                "double_xp"
            }
        },
        trial_period = 7, -- días
        popular = true
    }
}

function M.get_product_by_id(product_id)
    for _, product in ipairs(M.PRODUCTS) do
        if product.id == product_id then
            return product
        end
    end
    return nil
end

function M.get_products_by_category(category)
    local filtered = {}
    for _, product in ipairs(M.PRODUCTS) do
        if product.category == category then
            table.insert(filtered, product)
        end
    end
    return filtered
end

function M.get_popular_products()
    local popular = {}
    for _, product in ipairs(M.PRODUCTS) do
        if product.popular then
            table.insert(popular, product)
        end
    end
    return popular
end

return M

2. Store UI Manager

Interfaz de Tienda

-- store_ui_manager.gui_script
local product_catalog = require "main.product_catalog"
local iap_manager = require "main.iap_manager"

function init(self)
    self.store_container = gui.get_node("store_container")
    self.product_template = gui.get_node("product_template")
    self.close_button = gui.get_node("close_button")
    self.restore_button = gui.get_node("restore_button")

    -- Ocultar template
    gui.set_enabled(self.product_template, false)

    -- Estado de la tienda
    self.products_ui = {}
    self.selected_product = nil
    self.processing_purchase = false

    -- Crear UI de productos
    self:create_products_ui()

    -- Verificar compras restauradas
    self:check_owned_products()
end

function create_products_ui(self)
    local categories = {"currency", "power_ups", "premium", "subscription"}
    local y_offset = 0

    for _, category in ipairs(categories) do
        local products = product_catalog.get_products_by_category(category)

        if #products > 0 then
            -- Crear header de categoría
            y_offset = y_offset - 60
            self:create_category_header(category, y_offset)

            -- Crear productos
            for _, product in ipairs(products) do
                y_offset = y_offset - 120
                self:create_product_ui(product, y_offset)
            end
        end
    end
end

function create_product_ui(self, product, y_position)
    local product_node = gui.clone(self.product_template)
    gui.set_enabled(product_node, true)
    gui.set_parent(product_node, self.store_container)
    gui.set_position(product_node, vmath.vector3(0, y_position, 0))

    -- Configurar elementos del producto
    local title_node = gui.get_node("product_title", product_node)
    local desc_node = gui.get_node("product_description", product_node)
    local price_node = gui.get_node("product_price", product_node)
    local buy_button = gui.get_node("buy_button", product_node)
    local popular_badge = gui.get_node("popular_badge", product_node)

    -- Configurar textos
    gui.set_text(title_node, product.title or product.id)
    gui.set_text(desc_node, self:get_product_description(product))

    -- Configurar precio
    local iap_product = iap_manager:get_product(product.id)
    if iap_product then
        gui.set_text(price_node, iap_product.price_string)
    else
        gui.set_text(price_node, "$" .. product.base_price)
    end

    -- Mostrar badge de popular
    gui.set_enabled(popular_badge, product.popular or false)

    -- Configurar botón
    local owned = self:is_product_owned(product.id)
    if owned and product.type == product_catalog.PRODUCT_TYPES.NON_CONSUMABLE then
        gui.set_text(buy_button, "OWNED")
        gui.set_enabled(buy_button, false)
    else
        gui.set_text(buy_button, "BUY")
    end

    self.products_ui[product.id] = {
        node = product_node,
        product = product,
        buy_button = buy_button,
        price_node = price_node
    }
end

function get_product_description(self, product)
    if product.reward.type == "coins" then
        local total = product.reward.amount + (product.bonus or 0)
        if product.bonus and product.bonus > 0 then
            return string.format("%d coins (+%d bonus!)", product.reward.amount, product.bonus)
        else
            return string.format("%d coins", total)
        end

    elseif product.reward.type == "bundle" then
        return "Power-up bundle with shields, speed boost and coin magnet"

    elseif product.reward.type == "feature" then
        if product.reward.id == "no_ads" then
            return "Remove all advertisements permanently"
        elseif product.reward.id == "all_levels" then
            return "Unlock all levels and content"
        end

    elseif product.reward.type == "subscription_benefits" then
        return "Premium benefits: No ads, daily coins, exclusive content"
    end

    return product.description or "Premium content"
end

function on_input(self, action_id, action)
    if action_id == hash("touch") and action.pressed then
        -- Botón cerrar
        if gui.pick_node(self.close_button, action.x, action.y) then
            msg.post("main:/ui", "close_store")
            return true

        -- Botón restaurar
        elseif gui.pick_node(self.restore_button, action.x, action.y) then
            self:restore_purchases()
            return true

        -- Botones de compra
        else
            for product_id, ui_data in pairs(self.products_ui) do
                if gui.pick_node(ui_data.buy_button, action.x, action.y) then
                    self:purchase_product(product_id)
                    return true
                end
            end
        end
    end

    return false
end

function purchase_product(self, product_id)
    if self.processing_purchase then
        print("Purchase already in progress")
        return
    end

    local product = product_catalog.get_product_by_id(product_id)
    if not product then
        print("Product not found: " .. product_id)
        return
    end

    print("Initiating purchase for: " .. product_id)
    self.processing_purchase = true
    self.selected_product = product

    -- Mostrar loading
    self:show_purchase_loading(true)

    -- Realizar compra
    iap_manager:purchase_product(product_id, function(success, error, receipt)
        self:handle_purchase_result(product_id, success, error, receipt)
    end)
end

function handle_purchase_result(self, product_id, success, error, receipt)
    self.processing_purchase = false
    self:show_purchase_loading(false)

    if success then
        print("Purchase successful: " .. product_id)
        self:show_purchase_success(product_id)
        self:grant_product_reward(product_id, receipt)
    else
        print("Purchase failed: " .. (error or "Unknown error"))
        self:show_purchase_error(error)
    end

    self.selected_product = nil
end

function grant_product_reward(self, product_id, receipt)
    local product = product_catalog.get_product_by_id(product_id)
    if not product then return end

    local reward = product.reward

    if reward.type == "coins" then
        local total_coins = reward.amount + (product.bonus or 0)
        msg.post("main:/game_state", "add_coins", {amount = total_coins})

    elseif reward.type == "bundle" then
        for _, item in ipairs(reward.items) do
            msg.post("main:/game_state", "add_item", item)
        end

    elseif reward.type == "feature" then
        msg.post("main:/game_state", "unlock_feature", {feature = reward.id})

    elseif reward.type == "subscription_benefits" then
        msg.post("main:/game_state", "activate_subscription", {
            type = "premium",
            benefits = reward.benefits
        })
    end

    -- Registrar compra
    self:record_purchase(product_id, receipt)
end

return M

Validación de Recibos

1. Receipt Validation

Sistema de Validación

-- receipt_validator.lua
local M = {}

function init(self)
    self.validation_endpoint = sys.get_config("iap.validation_url", "")
    self.app_secret = sys.get_config("iap.app_secret", "")
    self.pending_validations = {}
end

function M.validate_receipt(self, receipt_data, product_id, callback)
    if not self.validation_endpoint or self.validation_endpoint == "" then
        print("No validation endpoint configured - skipping validation")
        if callback then callback(true, "validation_skipped") end
        return
    end

    local validation_id = self:generate_validation_id()
    self.pending_validations[validation_id] = {
        callback = callback,
        product_id = product_id,
        timestamp = socket.gettime()
    }

    -- Preparar datos para validación
    local platform = sys.get_sys_info().system_name
    local validation_data = {
        platform = platform == "iPhone OS" and "ios" or "android",
        receipt = receipt_data,
        product_id = product_id,
        app_secret = self.app_secret,
        validation_id = validation_id
    }

    -- Enviar al servidor
    self:send_validation_request(validation_data)
end

function send_validation_request(self, data)
    local headers = {
        ["Content-Type"] = "application/json",
        ["User-Agent"] = "DefoldGame/1.0"
    }

    local json_data = json.encode(data)

    http.request(self.validation_endpoint, "POST", function(self, id, response)
        self:handle_validation_response(data.validation_id, response)
    end, headers, json_data)
end

function handle_validation_response(self, validation_id, response)
    local validation = self.pending_validations[validation_id]
    if not validation then
        print("Validation response for unknown ID: " .. validation_id)
        return
    end

    local success = false
    local result = "validation_failed"

    if response.status == 200 then
        local ok, decoded = pcall(json.decode, response.response)
        if ok and decoded then
            success = decoded.valid == true
            result = decoded.result or "validation_completed"

            if success then
                print("Receipt validation successful for: " .. validation.product_id)
            else
                print("Receipt validation failed: " .. (decoded.error or "Invalid receipt"))
            end
        else
            print("Invalid JSON response from validation server")
        end
    else
        print("Validation server error: " .. response.status)
        result = "server_error"
    end

    -- Ejecutar callback
    if validation.callback then
        validation.callback(success, result)
    end

    self.pending_validations[validation_id] = nil
end

function generate_validation_id(self)
    return "val_" .. os.time() .. "_" .. math.random(10000, 99999)
end

-- Validación local para iOS (básica)
function M.validate_ios_receipt_local(receipt_data)
    -- Verificaciones básicas del formato
    if not receipt_data or receipt_data == "" then
        return false, "empty_receipt"
    end

    -- Verificar que es base64 válido
    local decoded = base64.decode(receipt_data)
    if not decoded then
        return false, "invalid_base64"
    end

    -- Verificaciones adicionales se harían en el servidor
    return true, "local_validation_passed"
end

-- Validación local para Android
function M.validate_android_receipt_local(receipt_data, signature)
    if not receipt_data or not signature then
        return false, "missing_data"
    end

    -- Verificar formato JSON
    local ok, decoded = pcall(json.decode, receipt_data)
    if not ok then
        return false, "invalid_json"
    end

    -- Verificaciones adicionales se harían en el servidor
    return true, "local_validation_passed"
end

return M

2. Server-Side Validation (Node.js Example)

Servidor de Validación

// validation_server.js
const express = require('express');
const https = require('https');
const {google} = require('googleapis');

const app = express();
app.use(express.json());

// Configuración
const APP_SECRET = process.env.APP_SECRET;
const GOOGLE_SERVICE_ACCOUNT = process.env.GOOGLE_SERVICE_ACCOUNT;

// Validación de recibos iOS
async function validateIOSReceipt(receiptData) {
    const postData = JSON.stringify({
        'receipt-data': receiptData,
        'password': APP_SECRET, // App-specific shared secret
        'exclude-old-transactions': true
    });

    // Primero intentar con producción
    let result = await makeIOSRequest('https://buy.itunes.apple.com/verifyReceipt', postData);

    // Si falla con código 21007, intentar con sandbox
    if (result.status === 21007) {
        result = await makeIOSRequest('https://sandbox.itunes.apple.com/verifyReceipt', postData);
    }

    return {
        valid: result.status === 0,
        result: result,
        environment: result.environment
    };
}

// Validación de recibos Android
async function validateAndroidReceipt(packageName, productId, purchaseToken) {
    try {
        const auth = new google.auth.GoogleAuth({
            keyFile: GOOGLE_SERVICE_ACCOUNT,
            scopes: ['https://www.googleapis.com/auth/androidpublisher']
        });

        const androidpublisher = google.androidpublisher({
            version: 'v3',
            auth: auth
        });

        const result = await androidpublisher.purchases.products.get({
            packageName: packageName,
            productId: productId,
            token: purchaseToken
        });

        return {
            valid: result.data.purchaseState === 0, // 0 = purchased
            result: result.data
        };
    } catch (error) {
        console.error('Android validation error:', error);
        return {
            valid: false,
            error: error.message
        };
    }
}

// Endpoint de validación
app.post('/validate', async (req, res) => {
    const {platform, receipt, product_id, app_secret, validation_id} = req.body;

    // Verificar app secret
    if (app_secret !== APP_SECRET) {
        return res.status(401).json({
            valid: false,
            error: 'Invalid app secret'
        });
    }

    try {
        let validation_result;

        if (platform === 'ios') {
            validation_result = await validateIOSReceipt(receipt);
        } else if (platform === 'android') {
            const {packageName, purchaseToken} = JSON.parse(receipt);
            validation_result = await validateAndroidReceipt(packageName, product_id, purchaseToken);
        } else {
            return res.status(400).json({
                valid: false,
                error: 'Unsupported platform'
            });
        }

        res.json({
            valid: validation_result.valid,
            result: validation_result.result,
            validation_id: validation_id
        });

    } catch (error) {
        console.error('Validation error:', error);
        res.status(500).json({
            valid: false,
            error: 'Validation failed'
        });
    }
});

function makeIOSRequest(url, postData) {
    return new Promise((resolve, reject) => {
        const options = {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'Content-Length': Buffer.byteLength(postData)
            }
        };

        const req = https.request(url, options, (res) => {
            let data = '';
            res.on('data', (chunk) => data += chunk);
            res.on('end', () => {
                try {
                    resolve(JSON.parse(data));
                } catch (error) {
                    reject(error);
                }
            });
        });

        req.on('error', reject);
        req.write(postData);
        req.end();
    });
}

app.listen(3000, () => {
    console.log('Validation server running on port 3000');
});

Purchase Analytics y Tracking

1. Purchase Analytics

Sistema de Analytics

-- purchase_analytics.lua
local M = {}

function init(self)
    self.purchase_events = {}
    self.revenue_tracking = {
        daily_revenue = 0,
        total_revenue = 0,
        purchases_count = 0,
        conversion_funnel = {
            store_visits = 0,
            product_views = 0,
            purchase_attempts = 0,
            successful_purchases = 0
        }
    }
end

function M.track_store_visit(self)
    self.revenue_tracking.conversion_funnel.store_visits =
        self.revenue_tracking.conversion_funnel.store_visits + 1

    self:send_analytics_event("store_visit", {
        timestamp = os.time(),
        session_id = self:get_session_id()
    })
end

function M.track_product_view(self, product_id)
    self.revenue_tracking.conversion_funnel.product_views =
        self.revenue_tracking.conversion_funnel.product_views + 1

    self:send_analytics_event("product_view", {
        product_id = product_id,
        timestamp = os.time()
    })
end

function M.track_purchase_attempt(self, product_id, price)
    self.revenue_tracking.conversion_funnel.purchase_attempts =
        self.revenue_tracking.conversion_funnel.purchase_attempts + 1

    self:send_analytics_event("purchase_attempt", {
        product_id = product_id,
        price = price,
        timestamp = os.time()
    })
end

function M.track_successful_purchase(self, product_id, price, currency, receipt)
    self.revenue_tracking.conversion_funnel.successful_purchases =
        self.revenue_tracking.conversion_funnel.successful_purchases + 1

    self.revenue_tracking.purchases_count = self.revenue_tracking.purchases_count + 1
    self.revenue_tracking.total_revenue = self.revenue_tracking.total_revenue + price
    self.revenue_tracking.daily_revenue = self.revenue_tracking.daily_revenue + price

    -- Enviar a múltiples plataformas de analytics
    self:send_purchase_events(product_id, price, currency, receipt)
end

function send_purchase_events(self, product_id, price, currency, receipt)
    -- Firebase Analytics
    self:send_analytics_event("purchase", {
        currency = currency,
        value = price,
        item_id = product_id,
        item_category = "iap"
    })

    -- Facebook Analytics
    msg.post("main:/facebook", "track_purchase", {
        amount = price,
        currency = currency,
        content_type = "product",
        content_id = product_id
    })

    -- AppsFlyer
    msg.post("main:/appsflyer", "track_purchase", {
        revenue = price,
        currency = currency,
        product_id = product_id,
        receipt_id = receipt
    })

    -- Adjust
    msg.post("main:/adjust", "track_revenue", {
        amount = price,
        currency = currency,
        product_id = product_id
    })
end

function M.get_conversion_rates(self)
    local funnel = self.revenue_tracking.conversion_funnel

    return {
        store_to_product = funnel.product_views / math.max(1, funnel.store_visits),
        product_to_attempt = funnel.purchase_attempts / math.max(1, funnel.product_views),
        attempt_to_purchase = funnel.successful_purchases / math.max(1, funnel.purchase_attempts),
        overall_conversion = funnel.successful_purchases / math.max(1, funnel.store_visits)
    }
end

function send_analytics_event(self, event_name, properties)
    -- Enviar a sistema de analytics principal
    msg.post("main:/analytics", "track_event", {
        event = event_name,
        properties = properties
    })
end

function get_session_id(self)
    return "session_" .. os.time()
end

return M

Subscription Management

1. Subscription Handler

Sistema de Suscripciones

-- subscription_manager.lua
local M = {}

function init(self)
    self.active_subscriptions = {}
    self.subscription_status = {}
    self.validation_interval = 24 * 60 * 60  -- Validar cada 24 horas
end

function M.handle_subscription_purchase(self, product_id, receipt_data)
    local subscription_data = {
        product_id = product_id,
        purchase_date = os.time(),
        receipt_data = receipt_data,
        status = "active",
        last_validated = os.time()
    }

    self.active_subscriptions[product_id] = subscription_data

    -- Activar beneficios
    self:activate_subscription_benefits(product_id)

    -- Programar validación periódica
    self:schedule_subscription_validation(product_id)

    print("Subscription activated: " .. product_id)
end

function activate_subscription_benefits(self, product_id)
    local product = product_catalog.get_product_by_id(product_id)
    if not product or not product.reward.benefits then return end

    for _, benefit in ipairs(product.reward.benefits) do
        if benefit == "no_ads" then
            msg.post("main:/game_state", "disable_ads")
        elseif benefit == "daily_coins_500" then
            msg.post("main:/game_state", "enable_daily_coins", {amount = 500})
        elseif benefit == "exclusive_skins" then
            msg.post("main:/game_state", "unlock_exclusive_content", {type = "skins"})
        elseif benefit == "double_xp" then
            msg.post("main:/game_state", "enable_xp_multiplier", {multiplier = 2.0})
        end
    end
end

function schedule_subscription_validation(self, product_id)
    timer.delay(self.validation_interval, true, function()
        self:validate_subscription_status(product_id)
    end)
end

function validate_subscription_status(self, product_id)
    local subscription = self.active_subscriptions[product_id]
    if not subscription then return end

    -- Validar con las tiendas
    local receipt_validator = require "main.receipt_validator"
    receipt_validator:validate_receipt(subscription.receipt_data, product_id,
        function(valid, result)
            if valid then
                subscription.last_validated = os.time()
                subscription.status = "active"
                print("Subscription validated: " .. product_id)
            else
                subscription.status = "expired"
                self:handle_subscription_expiry(product_id)
                print("Subscription expired: " .. product_id)
            end
        end)
end

function handle_subscription_expiry(self, product_id)
    -- Desactivar beneficios
    self:deactivate_subscription_benefits(product_id)

    -- Notificar al usuario
    msg.post("main:/ui", "show_subscription_expired", {product_id = product_id})

    -- Remover de activas
    self.active_subscriptions[product_id] = nil
end

function deactivate_subscription_benefits(self, product_id)
    local product = product_catalog.get_product_by_id(product_id)
    if not product or not product.reward.benefits then return end

    for _, benefit in ipairs(product.reward.benefits) do
        if benefit == "no_ads" then
            msg.post("main:/game_state", "enable_ads")
        elseif benefit == "daily_coins_500" then
            msg.post("main:/game_state", "disable_daily_coins")
        elseif benefit == "double_xp" then
            msg.post("main:/game_state", "disable_xp_multiplier")
        end
    end
end

function M.is_subscription_active(self, product_id)
    local subscription = self.active_subscriptions[product_id]
    return subscription and subscription.status == "active"
end

return M

Security y Anti-Fraud

1. Fraud Detection

Sistema Anti-Fraude

-- fraud_detector.lua
local M = {}

function init(self)
    self.fraud_signals = {}
    self.device_fingerprint = self:generate_device_fingerprint()
    self.purchase_patterns = {}
    self.risk_score = 0
end

function M.analyze_purchase_risk(self, product_id, price, user_data)
    local risk_factors = {}

    -- Factor 1: Velocidad de compras
    local purchase_velocity = self:calculate_purchase_velocity()
    if purchase_velocity > 5 then  -- Más de 5 compras en 1 hora
        table.insert(risk_factors, "high_velocity")
    end

    -- Factor 2: Patrón de precios
    local price_pattern = self:analyze_price_pattern(price)
    if price_pattern == "suspicious" then
        table.insert(risk_factors, "price_pattern")
    end

    -- Factor 3: Geolocalización (si está disponible)
    local geo_risk = self:check_geo_risk(user_data.country)
    if geo_risk then
        table.insert(risk_factors, "geo_risk")
    end

    -- Factor 4: Device fingerprint
    if self:is_device_suspicious() then
        table.insert(risk_factors, "device_risk")
    end

    -- Calcular score de riesgo
    local risk_score = #risk_factors * 0.25
    self.risk_score = math.min(1.0, risk_score)

    return {
        risk_score = self.risk_score,
        risk_factors = risk_factors,
        recommendation = self:get_risk_recommendation(self.risk_score)
    }
end

function calculate_purchase_velocity(self)
    local current_time = os.time()
    local recent_purchases = 0

    for _, purchase in ipairs(self.purchase_patterns) do
        if current_time - purchase.timestamp < 3600 then  -- Última hora
            recent_purchases = recent_purchases + 1
        end
    end

    return recent_purchases
end

function analyze_price_pattern(self, price)
    -- Detectar patrones sospechosos como:
    -- - Solo compras de productos de alto valor
    -- - Compras inmediatamente después de install
    -- - Compras repetidas del mismo producto

    local high_value_threshold = 10.0
    local recent_high_value = 0

    for _, purchase in ipairs(self.purchase_patterns) do
        if purchase.price >= high_value_threshold then
            recent_high_value = recent_high_value + 1
        end
    end

    if recent_high_value >= 3 and #self.purchase_patterns <= 5 then
        return "suspicious"
    end

    return "normal"
end

function check_geo_risk(self, country)
    -- Lista de países con alto riesgo de fraude
    local high_risk_countries = {
        "XX", "YY", "ZZ"  -- Códigos de país de ejemplo
    }

    for _, risk_country in ipairs(high_risk_countries) do
        if country == risk_country then
            return true
        end
    end

    return false
end

function is_device_suspicious(self)
    local sys_info = sys.get_sys_info()

    -- Verificar si es emulador
    if sys_info.device_model and string.find(sys_info.device_model, "Emulator") then
        return true
    end

    -- Verificar root/jailbreak (requiere extensión nativa)
    if sys_info.system_name == "Android" then
        -- Verificar indicadores de root
        local potential_root = sys_info.device_model == "sdk" or
                              sys_info.manufacturer == "Genymotion"
        if potential_root then
            return true
        end
    end

    return false
end

function get_risk_recommendation(self, risk_score)
    if risk_score < 0.3 then
        return "allow"
    elseif risk_score < 0.7 then
        return "review"
    else
        return "block"
    end
end

function generate_device_fingerprint(self)
    local sys_info = sys.get_sys_info()

    return {
        device_model = sys_info.device_model,
        manufacturer = sys_info.manufacturer,
        system_version = sys_info.system_version,
        language = sys_info.language,
        timezone = sys_info.timezone
    }
end

function M.record_purchase_attempt(self, product_id, price, result)
    table.insert(self.purchase_patterns, {
        product_id = product_id,
        price = price,
        result = result,
        timestamp = os.time()
    })

    -- Mantener solo los últimos 50 registros
    if #self.purchase_patterns > 50 then
        table.remove(self.purchase_patterns, 1)
    end
end

return M

Testing y Debug

1. IAP Debug Tools

Debug Console

-- iap_debug.gui_script
function init(self)
    self.debug_enabled = sys.get_config("project.debug", "0") == "1"

    if self.debug_enabled then
        self:create_debug_ui()
        self:setup_test_products()
    end
end

function create_debug_ui(self)
    -- Panel de debug
    self.debug_panel = gui.get_node("debug_panel")
    self.product_list = gui.get_node("product_list")
    self.test_buttons = {
        purchase = gui.get_node("test_purchase"),
        restore = gui.get_node("test_restore"),
        validate = gui.get_node("test_validate")
    }

    self:update_debug_display()
end

function setup_test_products(self)
    -- Productos de test para debug
    self.test_products = {
        "android.test.purchased",
        "android.test.canceled",
        "android.test.refunded",
        "android.test.item_unavailable"
    }
end

function on_input(self, action_id, action)
    if not self.debug_enabled or not action.pressed then
        return false
    end

    if action_id == hash("touch") then
        if gui.pick_node(self.test_buttons.purchase, action.x, action.y) then
            self:test_purchase()
            return true

        elseif gui.pick_node(self.test_buttons.restore, action.x, action.y) then
            self:test_restore()
            return true

        elseif gui.pick_node(self.test_buttons.validate, action.x, action.y) then
            self:test_validation()
            return true
        end
    end

    return false
end

function test_purchase(self)
    local test_product = self.test_products[1]  -- android.test.purchased
    print("Testing purchase: " .. test_product)

    local iap_manager = require "main.iap_manager"
    iap_manager:purchase_product(test_product, function(success, error, receipt)
        print("Test purchase result: " .. tostring(success))
        if error then
            print("Test purchase error: " .. error)
        end
    end)
end

function test_restore(self)
    print("Testing restore purchases")
    iap.restore()
end

function test_validation(self)
    print("Testing receipt validation")
    -- Simular validación con datos de test
    local receipt_validator = require "main.receipt_validator"
    receipt_validator:validate_receipt("test_receipt_data", "test_product",
        function(success, result)
            print("Test validation result: " .. tostring(success))
            print("Test validation details: " .. (result or "none"))
        end)
end

return M

Mejores Prácticas

1. User Experience

  • Mostrar precios claramente en moneda local
  • Explicar el valor de cada compra
  • Confirmar compras antes de procesar
  • Manejar errores gracefully
  • Providir restore button visible

2. Security

  • Validar todos los recibos en servidor
  • Implementar detección de fraude
  • Nunca confiar en validación local únicamente
  • Usar HTTPS para todas las comunicaciones
  • Proteger secrets y API keys

3. Business

  • Ofrecer variety de precios
  • Implementar analytics detallados
  • A/B test diferentes precios
  • Monitorear conversion rates
  • Optimizar timing de ofertas

4. Technical

  • Manejar network errors
  • Implementar retry logic
  • Cache product information
  • Handle subscription renewals
  • Test en multiple devices

Esta implementación completa te proporciona un sistema robusto de IAP que maneja todos los aspectos críticos de las compras in-app para dispositivos móviles.