← Volver al listado de tecnologías

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

2. Security

3. Business

4. Technical

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.