bepago 1.0.0 → 1.2.0
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/README.md +27 -16
- package/index.mjs +148 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -62,7 +62,9 @@ await bp.payouts.balance();
|
|
|
62
62
|
### Verificar un webhook
|
|
63
63
|
|
|
64
64
|
bePago firma cada webhook con `HMAC-SHA256` sobre `timestamp + body` usando el
|
|
65
|
-
secreto del webhook.
|
|
65
|
+
secreto del webhook. `verify` chequea la firma **y la frescura del timestamp**
|
|
66
|
+
(rechaza entregas fuera de ±5 minutos por defecto, configurable) y devuelve el
|
|
67
|
+
evento ya parseado, con su `id`:
|
|
66
68
|
|
|
67
69
|
```js
|
|
68
70
|
import BePago from 'bepago';
|
|
@@ -70,23 +72,32 @@ import BePago from 'bepago';
|
|
|
70
72
|
const bp = BePago('sk_live_…');
|
|
71
73
|
|
|
72
74
|
// En tu handler HTTP (body como string crudo, sin re-serializar):
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
);
|
|
83
|
-
|
|
84
|
-
if (!ok) {
|
|
85
|
-
return res.status(400).send('firma inválida');
|
|
75
|
+
let evt;
|
|
76
|
+
try {
|
|
77
|
+
evt = bp.webhooks.verify('whsec_tu_secreto_de_webhook', {
|
|
78
|
+
id: req.headers['x-bepago-event-id'],
|
|
79
|
+
timestamp: req.headers['x-webhook-timestamp'],
|
|
80
|
+
signature: req.headers['x-webhook-signature'],
|
|
81
|
+
body: req.rawBody, // el cuerpo tal cual llegó, sin re-serializar
|
|
82
|
+
});
|
|
83
|
+
} catch (e) {
|
|
84
|
+
return res.status(400).send('webhook inválido'); // firma o timestamp fuera de ventana
|
|
86
85
|
}
|
|
87
86
|
|
|
88
|
-
|
|
89
|
-
//
|
|
87
|
+
// Anti-replay: cada evento trae un id ESTABLE entre reintentos. Guardá los ids
|
|
88
|
+
// ya procesados y descartá los duplicados (los reintentos reenvían el mismo id
|
|
89
|
+
// con un timestamp nuevo).
|
|
90
|
+
if (await yaProcesado(evt.id)) return res.status(200).end();
|
|
91
|
+
await marcarProcesado(evt.id);
|
|
92
|
+
|
|
93
|
+
// procesar evt.event / evt.data …
|
|
94
|
+
res.status(200).end();
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Tolerancia configurable (ej. ±10 minutos):
|
|
98
|
+
|
|
99
|
+
```js
|
|
100
|
+
bp.webhooks.verify(secret, { id, timestamp, signature, body }, { toleranceSeconds: 600 });
|
|
90
101
|
```
|
|
91
102
|
|
|
92
103
|
## Documentación
|
package/index.mjs
CHANGED
|
@@ -56,12 +56,28 @@ export default function BePago(secret, opts = {}) {
|
|
|
56
56
|
return data;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
const api = {
|
|
60
60
|
charges: {
|
|
61
61
|
create: (params) => request('POST', '/v1/charges', params),
|
|
62
62
|
get: (ref) => request('GET', `/v1/charges/${encodeURIComponent(ref)}`),
|
|
63
63
|
refund: (ref, reason) => request('POST', `/v1/charges/${encodeURIComponent(ref)}/refund`, { reason }),
|
|
64
64
|
balance: () => request('GET', '/v1/charges/balance'),
|
|
65
|
+
/**
|
|
66
|
+
* Confirma el estado REAL de un cobro consultando la API (fuente de verdad).
|
|
67
|
+
* Usalo para decidir si entregás: nunca confíes en el navegador ni en el
|
|
68
|
+
* iframe. Devuelve `{ paid, status, charge }` — `paid` es true solo si el
|
|
69
|
+
* cobro figura `paid` en bePago.
|
|
70
|
+
*
|
|
71
|
+
* const { paid, charge } = await bp.charges.confirm(orderId);
|
|
72
|
+
* if (paid && charge.amount === order.amount) entregar();
|
|
73
|
+
*
|
|
74
|
+
* @returns {Promise<{ paid: boolean, status: string, charge: object }>}
|
|
75
|
+
*/
|
|
76
|
+
async confirm(ref) {
|
|
77
|
+
const charge = await request('GET', `/v1/charges/${encodeURIComponent(ref)}`);
|
|
78
|
+
const status = charge?.status ?? charge?.data?.status;
|
|
79
|
+
return { paid: status === 'paid', status, charge };
|
|
80
|
+
},
|
|
65
81
|
},
|
|
66
82
|
payouts: {
|
|
67
83
|
create: (params, idempotencyKey) => request('POST', '/v1/payouts', params, { idempotencyKey }),
|
|
@@ -70,10 +86,138 @@ export default function BePago(secret, opts = {}) {
|
|
|
70
86
|
balance: () => request('GET', '/v1/balance'),
|
|
71
87
|
},
|
|
72
88
|
webhooks: {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
89
|
+
/**
|
|
90
|
+
* Verifica un webhook de bePago y devuelve el evento parseado.
|
|
91
|
+
*
|
|
92
|
+
* Chequea la firma HMAC y la FRESCURA del timestamp: por defecto rechaza
|
|
93
|
+
* entregas fuera de ±5 minutos (configurable con `toleranceSeconds`). Lanza
|
|
94
|
+
* si la firma no valida o el timestamp está fuera de la ventana.
|
|
95
|
+
*
|
|
96
|
+
* Expone `id` (header X-BePago-Event-Id, estable entre reintentos): guardá
|
|
97
|
+
* los ids ya procesados para descartar duplicados de forma idempotente.
|
|
98
|
+
*
|
|
99
|
+
* const evt = bp.webhooks.verify(secret, {
|
|
100
|
+
* id: req.headers['x-bepago-event-id'],
|
|
101
|
+
* timestamp: req.headers['x-webhook-timestamp'],
|
|
102
|
+
* signature: req.headers['x-webhook-signature'],
|
|
103
|
+
* body: rawBody, // el cuerpo EXACTO recibido, sin reserializar
|
|
104
|
+
* });
|
|
105
|
+
* if (seen.has(evt.id)) return; // duplicado
|
|
106
|
+
* seen.add(evt.id);
|
|
107
|
+
*
|
|
108
|
+
* @returns {{ id: string, event: string, livemode: boolean, data: object }}
|
|
109
|
+
*/
|
|
110
|
+
verify(webhookSecret, { id, timestamp, body, signature }, opts = {}) {
|
|
111
|
+
const toleranceMs = (opts.toleranceSeconds ?? 300) * 1000;
|
|
112
|
+
const expected = createHmac('sha256', webhookSecret).update(String(timestamp) + body, 'utf8').digest('hex');
|
|
113
|
+
const a = Buffer.from(expected, 'hex');
|
|
114
|
+
const b = Buffer.from(String(signature ?? ''), 'hex');
|
|
115
|
+
if (a.length !== b.length || !timingSafeEqual(a, b)) throw new Error('bePago webhook: firma inválida');
|
|
116
|
+
const age = Math.abs(Date.now() - Number(timestamp));
|
|
117
|
+
if (!Number.isFinite(age) || age > toleranceMs) throw new Error('bePago webhook: timestamp fuera de la ventana permitida');
|
|
118
|
+
const payload = JSON.parse(body);
|
|
119
|
+
return { id: id ?? payload.id, event: payload.event, livemode: payload.livemode, data: payload.data };
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Crea un manejador de webhooks seguro-por-defecto. Verifica firma y
|
|
124
|
+
* frescura (±5 min), descarta duplicados por X-BePago-Event-Id y SOLO
|
|
125
|
+
* invoca `onPaid` con eventos `charge.paid` válidos y no vistos. Siempre
|
|
126
|
+
* responde 200 a entregas válidas (aunque sean duplicadas, para que
|
|
127
|
+
* bePago deje de reintentar) y 400 a firmas/timestamps inválidos.
|
|
128
|
+
*
|
|
129
|
+
* No montes el parser de JSON del framework delante: el handler necesita
|
|
130
|
+
* el cuerpo CRUDO (string/Buffer) para validar la firma byte a byte. Usá
|
|
131
|
+
* los adapters `bp.webhooks.express()` / `bp.webhooks.fastify()` que ya
|
|
132
|
+
* resuelven eso.
|
|
133
|
+
*
|
|
134
|
+
* const wh = bp.webhooks.handler({
|
|
135
|
+
* secret: process.env.BEPAGO_WEBHOOK_SECRET,
|
|
136
|
+
* onPaid: async ({ reference, data }) => { await entregar(reference); },
|
|
137
|
+
* onEvent: async (evt) => {}, // opcional: todos los eventos
|
|
138
|
+
* store: myRedisStore, // opcional: persistir ids vistos
|
|
139
|
+
* });
|
|
140
|
+
*
|
|
141
|
+
* `store` por defecto es un Set en memoria. Para no entregar dos veces
|
|
142
|
+
* tras un reinicio, pasá un store con `has(id)` y `add(id)` (pueden ser
|
|
143
|
+
* async) respaldado por tu base.
|
|
144
|
+
*
|
|
145
|
+
* @returns {{ process(input): Promise<{status:number, body:string, duplicate:boolean, ok:boolean}>, express(): Function, fastify(): Function }}
|
|
146
|
+
*/
|
|
147
|
+
handler(config = {}) {
|
|
148
|
+
const { secret, onPaid, onEvent } = config;
|
|
149
|
+
if (!secret) throw new Error('bePago webhooks.handler: falta `secret`');
|
|
150
|
+
const seen = config.store ?? defaultSeenStore();
|
|
151
|
+
|
|
152
|
+
async function process({ headers = {}, body }) {
|
|
153
|
+
const h = lowerHeaders(headers);
|
|
154
|
+
const raw = toRawBody(body);
|
|
155
|
+
let evt;
|
|
156
|
+
try {
|
|
157
|
+
evt = api.webhooks.verify(secret, {
|
|
158
|
+
id: h['x-bepago-event-id'],
|
|
159
|
+
timestamp: h['x-webhook-timestamp'],
|
|
160
|
+
signature: h['x-webhook-signature'],
|
|
161
|
+
body: raw,
|
|
162
|
+
}, config);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
return { status: 400, body: JSON.stringify({ error: 'invalid_signature' }), duplicate: false, ok: false };
|
|
165
|
+
}
|
|
166
|
+
if (await seen.has(evt.id)) {
|
|
167
|
+
return { status: 200, body: JSON.stringify({ received: true, duplicate: true }), duplicate: true, ok: true };
|
|
168
|
+
}
|
|
169
|
+
await seen.add(evt.id);
|
|
170
|
+
try {
|
|
171
|
+
if (typeof onEvent === 'function') await onEvent(evt);
|
|
172
|
+
if (evt.event === 'charge.paid' && typeof onPaid === 'function') {
|
|
173
|
+
await onPaid({ reference: evt.data?.reference, data: evt.data, event: evt });
|
|
174
|
+
}
|
|
175
|
+
} catch (err) {
|
|
176
|
+
return { status: 500, body: JSON.stringify({ error: 'handler_error' }), duplicate: false, ok: false };
|
|
177
|
+
}
|
|
178
|
+
return { status: 200, body: JSON.stringify({ received: true }), duplicate: false, ok: true };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
process,
|
|
183
|
+
/** Middleware Express. Montalo con el raw body: app.post(url, express.raw({type:'*/*'}), wh.express()) */
|
|
184
|
+
express() {
|
|
185
|
+
return async (req, res) => {
|
|
186
|
+
const r = await process({ headers: req.headers, body: req.body });
|
|
187
|
+
res.status(r.status).set('content-type', 'application/json').send(r.body);
|
|
188
|
+
};
|
|
189
|
+
},
|
|
190
|
+
/** Handler Fastify. Registrá un parser que deje el raw body (ver ejemplo en /docs). */
|
|
191
|
+
fastify() {
|
|
192
|
+
return async (req, reply) => {
|
|
193
|
+
const r = await process({ headers: req.headers, body: req.rawBody ?? req.body });
|
|
194
|
+
reply.code(r.status).header('content-type', 'application/json').send(r.body);
|
|
195
|
+
};
|
|
196
|
+
},
|
|
197
|
+
};
|
|
76
198
|
},
|
|
77
199
|
},
|
|
78
200
|
};
|
|
201
|
+
return api;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Store de ids vistos en memoria (por defecto). Para producción multi-proceso, pasá uno respaldado por tu base. */
|
|
205
|
+
function defaultSeenStore() {
|
|
206
|
+
const set = new Set();
|
|
207
|
+
return { has: (id) => set.has(id), add: (id) => { set.add(id); } };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function lowerHeaders(headers) {
|
|
211
|
+
const out = {};
|
|
212
|
+
for (const [k, v] of Object.entries(headers || {})) out[String(k).toLowerCase()] = Array.isArray(v) ? v[0] : v;
|
|
213
|
+
return out;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function toRawBody(body) {
|
|
217
|
+
if (body == null) return '';
|
|
218
|
+
if (typeof body === 'string') return body;
|
|
219
|
+
if (Buffer.isBuffer(body)) return body.toString('utf8');
|
|
220
|
+
// Último recurso: si el framework ya parseó a objeto, reserializar romperá la
|
|
221
|
+
// firma. Lo exponemos igual para que falle ruidoso y no silencioso.
|
|
222
|
+
return typeof body === 'object' ? JSON.stringify(body) : String(body);
|
|
79
223
|
}
|