@bepago/sdk 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.
Files changed (3) hide show
  1. package/README.md +27 -16
  2. package/index.mjs +148 -4
  3. package/package.json +4 -2
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. Verificá la firma antes de procesar el evento:
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
- const timestamp = req.headers['x-timestamp'];
74
- const signature = req.headers['x-signature'];
75
- const rawBody = req.rawBody; // el cuerpo tal cual llegó
76
-
77
- const ok = bp.webhooks.verify(
78
- 'whsec_tu_secreto_de_webhook',
79
- timestamp,
80
- rawBody,
81
- signature,
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
- const evento = JSON.parse(rawBody);
89
- // procesar evento.type …
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
- return {
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
- verify: (webhookSecret, timestamp, body, signature) => {
74
- const expected = createHmac('sha256', webhookSecret).update(timestamp + body, 'utf8').digest('hex');
75
- return timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(signature, 'hex'));
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:'*&#47;*'}), 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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bepago/sdk",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "SDK oficial de bePago para Node.js (18+, sin dependencias)",
5
5
  "type": "module",
6
6
  "main": "index.mjs",
@@ -29,7 +29,9 @@
29
29
  "bugs": {
30
30
  "url": "https://github.com/bepago/sdk-node/issues"
31
31
  },
32
- "engines": { "node": ">=18" },
32
+ "engines": {
33
+ "node": ">=18"
34
+ },
33
35
  "author": "bePago",
34
36
  "license": "MIT"
35
37
  }