@bepago/sdk 1.1.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 (2) hide show
  1. package/index.mjs +117 -1
  2. package/package.json +1 -1
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 }),
@@ -102,6 +118,106 @@ export default function BePago(secret, opts = {}) {
102
118
  const payload = JSON.parse(body);
103
119
  return { id: id ?? payload.id, event: payload.event, livemode: payload.livemode, data: payload.data };
104
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
+ };
198
+ },
105
199
  },
106
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);
107
223
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bepago/sdk",
3
- "version": "1.1.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",