recurrente-js 1.0.1 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config/axiosInstance.js +17 -7
- package/dist/index.d.ts +1 -0
- package/dist/index.js +15 -0
- package/package.json +43 -43
- package/readme.md +352 -352
|
@@ -15,13 +15,23 @@ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (
|
|
|
15
15
|
}) : function(o, v) {
|
|
16
16
|
o["default"] = v;
|
|
17
17
|
});
|
|
18
|
-
var __importStar = (this && this.__importStar) || function (
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
};
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
25
35
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
26
36
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
27
37
|
};
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
2
16
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
17
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
18
|
};
|
|
@@ -6,3 +20,4 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
20
|
exports.recurrente = void 0;
|
|
7
21
|
var recurrente_1 = require("./api/recurrente");
|
|
8
22
|
Object.defineProperty(exports, "recurrente", { enumerable: true, get: function () { return __importDefault(recurrente_1).default; } });
|
|
23
|
+
__exportStar(require("./types/globals"), exports); // <-- Add this line
|
package/package.json
CHANGED
|
@@ -1,43 +1,43 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "recurrente-js",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"main": "./dist/index.js",
|
|
5
|
-
"types": "./dist/globals.d.ts",
|
|
6
|
-
"exports": {
|
|
7
|
-
".": "./dist/index.js",
|
|
8
|
-
"./webhooks": "./dist/webhooks.js"
|
|
9
|
-
},
|
|
10
|
-
"files": [
|
|
11
|
-
"dist/**/*",
|
|
12
|
-
"src/**/*.d.ts"
|
|
13
|
-
],
|
|
14
|
-
"scripts": {
|
|
15
|
-
"test": "jest",
|
|
16
|
-
"lint": "gts lint",
|
|
17
|
-
"clean": "gts clean",
|
|
18
|
-
"compile": "tsc",
|
|
19
|
-
"fix": "gts fix",
|
|
20
|
-
"prepare": "npm.cmd run compile",
|
|
21
|
-
"pretest": "npm.cmd run compile",
|
|
22
|
-
"posttest": "npm.cmd run lint",
|
|
23
|
-
"dev": "nodemon -w *.ts -e ts -x ts-node --files -H -T ./src/index.ts"
|
|
24
|
-
},
|
|
25
|
-
"author": "Axel Aguilar",
|
|
26
|
-
"license": "MIT",
|
|
27
|
-
"description": "Recurrente API Wrapper",
|
|
28
|
-
"devDependencies": {
|
|
29
|
-
"@types/jest": "^29.5.12",
|
|
30
|
-
"@types/node": "20.12.7",
|
|
31
|
-
"gts": "^5.3.1",
|
|
32
|
-
"jest": "^29.7.0",
|
|
33
|
-
"nodemon": "^3.1.4",
|
|
34
|
-
"ts-jest": "^29.2.5",
|
|
35
|
-
"ts-node": "^10.9.2",
|
|
36
|
-
"typescript": "^5.6.2"
|
|
37
|
-
},
|
|
38
|
-
"dependencies": {
|
|
39
|
-
"axios": "^1.7.7",
|
|
40
|
-
"dotenv": "^16.4.5",
|
|
41
|
-
"svix": "^1.34.0"
|
|
42
|
-
}
|
|
43
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "recurrente-js",
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"main": "./dist/index.js",
|
|
5
|
+
"types": "./dist/globals.d.ts",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./dist/index.js",
|
|
8
|
+
"./webhooks": "./dist/webhooks.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist/**/*",
|
|
12
|
+
"src/**/*.d.ts"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"test": "jest",
|
|
16
|
+
"lint": "gts lint",
|
|
17
|
+
"clean": "gts clean",
|
|
18
|
+
"compile": "tsc",
|
|
19
|
+
"fix": "gts fix",
|
|
20
|
+
"prepare": "npm.cmd run compile",
|
|
21
|
+
"pretest": "npm.cmd run compile",
|
|
22
|
+
"posttest": "npm.cmd run lint",
|
|
23
|
+
"dev": "nodemon -w *.ts -e ts -x ts-node --files -H -T ./src/index.ts"
|
|
24
|
+
},
|
|
25
|
+
"author": "Axel Aguilar",
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"description": "Recurrente API Wrapper",
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/jest": "^29.5.12",
|
|
30
|
+
"@types/node": "20.12.7",
|
|
31
|
+
"gts": "^5.3.1",
|
|
32
|
+
"jest": "^29.7.0",
|
|
33
|
+
"nodemon": "^3.1.4",
|
|
34
|
+
"ts-jest": "^29.2.5",
|
|
35
|
+
"ts-node": "^10.9.2",
|
|
36
|
+
"typescript": "^5.6.2"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"axios": "^1.7.7",
|
|
40
|
+
"dotenv": "^16.4.5",
|
|
41
|
+
"svix": "^1.34.0"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/readme.md
CHANGED
|
@@ -1,352 +1,352 @@
|
|
|
1
|
-
# Recurrente.js - Wrapper para la API de Recurrente
|
|
2
|
-
|
|
3
|
-
Este proyecto es un wrapper para la API de pagos recurrentes Recurrente, creado para facilitar la integración de pagos en proyectos. Con este paquete, puedes crear, actualizar, obtener y eliminar productos y suscripciones de manera eficiente utilizando solo Node.js y gestionando tus credenciales a través de un archivo .env.
|
|
4
|
-
|
|
5
|
-
## Beneficios del Proyecto
|
|
6
|
-
|
|
7
|
-
- Totalmente Tipado: El paquete está desarrollado con TypeScript, lo que proporciona un tipado fuerte en todas las funciones y objetos. Esto te ayuda a detectar errores durante el desarrollo y a mantener un código limpio y seguro.
|
|
8
|
-
|
|
9
|
-
- Promesas y Flujo Asíncrono: El wrapper sigue un enfoque basado en promesas, lo que permite una integración asíncrona y fluida con tu aplicación. Puedes gestionar productos y suscripciones utilizando async/await o .then()/.catch().
|
|
10
|
-
|
|
11
|
-
- Configuración Simple: Usa un archivo .env para gestionar tus claves API y URL base, lo que simplifica la configuración.
|
|
12
|
-
Integración Sencilla: Con unas pocas líneas de código, puedes interactuar con la API de Recurrente para manejar productos y suscripciones.
|
|
13
|
-
|
|
14
|
-
- Modularidad y Flexibilidad: Funciones claras y bien organizadas que permiten crear productos, suscripciones y gestionar estas entidades de manera independiente.
|
|
15
|
-
|
|
16
|
-
- Manejo de Errores Centralizado: Simplifica la depuración y el manejo de errores con un sistema centralizado que gestiona las respuestas incorrectas de la API.
|
|
17
|
-
|
|
18
|
-
- Manejo de Webhooks Personalizado con Svix: El paquete incluye una implementación para manejar webhooks de Recurrente, que utiliza Svix para la entrega segura y fiable de webhooks. Esto te permite verificar las firmas de los webhooks, registrar manejadores personalizados para diferentes tipos de eventos y procesar eventos de webhook de manera eficiente y segura.
|
|
19
|
-
|
|
20
|
-
## Instalación
|
|
21
|
-
|
|
22
|
-
Instala el paquete directamente desde npm o yarn.
|
|
23
|
-
|
|
24
|
-
`npm install recurrente-js`
|
|
25
|
-
|
|
26
|
-
## Configuración de Variables de Entorno
|
|
27
|
-
|
|
28
|
-
Crea un archivo .env en la raíz de tu proyecto y añade las siguientes claves:
|
|
29
|
-
|
|
30
|
-
```
|
|
31
|
-
RECURRENTE_BASE_URL=https://app.recurrente.com
|
|
32
|
-
RECURRENTE_PUBLIC_KEY=tu-public-key
|
|
33
|
-
RECURRENTE_SECRET_KEY=tu-secret-key
|
|
34
|
-
RECURRENTE_SVIX_SIGNING_SECRET=tu-svix-signing-key
|
|
35
|
-
```
|
|
36
|
-
|
|
37
|
-
## Ejemplos de Uso
|
|
38
|
-
|
|
39
|
-
### Crear un Producto
|
|
40
|
-
|
|
41
|
-
El siguiente código muestra cómo crear un producto de pago único utilizando este wrapper de forma tipada y asíncrona.
|
|
42
|
-
|
|
43
|
-
```
|
|
44
|
-
import { recurrente } from 'recurrente-js';
|
|
45
|
-
import { CreateProductRequest } from 'recurrente-js/types/globals';
|
|
46
|
-
|
|
47
|
-
// Datos de ejemplo para crear un producto
|
|
48
|
-
const productData: CreateProductRequest = {
|
|
49
|
-
name: 'Producto Ejemplo',
|
|
50
|
-
pricesAttributes: [
|
|
51
|
-
{
|
|
52
|
-
currency: 'GTQ',
|
|
53
|
-
chargeType: 'one_time',
|
|
54
|
-
amountInCents: 1000,
|
|
55
|
-
},
|
|
56
|
-
],
|
|
57
|
-
successUrl: 'https://www.example.com/success',
|
|
58
|
-
cancelUrl: 'https://www.example.com/cancel',
|
|
59
|
-
phoneRequirement: 'none',
|
|
60
|
-
addressRequirement: 'none',
|
|
61
|
-
billingInfoRequirement: 'none',
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
// Función asíncrona para crear el producto
|
|
65
|
-
async function createProduct() {
|
|
66
|
-
try {
|
|
67
|
-
const productResponse = await recurrente.createProduct(productData);
|
|
68
|
-
console.log('Producto creado:', productResponse);
|
|
69
|
-
} catch (error) {
|
|
70
|
-
console.error('Error al crear el producto:', error);
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
createProduct();
|
|
75
|
-
```
|
|
76
|
-
|
|
77
|
-
### Crear una Suscripción
|
|
78
|
-
|
|
79
|
-
El siguiente ejemplo muestra cómo crear una suscripción recurrente para un producto de forma asíncrona:
|
|
80
|
-
|
|
81
|
-
```import { recurrente } from 'recurrente-js';
|
|
82
|
-
import { ProductSubscription } from 'recurrente-js/types/globals';
|
|
83
|
-
|
|
84
|
-
// Datos de ejemplo para crear una suscripción
|
|
85
|
-
const subscriptionData: ProductSubscription = {
|
|
86
|
-
product: {
|
|
87
|
-
name: 'Producto Suscripción',
|
|
88
|
-
pricesAttributes: [
|
|
89
|
-
{
|
|
90
|
-
currency: 'GTQ',
|
|
91
|
-
chargeType: 'recurring',
|
|
92
|
-
amountInCents: 500,
|
|
93
|
-
billingIntervalCount: 1,
|
|
94
|
-
billingInterval: 'month',
|
|
95
|
-
},
|
|
96
|
-
],
|
|
97
|
-
successUrl: 'https://www.example.com/success',
|
|
98
|
-
cancelUrl: 'https://www.example.com/cancel',
|
|
99
|
-
},
|
|
100
|
-
};
|
|
101
|
-
|
|
102
|
-
// Función asíncrona para crear la suscripción
|
|
103
|
-
async function createSubscription() {
|
|
104
|
-
try {
|
|
105
|
-
const subscriptionResponse = await recurrente.createSubscription(subscriptionData);
|
|
106
|
-
console.log('Suscripción creada:', subscriptionResponse);
|
|
107
|
-
} catch (error) {
|
|
108
|
-
console.error('Error al crear la suscripción:', error);
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
createSubscription();
|
|
113
|
-
```
|
|
114
|
-
|
|
115
|
-
### Recuperar y Actualizar Productos
|
|
116
|
-
|
|
117
|
-
Puedes obtener la lista de productos existentes y también actualizar los productos con la API:
|
|
118
|
-
|
|
119
|
-
```
|
|
120
|
-
// Obtener todos los productos
|
|
121
|
-
async function getAllProducts() {
|
|
122
|
-
try {
|
|
123
|
-
const products = await recurrente.getAllProducts();
|
|
124
|
-
console.log('Productos recuperados:', products);
|
|
125
|
-
} catch (error) {
|
|
126
|
-
console.error('Error al recuperar productos:', error);
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// Actualizar un producto existente
|
|
131
|
-
async function updateProduct(productId: string) {
|
|
132
|
-
const updatedData = {
|
|
133
|
-
name: 'Producto Actualizado',
|
|
134
|
-
pricesAttributes: [
|
|
135
|
-
{
|
|
136
|
-
currency: 'GTQ',
|
|
137
|
-
chargeType: 'one_time',
|
|
138
|
-
amountInCents: 1500,
|
|
139
|
-
},
|
|
140
|
-
],
|
|
141
|
-
};
|
|
142
|
-
|
|
143
|
-
try {
|
|
144
|
-
const updatedProduct = await recurrente.updateProduct(productId, updatedData);
|
|
145
|
-
console.log('Producto actualizado:', updatedProduct);
|
|
146
|
-
} catch (error) {
|
|
147
|
-
console.error('Error al actualizar el producto:', error);
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
getAllProducts();
|
|
152
|
-
```
|
|
153
|
-
|
|
154
|
-
### Eliminar Productos y Cancelar Suscripciones
|
|
155
|
-
|
|
156
|
-
Eliminar un producto o cancelar una suscripción es tan simple como llamar a las funciones adecuadas con el ID correspondiente.
|
|
157
|
-
|
|
158
|
-
```
|
|
159
|
-
// Cancelar una suscripción
|
|
160
|
-
async function cancelSubscription(subscriptionId: string) {
|
|
161
|
-
try {
|
|
162
|
-
const cancelResponse = await recurrente.cancelSubscription(subscriptionId);
|
|
163
|
-
console.log('Suscripción cancelada:', cancelResponse.message);
|
|
164
|
-
} catch (error) {
|
|
165
|
-
console.error('Error al cancelar la suscripción:', error);
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// Eliminar un producto
|
|
170
|
-
async function deleteProduct(productId: string) {
|
|
171
|
-
try {
|
|
172
|
-
const deleteResponse = await recurrente.deleteProduct(productId);
|
|
173
|
-
console.log('Producto eliminado:', deleteResponse.message);
|
|
174
|
-
} catch (error) {
|
|
175
|
-
console.error('Error al eliminar el producto:', error);
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
cancelSubscription('subscription-id');
|
|
180
|
-
deleteProduct('product-id');
|
|
181
|
-
```
|
|
182
|
-
|
|
183
|
-
### Manejo de Webhooks
|
|
184
|
-
|
|
185
|
-
Recurrente utiliza Svix para la entrega de webhooks, lo que proporciona una capa adicional de seguridad y fiabilidad en la comunicación. Svix ayuda a garantizar que los webhooks que recibes sean legítimos y no hayan sido manipulados durante el tránsito.
|
|
186
|
-
|
|
187
|
-
#### Verificación de la Firma del Webhook
|
|
188
|
-
|
|
189
|
-
La implementación asume un enfoque de un solo endpoint, donde todos los webhooks se manejan a través de una única ruta, y se utiliza un solo `SVIX_SIGNING_SECRET` global para verificar todos los webhooks entrantes.
|
|
190
|
-
|
|
191
|
-
Para asegurar que los webhooks son auténticos, puedes utilizar la función verifySvixSignature para verificar la firma del
|
|
192
|
-
|
|
193
|
-
```
|
|
194
|
-
import { verifySvixSignature, handleWebhookEvent } from 'recurrente-webhooks';
|
|
195
|
-
|
|
196
|
-
// En tu controlador de webhooks
|
|
197
|
-
app.post('/webhook', (req, res) => {
|
|
198
|
-
try {
|
|
199
|
-
const payload = JSON.stringify(req.body);
|
|
200
|
-
const headers = req.headers;
|
|
201
|
-
|
|
202
|
-
// Verificar la firma y obtener el evento
|
|
203
|
-
const event = verifySvixSignature(payload, headers);
|
|
204
|
-
|
|
205
|
-
// Procesar el evento
|
|
206
|
-
handleWebhookEvent(event);
|
|
207
|
-
|
|
208
|
-
res.status(200).send('Webhook procesado');
|
|
209
|
-
} catch (error) {
|
|
210
|
-
console.error('Error al procesar el webhook:', error);
|
|
211
|
-
res.status(400).send('Firma de webhook inválida');
|
|
212
|
-
}
|
|
213
|
-
});
|
|
214
|
-
```
|
|
215
|
-
|
|
216
|
-
#### Manejar Eventos de Webhook
|
|
217
|
-
|
|
218
|
-
La función handleWebhookEvent se encarga de despachar el evento al manejador registrado correspondiente basado en el tipo de evento recibido.
|
|
219
|
-
|
|
220
|
-
#### Registrar Manejadores de Eventos de Webhook
|
|
221
|
-
|
|
222
|
-
##### En Next.js (v14+)
|
|
223
|
-
|
|
224
|
-
En Next.js 14, los handlers no se registran automáticamente en cada solicitud. Debes asegurarte de registrarlos en cada petición, una posible solución es registrarlos bajo una función al mismo nivel de tu ruta de API:
|
|
225
|
-
|
|
226
|
-
```
|
|
227
|
-
// api/webhook/handlers.ts
|
|
228
|
-
import { registerWebhookHandler } from 'recurrente-js/webhooks';
|
|
229
|
-
|
|
230
|
-
let handlersRegistered = false;
|
|
231
|
-
|
|
232
|
-
export function initializeWebhookHandlers() {
|
|
233
|
-
if (handlersRegistered) return;
|
|
234
|
-
|
|
235
|
-
registerWebhookHandler('payment_intent.failed', (event) => {
|
|
236
|
-
console.log('Manejador personalizado de payment_intent.failed activado!');
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
registerWebhookHandler('payment_intent.succeeded', (event) => {
|
|
240
|
-
console.log('Manejador personalizado de payment_intent.succeeded activado!');
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
handlersRegistered = true;
|
|
244
|
-
}
|
|
245
|
-
```
|
|
246
|
-
|
|
247
|
-
Para luego llamar esa función en tu ruta:
|
|
248
|
-
|
|
249
|
-
```
|
|
250
|
-
|
|
251
|
-
// /api/webhook/route.ts
|
|
252
|
-
import { NextResponse } from 'next/server';
|
|
253
|
-
import { initializeWebhookHandlers } from '../../handlers';
|
|
254
|
-
import { handleWebhookEvent, verifySvixSignature } from 'recurrente-js/webhooks';
|
|
255
|
-
|
|
256
|
-
export async function POST(req: Request) {
|
|
257
|
-
initializeWebhookHandlers(); // Asegura que los handlers estén registrados
|
|
258
|
-
|
|
259
|
-
const body = await req.text();
|
|
260
|
-
const headers = {
|
|
261
|
-
'svix-id': req.headers.get('svix-id'),
|
|
262
|
-
'svix-timestamp': req.headers.get('svix-timestamp'),
|
|
263
|
-
'svix-signature': req.headers.get('svix-signature'),
|
|
264
|
-
};
|
|
265
|
-
|
|
266
|
-
try {
|
|
267
|
-
const event = verifySvixSignature(body, headers);
|
|
268
|
-
handleWebhookEvent(event);
|
|
269
|
-
return NextResponse.json({ status: 'Webhook procesado' });
|
|
270
|
-
} catch (error) {
|
|
271
|
-
return NextResponse.json({ error: 'Firma de webhook inválida' }, { status: 400 });
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
```
|
|
275
|
-
|
|
276
|
-
##### En una App Normal con Express
|
|
277
|
-
|
|
278
|
-
En Express, puedes registrar los handlers globalmente al iniciar el servidor:
|
|
279
|
-
|
|
280
|
-
```
|
|
281
|
-
// app.ts
|
|
282
|
-
import express from 'express';
|
|
283
|
-
import { verifySvixSignature, handleWebhookEvent, registerWebhookHandler } from 'recurrente-js/webhooks';
|
|
284
|
-
|
|
285
|
-
const app = express();
|
|
286
|
-
app.use(express.json());
|
|
287
|
-
|
|
288
|
-
// Registra los handlers una vez al iniciar la app
|
|
289
|
-
registerWebhookHandler('payment_intent.failed', (event) => {
|
|
290
|
-
console.log('¡Handler personalizado de payment_intent.failed activado!');
|
|
291
|
-
});
|
|
292
|
-
|
|
293
|
-
registerWebhookHandler('payment_intent.succeeded', (event) => {
|
|
294
|
-
console.log('¡Handler personalizado de payment_intent.succeeded activado!');
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
app.post('/webhook', (req, res) => {
|
|
298
|
-
const payload = JSON.stringify(req.body);
|
|
299
|
-
const headers = req.headers;
|
|
300
|
-
|
|
301
|
-
try {
|
|
302
|
-
const event = verifySvixSignature(payload, headers);
|
|
303
|
-
handleWebhookEvent(event);
|
|
304
|
-
res.status(200).send('Webhook procesado');
|
|
305
|
-
} catch (error) {
|
|
306
|
-
res.status(400).send('Firma de webhook inválida');
|
|
307
|
-
}
|
|
308
|
-
});
|
|
309
|
-
|
|
310
|
-
app.listen(3000, () => console.log('Servidor corriendo en el puerto 3000'));
|
|
311
|
-
```
|
|
312
|
-
|
|
313
|
-
#### Eventos Disponibles
|
|
314
|
-
|
|
315
|
-
- payment_intent.succeeded
|
|
316
|
-
- payment_intent.failed
|
|
317
|
-
- subscription.create
|
|
318
|
-
- subscription.past_due
|
|
319
|
-
- subscription.paused
|
|
320
|
-
- subscription.cancel
|
|
321
|
-
|
|
322
|
-
Puedes registrar manejadores para estos eventos según tus necesidades utilizando `registerWebhookHandler`.
|
|
323
|
-
|
|
324
|
-
### Contribuir
|
|
325
|
-
|
|
326
|
-
Si deseas contribuir al proyecto, sigue estas pautas y asegúrate de cumplir con los estándares y buenas prácticas definidos:
|
|
327
|
-
|
|
328
|
-
1. **Haz un fork del repositorio** y clona el proyecto localmente.
|
|
329
|
-
2. **Crea una nueva rama** para tu funcionalidad o corrección (`git checkout -b nueva-funcionalidad`).
|
|
330
|
-
3. **Realiza los cambios necesarios** en el código, asegurándote de seguir las guías de estilo y prácticas establecidas:
|
|
331
|
-
- **Mensajes de Commit**: Asegúrate de que tus commits sigan la convención de [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) para mantener un historial claro y estructurado.
|
|
332
|
-
- **Linting y Estilo**: Este proyecto utiliza la [Google TypeScript Style Guide](https://github.com/google/gts) y tiene integrado el sistema `gts` para asegurar un código consistente. Utiliza los siguientes comandos:
|
|
333
|
-
1. `npm run lint`: Verifica que el código siga las reglas de estilo.
|
|
334
|
-
2. `npm run fix`: Corrige automáticamente los errores de estilo.
|
|
335
|
-
3. `npm run clean`: Limpia archivos generados automáticamente.
|
|
336
|
-
- **Nomenclatura**: Las interfaces, variables y funciones deben seguir la convención de `camelCase`. Para manejar casos en los que se requiera `snake_case`, el proyecto incluye utilidades para convertir entre estos formatos.
|
|
337
|
-
- **Escribe tests**: Antes de integrar nuevas funcionalidades, asegúrate de crear los tests correspondientes. El proyecto utiliza `jest` para pruebas unitarias. Ejecuta los tests con `npm run test` y verifica que todo funcione correctamente antes de enviar los cambios.
|
|
338
|
-
4. **Haz commit de tus cambios** usando un mensaje descriptivo (`git commit -am 'feat: agrega nueva funcionalidad'`).
|
|
339
|
-
5. **Haz push a tu rama** (`git push origin nueva-funcionalidad`).
|
|
340
|
-
6. **Crea un pull request** desde tu repositorio forkeado hacia el repositorio original para revisión.
|
|
341
|
-
|
|
342
|
-
## Contacto
|
|
343
|
-
|
|
344
|
-
Si tienes preguntas o sugerencias, no dudes en contactarme:
|
|
345
|
-
|
|
346
|
-
Correo electrónico: herdezx@gmail.com
|
|
347
|
-
|
|
348
|
-
Invitame un almuerzo: https://app.recurrente.com/s/pc-store/almuerzo
|
|
349
|
-
|
|
350
|
-
---
|
|
351
|
-
|
|
352
|
-
Este proyecto fue creado para facilitar la integración de pagos recurrentes en una SaaS que me encuentro desarrollando. Si te ha sido útil, considera compartirlo o contribuir para mejorarlo. ¡Gracias!
|
|
1
|
+
# Recurrente.js - Wrapper para la API de Recurrente
|
|
2
|
+
|
|
3
|
+
Este proyecto es un wrapper para la API de pagos recurrentes Recurrente, creado para facilitar la integración de pagos en proyectos. Con este paquete, puedes crear, actualizar, obtener y eliminar productos y suscripciones de manera eficiente utilizando solo Node.js y gestionando tus credenciales a través de un archivo .env.
|
|
4
|
+
|
|
5
|
+
## Beneficios del Proyecto
|
|
6
|
+
|
|
7
|
+
- Totalmente Tipado: El paquete está desarrollado con TypeScript, lo que proporciona un tipado fuerte en todas las funciones y objetos. Esto te ayuda a detectar errores durante el desarrollo y a mantener un código limpio y seguro.
|
|
8
|
+
|
|
9
|
+
- Promesas y Flujo Asíncrono: El wrapper sigue un enfoque basado en promesas, lo que permite una integración asíncrona y fluida con tu aplicación. Puedes gestionar productos y suscripciones utilizando async/await o .then()/.catch().
|
|
10
|
+
|
|
11
|
+
- Configuración Simple: Usa un archivo .env para gestionar tus claves API y URL base, lo que simplifica la configuración.
|
|
12
|
+
Integración Sencilla: Con unas pocas líneas de código, puedes interactuar con la API de Recurrente para manejar productos y suscripciones.
|
|
13
|
+
|
|
14
|
+
- Modularidad y Flexibilidad: Funciones claras y bien organizadas que permiten crear productos, suscripciones y gestionar estas entidades de manera independiente.
|
|
15
|
+
|
|
16
|
+
- Manejo de Errores Centralizado: Simplifica la depuración y el manejo de errores con un sistema centralizado que gestiona las respuestas incorrectas de la API.
|
|
17
|
+
|
|
18
|
+
- Manejo de Webhooks Personalizado con Svix: El paquete incluye una implementación para manejar webhooks de Recurrente, que utiliza Svix para la entrega segura y fiable de webhooks. Esto te permite verificar las firmas de los webhooks, registrar manejadores personalizados para diferentes tipos de eventos y procesar eventos de webhook de manera eficiente y segura.
|
|
19
|
+
|
|
20
|
+
## Instalación
|
|
21
|
+
|
|
22
|
+
Instala el paquete directamente desde npm o yarn.
|
|
23
|
+
|
|
24
|
+
`npm install recurrente-js`
|
|
25
|
+
|
|
26
|
+
## Configuración de Variables de Entorno
|
|
27
|
+
|
|
28
|
+
Crea un archivo .env en la raíz de tu proyecto y añade las siguientes claves:
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
RECURRENTE_BASE_URL=https://app.recurrente.com
|
|
32
|
+
RECURRENTE_PUBLIC_KEY=tu-public-key
|
|
33
|
+
RECURRENTE_SECRET_KEY=tu-secret-key
|
|
34
|
+
RECURRENTE_SVIX_SIGNING_SECRET=tu-svix-signing-key
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Ejemplos de Uso
|
|
38
|
+
|
|
39
|
+
### Crear un Producto
|
|
40
|
+
|
|
41
|
+
El siguiente código muestra cómo crear un producto de pago único utilizando este wrapper de forma tipada y asíncrona.
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
import { recurrente } from 'recurrente-js';
|
|
45
|
+
import { CreateProductRequest } from 'recurrente-js/types/globals';
|
|
46
|
+
|
|
47
|
+
// Datos de ejemplo para crear un producto
|
|
48
|
+
const productData: CreateProductRequest = {
|
|
49
|
+
name: 'Producto Ejemplo',
|
|
50
|
+
pricesAttributes: [
|
|
51
|
+
{
|
|
52
|
+
currency: 'GTQ',
|
|
53
|
+
chargeType: 'one_time',
|
|
54
|
+
amountInCents: 1000,
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
successUrl: 'https://www.example.com/success',
|
|
58
|
+
cancelUrl: 'https://www.example.com/cancel',
|
|
59
|
+
phoneRequirement: 'none',
|
|
60
|
+
addressRequirement: 'none',
|
|
61
|
+
billingInfoRequirement: 'none',
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// Función asíncrona para crear el producto
|
|
65
|
+
async function createProduct() {
|
|
66
|
+
try {
|
|
67
|
+
const productResponse = await recurrente.createProduct(productData);
|
|
68
|
+
console.log('Producto creado:', productResponse);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
console.error('Error al crear el producto:', error);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
createProduct();
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Crear una Suscripción
|
|
78
|
+
|
|
79
|
+
El siguiente ejemplo muestra cómo crear una suscripción recurrente para un producto de forma asíncrona:
|
|
80
|
+
|
|
81
|
+
```import { recurrente } from 'recurrente-js';
|
|
82
|
+
import { ProductSubscription } from 'recurrente-js/types/globals';
|
|
83
|
+
|
|
84
|
+
// Datos de ejemplo para crear una suscripción
|
|
85
|
+
const subscriptionData: ProductSubscription = {
|
|
86
|
+
product: {
|
|
87
|
+
name: 'Producto Suscripción',
|
|
88
|
+
pricesAttributes: [
|
|
89
|
+
{
|
|
90
|
+
currency: 'GTQ',
|
|
91
|
+
chargeType: 'recurring',
|
|
92
|
+
amountInCents: 500,
|
|
93
|
+
billingIntervalCount: 1,
|
|
94
|
+
billingInterval: 'month',
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
successUrl: 'https://www.example.com/success',
|
|
98
|
+
cancelUrl: 'https://www.example.com/cancel',
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// Función asíncrona para crear la suscripción
|
|
103
|
+
async function createSubscription() {
|
|
104
|
+
try {
|
|
105
|
+
const subscriptionResponse = await recurrente.createSubscription(subscriptionData);
|
|
106
|
+
console.log('Suscripción creada:', subscriptionResponse);
|
|
107
|
+
} catch (error) {
|
|
108
|
+
console.error('Error al crear la suscripción:', error);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
createSubscription();
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Recuperar y Actualizar Productos
|
|
116
|
+
|
|
117
|
+
Puedes obtener la lista de productos existentes y también actualizar los productos con la API:
|
|
118
|
+
|
|
119
|
+
```
|
|
120
|
+
// Obtener todos los productos
|
|
121
|
+
async function getAllProducts() {
|
|
122
|
+
try {
|
|
123
|
+
const products = await recurrente.getAllProducts();
|
|
124
|
+
console.log('Productos recuperados:', products);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
console.error('Error al recuperar productos:', error);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Actualizar un producto existente
|
|
131
|
+
async function updateProduct(productId: string) {
|
|
132
|
+
const updatedData = {
|
|
133
|
+
name: 'Producto Actualizado',
|
|
134
|
+
pricesAttributes: [
|
|
135
|
+
{
|
|
136
|
+
currency: 'GTQ',
|
|
137
|
+
chargeType: 'one_time',
|
|
138
|
+
amountInCents: 1500,
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
const updatedProduct = await recurrente.updateProduct(productId, updatedData);
|
|
145
|
+
console.log('Producto actualizado:', updatedProduct);
|
|
146
|
+
} catch (error) {
|
|
147
|
+
console.error('Error al actualizar el producto:', error);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
getAllProducts();
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### Eliminar Productos y Cancelar Suscripciones
|
|
155
|
+
|
|
156
|
+
Eliminar un producto o cancelar una suscripción es tan simple como llamar a las funciones adecuadas con el ID correspondiente.
|
|
157
|
+
|
|
158
|
+
```
|
|
159
|
+
// Cancelar una suscripción
|
|
160
|
+
async function cancelSubscription(subscriptionId: string) {
|
|
161
|
+
try {
|
|
162
|
+
const cancelResponse = await recurrente.cancelSubscription(subscriptionId);
|
|
163
|
+
console.log('Suscripción cancelada:', cancelResponse.message);
|
|
164
|
+
} catch (error) {
|
|
165
|
+
console.error('Error al cancelar la suscripción:', error);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Eliminar un producto
|
|
170
|
+
async function deleteProduct(productId: string) {
|
|
171
|
+
try {
|
|
172
|
+
const deleteResponse = await recurrente.deleteProduct(productId);
|
|
173
|
+
console.log('Producto eliminado:', deleteResponse.message);
|
|
174
|
+
} catch (error) {
|
|
175
|
+
console.error('Error al eliminar el producto:', error);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
cancelSubscription('subscription-id');
|
|
180
|
+
deleteProduct('product-id');
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### Manejo de Webhooks
|
|
184
|
+
|
|
185
|
+
Recurrente utiliza Svix para la entrega de webhooks, lo que proporciona una capa adicional de seguridad y fiabilidad en la comunicación. Svix ayuda a garantizar que los webhooks que recibes sean legítimos y no hayan sido manipulados durante el tránsito.
|
|
186
|
+
|
|
187
|
+
#### Verificación de la Firma del Webhook
|
|
188
|
+
|
|
189
|
+
La implementación asume un enfoque de un solo endpoint, donde todos los webhooks se manejan a través de una única ruta, y se utiliza un solo `SVIX_SIGNING_SECRET` global para verificar todos los webhooks entrantes.
|
|
190
|
+
|
|
191
|
+
Para asegurar que los webhooks son auténticos, puedes utilizar la función verifySvixSignature para verificar la firma del
|
|
192
|
+
|
|
193
|
+
```
|
|
194
|
+
import { verifySvixSignature, handleWebhookEvent } from 'recurrente-webhooks';
|
|
195
|
+
|
|
196
|
+
// En tu controlador de webhooks
|
|
197
|
+
app.post('/webhook', (req, res) => {
|
|
198
|
+
try {
|
|
199
|
+
const payload = JSON.stringify(req.body);
|
|
200
|
+
const headers = req.headers;
|
|
201
|
+
|
|
202
|
+
// Verificar la firma y obtener el evento
|
|
203
|
+
const event = verifySvixSignature(payload, headers);
|
|
204
|
+
|
|
205
|
+
// Procesar el evento
|
|
206
|
+
handleWebhookEvent(event);
|
|
207
|
+
|
|
208
|
+
res.status(200).send('Webhook procesado');
|
|
209
|
+
} catch (error) {
|
|
210
|
+
console.error('Error al procesar el webhook:', error);
|
|
211
|
+
res.status(400).send('Firma de webhook inválida');
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
#### Manejar Eventos de Webhook
|
|
217
|
+
|
|
218
|
+
La función handleWebhookEvent se encarga de despachar el evento al manejador registrado correspondiente basado en el tipo de evento recibido.
|
|
219
|
+
|
|
220
|
+
#### Registrar Manejadores de Eventos de Webhook
|
|
221
|
+
|
|
222
|
+
##### En Next.js (v14+)
|
|
223
|
+
|
|
224
|
+
En Next.js 14, los handlers no se registran automáticamente en cada solicitud. Debes asegurarte de registrarlos en cada petición, una posible solución es registrarlos bajo una función al mismo nivel de tu ruta de API:
|
|
225
|
+
|
|
226
|
+
```
|
|
227
|
+
// api/webhook/handlers.ts
|
|
228
|
+
import { registerWebhookHandler } from 'recurrente-js/webhooks';
|
|
229
|
+
|
|
230
|
+
let handlersRegistered = false;
|
|
231
|
+
|
|
232
|
+
export function initializeWebhookHandlers() {
|
|
233
|
+
if (handlersRegistered) return;
|
|
234
|
+
|
|
235
|
+
registerWebhookHandler('payment_intent.failed', (event) => {
|
|
236
|
+
console.log('Manejador personalizado de payment_intent.failed activado!');
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
registerWebhookHandler('payment_intent.succeeded', (event) => {
|
|
240
|
+
console.log('Manejador personalizado de payment_intent.succeeded activado!');
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
handlersRegistered = true;
|
|
244
|
+
}
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Para luego llamar esa función en tu ruta:
|
|
248
|
+
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
// /api/webhook/route.ts
|
|
252
|
+
import { NextResponse } from 'next/server';
|
|
253
|
+
import { initializeWebhookHandlers } from '../../handlers';
|
|
254
|
+
import { handleWebhookEvent, verifySvixSignature } from 'recurrente-js/webhooks';
|
|
255
|
+
|
|
256
|
+
export async function POST(req: Request) {
|
|
257
|
+
initializeWebhookHandlers(); // Asegura que los handlers estén registrados
|
|
258
|
+
|
|
259
|
+
const body = await req.text();
|
|
260
|
+
const headers = {
|
|
261
|
+
'svix-id': req.headers.get('svix-id'),
|
|
262
|
+
'svix-timestamp': req.headers.get('svix-timestamp'),
|
|
263
|
+
'svix-signature': req.headers.get('svix-signature'),
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
try {
|
|
267
|
+
const event = verifySvixSignature(body, headers);
|
|
268
|
+
handleWebhookEvent(event);
|
|
269
|
+
return NextResponse.json({ status: 'Webhook procesado' });
|
|
270
|
+
} catch (error) {
|
|
271
|
+
return NextResponse.json({ error: 'Firma de webhook inválida' }, { status: 400 });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
##### En una App Normal con Express
|
|
277
|
+
|
|
278
|
+
En Express, puedes registrar los handlers globalmente al iniciar el servidor:
|
|
279
|
+
|
|
280
|
+
```
|
|
281
|
+
// app.ts
|
|
282
|
+
import express from 'express';
|
|
283
|
+
import { verifySvixSignature, handleWebhookEvent, registerWebhookHandler } from 'recurrente-js/webhooks';
|
|
284
|
+
|
|
285
|
+
const app = express();
|
|
286
|
+
app.use(express.json());
|
|
287
|
+
|
|
288
|
+
// Registra los handlers una vez al iniciar la app
|
|
289
|
+
registerWebhookHandler('payment_intent.failed', (event) => {
|
|
290
|
+
console.log('¡Handler personalizado de payment_intent.failed activado!');
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
registerWebhookHandler('payment_intent.succeeded', (event) => {
|
|
294
|
+
console.log('¡Handler personalizado de payment_intent.succeeded activado!');
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
app.post('/webhook', (req, res) => {
|
|
298
|
+
const payload = JSON.stringify(req.body);
|
|
299
|
+
const headers = req.headers;
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
const event = verifySvixSignature(payload, headers);
|
|
303
|
+
handleWebhookEvent(event);
|
|
304
|
+
res.status(200).send('Webhook procesado');
|
|
305
|
+
} catch (error) {
|
|
306
|
+
res.status(400).send('Firma de webhook inválida');
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
app.listen(3000, () => console.log('Servidor corriendo en el puerto 3000'));
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
#### Eventos Disponibles
|
|
314
|
+
|
|
315
|
+
- payment_intent.succeeded
|
|
316
|
+
- payment_intent.failed
|
|
317
|
+
- subscription.create
|
|
318
|
+
- subscription.past_due
|
|
319
|
+
- subscription.paused
|
|
320
|
+
- subscription.cancel
|
|
321
|
+
|
|
322
|
+
Puedes registrar manejadores para estos eventos según tus necesidades utilizando `registerWebhookHandler`.
|
|
323
|
+
|
|
324
|
+
### Contribuir
|
|
325
|
+
|
|
326
|
+
Si deseas contribuir al proyecto, sigue estas pautas y asegúrate de cumplir con los estándares y buenas prácticas definidos:
|
|
327
|
+
|
|
328
|
+
1. **Haz un fork del repositorio** y clona el proyecto localmente.
|
|
329
|
+
2. **Crea una nueva rama** para tu funcionalidad o corrección (`git checkout -b nueva-funcionalidad`).
|
|
330
|
+
3. **Realiza los cambios necesarios** en el código, asegurándote de seguir las guías de estilo y prácticas establecidas:
|
|
331
|
+
- **Mensajes de Commit**: Asegúrate de que tus commits sigan la convención de [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) para mantener un historial claro y estructurado.
|
|
332
|
+
- **Linting y Estilo**: Este proyecto utiliza la [Google TypeScript Style Guide](https://github.com/google/gts) y tiene integrado el sistema `gts` para asegurar un código consistente. Utiliza los siguientes comandos:
|
|
333
|
+
1. `npm run lint`: Verifica que el código siga las reglas de estilo.
|
|
334
|
+
2. `npm run fix`: Corrige automáticamente los errores de estilo.
|
|
335
|
+
3. `npm run clean`: Limpia archivos generados automáticamente.
|
|
336
|
+
- **Nomenclatura**: Las interfaces, variables y funciones deben seguir la convención de `camelCase`. Para manejar casos en los que se requiera `snake_case`, el proyecto incluye utilidades para convertir entre estos formatos.
|
|
337
|
+
- **Escribe tests**: Antes de integrar nuevas funcionalidades, asegúrate de crear los tests correspondientes. El proyecto utiliza `jest` para pruebas unitarias. Ejecuta los tests con `npm run test` y verifica que todo funcione correctamente antes de enviar los cambios.
|
|
338
|
+
4. **Haz commit de tus cambios** usando un mensaje descriptivo (`git commit -am 'feat: agrega nueva funcionalidad'`).
|
|
339
|
+
5. **Haz push a tu rama** (`git push origin nueva-funcionalidad`).
|
|
340
|
+
6. **Crea un pull request** desde tu repositorio forkeado hacia el repositorio original para revisión.
|
|
341
|
+
|
|
342
|
+
## Contacto
|
|
343
|
+
|
|
344
|
+
Si tienes preguntas o sugerencias, no dudes en contactarme:
|
|
345
|
+
|
|
346
|
+
Correo electrónico: herdezx@gmail.com
|
|
347
|
+
|
|
348
|
+
Invitame un almuerzo: https://app.recurrente.com/s/pc-store/almuerzo
|
|
349
|
+
|
|
350
|
+
---
|
|
351
|
+
|
|
352
|
+
Este proyecto fue creado para facilitar la integración de pagos recurrentes en una SaaS que me encuentro desarrollando. Si te ha sido útil, considera compartirlo o contribuir para mejorarlo. ¡Gracias!
|