@bepago/sdk 1.1.0 → 1.3.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 +11 -0
  2. package/index.mjs +131 -1
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -59,6 +59,17 @@ await bp.payouts.cancel(payout.id);
59
59
  await bp.payouts.balance();
60
60
  ```
61
61
 
62
+ Lote de pagos (hasta 1000 por llamada; queda pendiente de aprobación en el
63
+ panel, cuatro ojos, salvo auto-aprobación por API habilitada):
64
+
65
+ ```js
66
+ const lote = await bp.payouts.batches.create(
67
+ { payouts: [{ amount: 150000, destination: { type: 'alias', alias: 'juan.perez' }, external_id: 'premio-88' }] },
68
+ 'idem-key-unica',
69
+ );
70
+ await bp.payouts.batches.get(lote.id);
71
+ ```
72
+
62
73
  ### Verificar un webhook
63
74
 
64
75
  bePago firma cada webhook con `HMAC-SHA256` sobre `timestamp + body` usando el
package/index.mjs CHANGED
@@ -56,18 +56,48 @@ 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 }),
68
84
  get: (id) => request('GET', `/v1/payouts/${encodeURIComponent(id)}`),
69
85
  cancel: (id) => request('POST', `/v1/payouts/${encodeURIComponent(id)}/cancel`),
70
86
  balance: () => request('GET', '/v1/balance'),
87
+ /**
88
+ * Lotes de pagos masivos. `create` carga el lote entero (hasta 1000 pagos);
89
+ * queda pendiente de aprobación en el panel (cuatro ojos) salvo que el
90
+ * comercio tenga habilitada la auto-aprobación por API.
91
+ *
92
+ * const lote = await bp.payouts.batches.create({
93
+ * payouts: [{ amount: 150000, destination: { type: 'alias', alias: 'juan.perez' }, external_id: 'premio-88' }],
94
+ * }, 'idem-key-unica');
95
+ * await bp.payouts.batches.get(lote.id);
96
+ */
97
+ batches: {
98
+ create: (params, idempotencyKey) => request('POST', '/v1/payouts/batches', params, { idempotencyKey }),
99
+ get: (id) => request('GET', `/v1/payouts/batches/${encodeURIComponent(id)}`),
100
+ },
71
101
  },
72
102
  webhooks: {
73
103
  /**
@@ -102,6 +132,106 @@ export default function BePago(secret, opts = {}) {
102
132
  const payload = JSON.parse(body);
103
133
  return { id: id ?? payload.id, event: payload.event, livemode: payload.livemode, data: payload.data };
104
134
  },
135
+
136
+ /**
137
+ * Crea un manejador de webhooks seguro-por-defecto. Verifica firma y
138
+ * frescura (±5 min), descarta duplicados por X-BePago-Event-Id y SOLO
139
+ * invoca `onPaid` con eventos `charge.paid` válidos y no vistos. Siempre
140
+ * responde 200 a entregas válidas (aunque sean duplicadas, para que
141
+ * bePago deje de reintentar) y 400 a firmas/timestamps inválidos.
142
+ *
143
+ * No montes el parser de JSON del framework delante: el handler necesita
144
+ * el cuerpo CRUDO (string/Buffer) para validar la firma byte a byte. Usá
145
+ * los adapters `bp.webhooks.express()` / `bp.webhooks.fastify()` que ya
146
+ * resuelven eso.
147
+ *
148
+ * const wh = bp.webhooks.handler({
149
+ * secret: process.env.BEPAGO_WEBHOOK_SECRET,
150
+ * onPaid: async ({ reference, data }) => { await entregar(reference); },
151
+ * onEvent: async (evt) => {}, // opcional: todos los eventos
152
+ * store: myRedisStore, // opcional: persistir ids vistos
153
+ * });
154
+ *
155
+ * `store` por defecto es un Set en memoria. Para no entregar dos veces
156
+ * tras un reinicio, pasá un store con `has(id)` y `add(id)` (pueden ser
157
+ * async) respaldado por tu base.
158
+ *
159
+ * @returns {{ process(input): Promise<{status:number, body:string, duplicate:boolean, ok:boolean}>, express(): Function, fastify(): Function }}
160
+ */
161
+ handler(config = {}) {
162
+ const { secret, onPaid, onEvent } = config;
163
+ if (!secret) throw new Error('bePago webhooks.handler: falta `secret`');
164
+ const seen = config.store ?? defaultSeenStore();
165
+
166
+ async function process({ headers = {}, body }) {
167
+ const h = lowerHeaders(headers);
168
+ const raw = toRawBody(body);
169
+ let evt;
170
+ try {
171
+ evt = api.webhooks.verify(secret, {
172
+ id: h['x-bepago-event-id'],
173
+ timestamp: h['x-webhook-timestamp'],
174
+ signature: h['x-webhook-signature'],
175
+ body: raw,
176
+ }, config);
177
+ } catch (err) {
178
+ return { status: 400, body: JSON.stringify({ error: 'invalid_signature' }), duplicate: false, ok: false };
179
+ }
180
+ if (await seen.has(evt.id)) {
181
+ return { status: 200, body: JSON.stringify({ received: true, duplicate: true }), duplicate: true, ok: true };
182
+ }
183
+ await seen.add(evt.id);
184
+ try {
185
+ if (typeof onEvent === 'function') await onEvent(evt);
186
+ if (evt.event === 'charge.paid' && typeof onPaid === 'function') {
187
+ await onPaid({ reference: evt.data?.reference, data: evt.data, event: evt });
188
+ }
189
+ } catch (err) {
190
+ return { status: 500, body: JSON.stringify({ error: 'handler_error' }), duplicate: false, ok: false };
191
+ }
192
+ return { status: 200, body: JSON.stringify({ received: true }), duplicate: false, ok: true };
193
+ }
194
+
195
+ return {
196
+ process,
197
+ /** Middleware Express. Montalo con el raw body: app.post(url, express.raw({type:'*&#47;*'}), wh.express()) */
198
+ express() {
199
+ return async (req, res) => {
200
+ const r = await process({ headers: req.headers, body: req.body });
201
+ res.status(r.status).set('content-type', 'application/json').send(r.body);
202
+ };
203
+ },
204
+ /** Handler Fastify. Registrá un parser que deje el raw body (ver ejemplo en /docs). */
205
+ fastify() {
206
+ return async (req, reply) => {
207
+ const r = await process({ headers: req.headers, body: req.rawBody ?? req.body });
208
+ reply.code(r.status).header('content-type', 'application/json').send(r.body);
209
+ };
210
+ },
211
+ };
212
+ },
105
213
  },
106
214
  };
215
+ return api;
216
+ }
217
+
218
+ /** Store de ids vistos en memoria (por defecto). Para producción multi-proceso, pasá uno respaldado por tu base. */
219
+ function defaultSeenStore() {
220
+ const set = new Set();
221
+ return { has: (id) => set.has(id), add: (id) => { set.add(id); } };
222
+ }
223
+
224
+ function lowerHeaders(headers) {
225
+ const out = {};
226
+ for (const [k, v] of Object.entries(headers || {})) out[String(k).toLowerCase()] = Array.isArray(v) ? v[0] : v;
227
+ return out;
228
+ }
229
+
230
+ function toRawBody(body) {
231
+ if (body == null) return '';
232
+ if (typeof body === 'string') return body;
233
+ if (Buffer.isBuffer(body)) return body.toString('utf8');
234
+ // Último recurso: si el framework ya parseó a objeto, reserializar romperá la
235
+ // firma. Lo exponemos igual para que falle ruidoso y no silencioso.
236
+ return typeof body === 'object' ? JSON.stringify(body) : String(body);
107
237
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bepago/sdk",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "SDK oficial de bePago para Node.js (18+, sin dependencias)",
5
5
  "type": "module",
6
6
  "main": "index.mjs",
@@ -34,4 +34,4 @@
34
34
  },
35
35
  "author": "bePago",
36
36
  "license": "MIT"
37
- }
37
+ }