Cargando…

Esto está tardando más de lo esperado.

Volver al centro de ayuda

Monedas, intervalos y tipos de cambio

Los ciclos de cobro que ofreces, la moneda que usa cada precio y cada reporte, la sincronización diaria de tipos de cambio detrás de la gráfica de ingresos y los interruptores que apagan el paquete.

Cada precio que creas en el panel de administración tiene un país, una moneda y un monto por intervalo de cobro. Esta guía cubre config/pricing.php, que decide los intervalos y la moneda en que se expresan los reportes; cómo un visitante termina viendo un precio y no otro; cómo los ingresos en varias monedas se traen de vuelta a una sola; y cómo se apaga el paquete completo. Crear los precios en sí está en Crea y ponle precio a un plan; llaves, banderas y webhook en Enciende planes y facturación.

Intervalos

config/pricing.php trae dos:

'intervals' => [
    'monthly' => [
        'interval' => 'month',
        'label'    => 'Monthly',
        'count'    => 1,
        'active'   => 1,
    ],
    'yearly' => [
        'interval' => 'year',
        'label'    => 'Yearly',
        'count'    => 1,
        'active'   => 1,
    ],
],
Clave Qué es
La clave del arreglo (monthly) Tu nombre interno. Se guarda en cada variación de precio como interval y nunca se envía a Stripe.
interval El intervalo recurrente de Stripe, month o year.
count Cuántos de ellos por ciclo: el interval_count de Stripe.
label El encabezado de la columna en el formulario de administración y la pastilla en la página de precios, pasado por __(), así que tradúcelo en lang/.
active 1 muestra el intervalo en las páginas de planes y complementos; 0 lo oculta ahí mientras el formulario de administración conserva su columna y los suscriptores existentes siguen pagando.

La primera entrada es la que va por omisión: el catálogo público de complementos abre en la primera activa, y getPriceFor($country) en un plan o complemento devuelve su variación para ella. La página de planes abre en el intervalo que la cuenta ya paga, o en la clave monthly.

Agregar una entrada agrega una columna a la tarjeta Pricing de todos los formularios de plan y complemento, sin cambiar código. Cada monto que escribes ahí se convierte en un precio de Stripe con ese interval e interval_count. Estas son las formas que el propio archivo sugiere:

'quarterly'  => ['interval' => 'month', 'label' => 'Quarterly',  'count' => 3, 'active' => 1],
'semiannual' => ['interval' => 'month', 'label' => 'Semiannual', 'count' => 6, 'active' => 1],
'biannual'   => ['interval' => 'year',  'label' => 'Biannual',   'count' => 2, 'active' => 1],

Quitar una entrada no borra nada: las filas y los precios de Stripe se quedan, solo desaparecen del formulario, y un guardado los omite. Para retirar un intervalo que tiene suscriptores, pon active en 0 en lugar de borrar la clave.

Un suscriptor que se mueve a una variación con distinto interval o count hace un cambio de ciclo. El kit compara los valores de Stripe, no tus claves, actualiza todas las líneas de la suscripción mediante la API de Stripe con proration_behavior en always_invoice, para que la diferencia se cobre de inmediato, y payment_behavior en error_if_incomplete, para que falle en lugar de dejar una factura sin pagar cuando no hay tarjeta registrada. Cada complemento de la suscripción debe tener un precio en el nuevo intervalo y en la misma moneda, o el cambio se rechaza con "Addon ":name" is not available for this billing interval."

Qué precio ve una persona

Una fila de precio es un país más una moneda. El select de país de la tarjeta Pricing ofrece All, guardado como default, y veinticuatro países; el select de moneda ofrece veinte monedas, de usd y mxn a jpy y sgd. En cuanto una fila tiene un monto guardado, ambos selects se bloquean, porque los precios de Stripe ya los llevan.

El país de quien mira es el country_code del propio usuario, luego lo que encuentre App\Services\CountryDetectionService::detect(), luego default. En la página de inicio pública solo corre la detección. Con ese país:

  • La página de planes toma, para cada plan, la variación de ese país en el intervalo seleccionado, y cae en la fila default. Un plan con precio solo para mx no se muestra a un visitante de otro lugar.
  • El catálogo de complementos hace lo mismo por complemento. Un complemento sin precio en el intervalo seleccionado muestra Not available.
  • En el registro, el usuario nuevo se suscribe al plan gratuito de su país: la variación de plan con monto 0 para ese país, o para default. Exactamente un plan debe calificar; con dos planes gratuitos nadie se suscribe automáticamente.
  • Un usuario en un plan gratuito con precio de otro país se mueve cuando abre la página de planes: la suscripción actual se cancela al momento y se crea una nueva en el plan gratuito de su propio país.

Stripe cobra cada suscripción en la moneda de sus precios. El kit nunca mezcla monedas en una suscripción: cuando un plan cambia, cada línea de complemento se reemplaza por su variación en la misma moneda e intervalo, y una migración masiva entre planes solo arrastra los complementos que coinciden con la moneda del destino.

El monto se muestra con un signo de dólar fijo sea cual sea la moneda: la tarjeta de plan muestra $499 seguido del código, MXN, la tarjeta de complemento $49.00, y el helper money_format() en app/Helpers/base.php devuelve '$' . number_format($value, 2) sin argumento de moneda. Se usa para la columna de precio en el panel de administración y para las recompensas de referidos. Cambia el helper cuando tu moneda necesite otro símbolo.

Stripe recibe unit_amount como el monto multiplicado por 100 y redondeado, para todas las monedas. Eso es correcto para monedas de dos decimales e incorrecto para las de cero decimales: jpy y clp están en la lista, pero un precio de 500 llegaría a Stripe como 50000. No pongas precios en una moneda de cero decimales sin cambiar createPrice() en WeblaborMx\BillingCore\Services\StripeSyncService.

Dos monedas que no son lo mismo

Ajuste Dónde Valor por omisión Qué decide
primary_currency config/pricing.php, un literal committeado, sin variable de entorno mxn La base de cada captura de tipo de cambio y la moneda en que se expresa la gráfica de ingresos. No cobra nada.
CASHIER_CURRENCY vendor/laravel/cashier/config/cashier.php, leída de .env usd La moneda propia de Cashier: la que usa para cobros únicos mediante checkoutCharge(), para items de factura, y como respaldo cuando un monto se formatea sin moneda propia.

Los precios de suscripción no usan ninguna de las dos: cada precio de Stripe lleva su moneda desde el formulario de administración, y las facturas listadas en /account/billing se formatean en la moneda de la propia factura. Así que un proyecto que vende en pesos cambia el literal para mantener sus reportes en pesos, y pone CASHIER_CURRENCY=mxn solo si además recibe pagos únicos, cubiertos en Cobra una vez con Stripe Checkout. .env.example no lista ninguna de las dos.

Tipos de cambio

Los ingresos llegan en la moneda que paga cada suscriptor. Para sumarlos, el kit guarda un tipo de cambio por día, por moneda, de primary_currency a esa moneda:

'exchange_rates' => [
    'provider' => 'frankfurter',
    'endpoint' => 'https://api.frankfurter.app',
    'timeout'  => 10,
],

frankfurter es el único proveedor implementado; cualquier otro valor hace que la sincronización falle con "Unsupported exchange-rate provider". La petición es GET {endpoint}/{date}?from=MXN&to=USD,EUR, con el timeout en segundos. Una respuesta distinta de 200, o una respuesta a la que le falte alguna de las monedas pedidas, detiene la sincronización con un error y no guarda nada.

Las filas caen en currency_exchange_rates: date, base_currency, quote_currency, rate con ocho decimales, provider y un payload con la fecha, el monto y la base del propio proveedor. Una fila por fecha, base, moneda cotizada y proveedor; sincronizar el mismo día otra vez la actualiza. El modelo es WeblaborMx\BillingCore\Models\CurrencyExchangeRate.

Solo se consultan las monedas que realmente se pagaron. Para una fecha dada, el servicio mira los webhooks invoice.paid registrados ese día, en App\Models\WebhookEvent, con estado success, un amount_paid mayor que cero y una suscripción; las monedas distintas de esas facturas, menos la principal, son las que se piden. Un proyecto en el que todos los suscriptores pagan en la moneda principal nunca guarda un tipo de cambio ni llama al proveedor.

El comando

php artisan billing:sync-exchange-rates
php artisan billing:sync-exchange-rates --date=2026-08-15

Sin opciones encuentra cada fecha, desde el primer webhook, que tiene una factura de suscripción pagada en una moneda secundaria y ningún tipo de cambio guardado para ella, y consulta lo que falta. Imprime "No secondary-currency paid subscription dates found. Nothing to sync." o "All paid subscription exchange rates are already synced." cuando no hay nada que hacer, y si no, una línea por tipo de cambio, MXN → USD: 0.0546. Con --date hace lo mismo para ese único día, que es como rellenas un día en que el proveedor estuvo caído.

El paquete lo programa a diario a las 12:00, en la zona horaria de la aplicación, siempre que FEATURE_PLANS_ENABLED o FEATURE_ADDONS_ENABLED esté encendida. Solo corre si el scheduler corre; revisa Colas y trabajo programado.

Ingresos en el dashboard

El dashboard de administración en /admin gana una métrica Daily Revenue cuando el paquete está encendido, etiquetada con la moneda principal, y Subscriptions Created y Total Accumulated Subscriptions cuando los planes están activos. Daily Revenue suma las mismas facturas pagadas, amount_paid dividido entre 100, por día, semana o mes. Una factura en la moneda principal cuenta tal cual. Cualquier otra se divide entre el tipo de cambio de ese día, ya que el tipo de cambio dice cuántas unidades de la moneda pagada compra una unidad de la principal. Una factura cuyo día no tiene tipo de cambio para su moneda cuenta como cero, en silencio. Cuando la gráfica se vea baja, corre el comando para esa fecha.

Apagar el paquete

Variable Clave de configuración Valor por omisión Qué hace false
BILLING_CORE_ENABLED billing-core.enabled true El proveedor se detiene al arrancar: sin configuración de Cashier, sin modelos observados, sin recursos de administración, vistas, migraciones, comando ni programación, sin rutas, sin componentes Livewire, sin políticas, sin adaptadores.
BILLING_USER_ENABLED billing-core.user_adapter.enabled true Solo se va el adaptador de usuario: las rutas de facturación /account/*, el listener que suscribe al usuario nuevo al plan gratuito y el middleware ensure.subscribed. Las tablas y los recursos de administración se quedan para otro adaptador.
BILLING_SUITE_ENABLED billing-core.adapters.suite.enabled no se lee El ejemplo comentado bajo adapters en config/billing-core.php. Nada lo lee hasta que descomentas la entrada y escribes la clase que nombra.

El host nunca importa una clase del paquete directamente. app/Helpers/base.php provee los puentes, y cada uno responde null o false cuando el paquete está apagado o ausente:

Helper Responde
billingCoreAvailable() true cuando la clase del proveedor existe y billing-core.enabled está encendida. Antes de que se cargue la configuración lee BILLING_CORE_ENABLED directamente.
billingUserBillingAvailable() Lo mismo más user_adapter.enabled, o BILLING_USER_ENABLED antes de la configuración.
billingCoreModel('plan') La clase del modelo para una clave de models en config/billing-core.php, o null.
billingCoreManager() La instancia de BillingManager, o null.
billingUser() El recurso de facturación de usuario que usa el formulario de usuario en administración, o null.
billingDashboardMetrics() El servicio de métricas del dashboard, o null, que es lo que quita la métrica de ingresos.

bootstrap/app.php agrega ensure.subscribed al grupo de rutas /app, y registra el alias siquiera, solo cuando billingUserBillingAvailable() es verdadero. routes/api.php mantiene registrado POST /stripe/webhook y responde 204 No Content cuando billingCoreAvailable() es falso, para que Stripe deje de reintentar. El trait HasPlans del usuario devuelve relaciones vacías y resultados null por la misma razón.

Para probar que la aplicación arranca sin el paquete:

composer test-without-billing-core

Copia el repositorio a un directorio temporal, lo apunta a un archivo SQLite nuevo, quita ahí weblabormx/billing-core y su repositorio de tipo path, borra el directorio del paquete y luego corre php artisan about, verifica que no sobreviva ninguna ruta auth.billing, billing-plans ni billing-addons, migra, siembra y corre dos pruebas que no son de facturación. Nunca toca tu base de datos ni tu copia de trabajo. Quitar el paquete de verdad es la misma secuencia, corrida en el lugar: composer remove weblabormx/billing-core, borra la entrada packages/billing-core de repositories en composer.json, borra el directorio, y luego composer dump-autoload y php artisan optimize:clear. Las tablas y los datos de facturación no se eliminan.

Lo que el paquete toma prestado del host

El bloque host de config/billing-core.php nombra las clases de tu aplicación que el paquete usa en lugar de traer las suyas:

Clave Valor por omisión Se usa para
webhook_event App\Models\WebhookEvent La tabla de webhooks recibidos. La sincronización de tipos de cambio y la gráfica de ingresos leen de ella las facturas pagadas.
webhook_success_status success El valor de estado que marca un webhook como procesado, para que solo cuenten las facturas atendidas.
select_filter App\Front\Filters\SelectFilter La clase de filtro detrás del filtro Plan en la lista de usuarios de administración.
category_model App\Models\Category Disponible pero no la usa ninguna función del kit: las categorías de complementos pasan por el trait HasCategories, que no lee esta clave.
array_cast App\Casts\ArrayCast El cast para screenshots en complementos y extra_data en variaciones de precio. Cae en array cuando la clase no existe.

Solo cambias estas claves cuando renombras la clase del host a la que apuntan.