@dinnger/wallet 0.0.0-stage → 1.0.1
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/LICENSE +21 -0
- package/README.md +144 -2
- package/dist/index.d.ts +232 -0
- package/dist/index.js +179 -0
- package/package.json +50 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dinnger
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,145 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @dinnger/wallet
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Cliente de Node.js para emitir tarjetas de lealtad en **Google Wallet** a tus clientes: crea la tarjeta, suma y canjea
|
|
4
|
+
puntos, registra ventas de caja, envía notificaciones y verifica webhooks. Con tipos de TypeScript y sin dependencias.
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
npm install @dinnger/wallet
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Requiere Node.js 20.19 o superior. Es un paquete ESM; en proyectos CommonJS también funciona con `require`.
|
|
11
|
+
|
|
12
|
+
## Primeros pasos
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
import { Wallet } from '@dinnger/wallet'
|
|
16
|
+
|
|
17
|
+
const wallet = new Wallet({
|
|
18
|
+
apiKey: process.env.WALLET_API_KEY!, // sk_test_… o sk_live_…, del portal › Desarrolladores › API keys
|
|
19
|
+
baseUrl: 'https://wallet.example.com',
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
// 1. Crea (o actualiza) la tarjeta del cliente con TU identificador
|
|
23
|
+
const member = await wallet.members.upsert('CLI-001', { name: 'Ana López', email: 'ana@example.com' })
|
|
24
|
+
console.log(member.wallets.google?.saveUrl) // link «Agregar a Google Wallet»
|
|
25
|
+
console.log(member.shortUrl) // link corto para SMS o WhatsApp
|
|
26
|
+
console.log(member.qrUrl) // PNG con el QR para mostrar en caja
|
|
27
|
+
|
|
28
|
+
// 2. Suma puntos por una compra (idempotente: un reintento no duplica)
|
|
29
|
+
await wallet.members.addPoints('CLI-001', { delta: 50, reason: 'Compra #1234', idempotencyKey: 'venta-1234' })
|
|
30
|
+
|
|
31
|
+
// 3. Canjea
|
|
32
|
+
await wallet.members.addPoints('CLI-001', { delta: -20, reason: 'Canje café' })
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Con una llave `sk_test_…` las tarjetas salen marcadas como «PRUEBA»; nada de lo que hagas en modo prueba toca a tus
|
|
36
|
+
clientes reales.
|
|
37
|
+
|
|
38
|
+
## Cajas registradoras
|
|
39
|
+
|
|
40
|
+
Los puntos los calcula la plataforma con las reglas configuradas en el portal (*Clientes › Cajas*). El QR de la tarjeta
|
|
41
|
+
contiene el `customerId`. Una llave de caja solo puede usar `wallet.pos`.
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
// Cliente escaneado: saldo y valor de sus puntos
|
|
45
|
+
const customer = await wallet.pos.customer('CLI-001')
|
|
46
|
+
|
|
47
|
+
// Antes de cobrar: descuento por los puntos a canjear y puntos que dará la compra (no guarda nada)
|
|
48
|
+
const quote = await wallet.pos.quote({ customerId: 'CLI-001', amountInCents: 25550, redeemPoints: 100 })
|
|
49
|
+
|
|
50
|
+
// Al cobrar: canjea y suma en una sola operación. El número de ticket evita duplicados.
|
|
51
|
+
const { sale } = await wallet.pos.createSale({
|
|
52
|
+
customerId: 'CLI-001',
|
|
53
|
+
receiptNumber: 'F-000123',
|
|
54
|
+
amountInCents: 25550, // Q255.50
|
|
55
|
+
redeemPoints: 100,
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
// Devolución total del ticket: quita los puntos ganados y regresa los canjeados
|
|
59
|
+
await wallet.pos.refund('F-000123')
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Notificaciones push
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
// A un cliente («tu canje está listo»)
|
|
66
|
+
await wallet.messages.sendToMember('CLI-001', { header: '¡Tu café gratis te espera!', body: 'Válido hasta el domingo.' })
|
|
67
|
+
|
|
68
|
+
// Campaña a todos los clientes del programa
|
|
69
|
+
await wallet.messages.broadcast({ header: 'Doble puntos', body: 'Este sábado en todas las tiendas.', expiresAt: '2026-10-12T05:59:59Z' })
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
El aviso en la pantalla de bloqueo lo pone Google y es genérico; tu `header` y tu `body` se ven al abrir la tarjeta.
|
|
73
|
+
Google Wallet permite **3 notificaciones por tarjeta cada 24 h** (las campañas cuentan para todas); por encima, la API
|
|
74
|
+
responde `429 notification_limit`.
|
|
75
|
+
|
|
76
|
+
## Webhooks
|
|
77
|
+
|
|
78
|
+
La firma se calcula sobre los bytes exactos que enviamos: usa el cuerpo **crudo**, sin volver a serializarlo.
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
import express from 'express'
|
|
82
|
+
import { verifyWebhook } from '@dinnger/wallet'
|
|
83
|
+
|
|
84
|
+
const app = express()
|
|
85
|
+
|
|
86
|
+
app.post('/hooks/wallet', express.raw({ type: 'application/json' }), (req, res) => {
|
|
87
|
+
let event
|
|
88
|
+
try {
|
|
89
|
+
event = verifyWebhook(req.body, req.header('Wallet-Signature'), process.env.WALLET_WEBHOOK_SECRET!)
|
|
90
|
+
} catch {
|
|
91
|
+
return res.sendStatus(400) // firma inválida o fuera de la ventana de 5 minutos
|
|
92
|
+
}
|
|
93
|
+
if (event.type === 'member.saved') {
|
|
94
|
+
// el cliente guardó su tarjeta
|
|
95
|
+
}
|
|
96
|
+
res.sendStatus(200)
|
|
97
|
+
})
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Un evento puede llegar más de una vez: usa su `id` para no procesarlo dos veces.
|
|
101
|
+
|
|
102
|
+
## Errores
|
|
103
|
+
|
|
104
|
+
Toda respuesta que no es 2xx lanza `WalletError` con `status`, `code` y el `body` de la respuesta. Los errores de red,
|
|
105
|
+
los 5xx y los 429 se reintentan solos (2 veces por defecto, con espera creciente; cámbialo con `retries`).
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
import { WalletError } from '@dinnger/wallet'
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
await wallet.members.addPoints('CLI-001', { delta: -500, reason: 'Canje TV' })
|
|
112
|
+
} catch (err) {
|
|
113
|
+
if (err instanceof WalletError && err.code === 'insufficient_points') {
|
|
114
|
+
// muestra «saldo insuficiente» en la caja
|
|
115
|
+
} else {
|
|
116
|
+
throw err
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Códigos frecuentes: `insufficient_points`, `member_not_found`, `idempotency_conflict`, `demo_limit_reached`,
|
|
122
|
+
`notification_limit`.
|
|
123
|
+
|
|
124
|
+
## Referencia
|
|
125
|
+
|
|
126
|
+
| Grupo | Métodos |
|
|
127
|
+
|---|---|
|
|
128
|
+
| `wallet` | `me()` |
|
|
129
|
+
| `wallet.members` | `upsert`, `get`, `list`, `deactivate`, `addPoints`, `transactions` |
|
|
130
|
+
| `wallet.pos` | `customer`, `quote`, `createSale`, `getSale`, `refund` |
|
|
131
|
+
| `wallet.messages` | `sendToMember`, `broadcast`, `list` |
|
|
132
|
+
| Funciones | `verifyWebhook(cuerpo, firma, secreto, toleranciaEnSegundos = 300)` |
|
|
133
|
+
|
|
134
|
+
Opciones de `new Wallet({ … })`:
|
|
135
|
+
|
|
136
|
+
| Opción | Tipo | Descripción |
|
|
137
|
+
|---|---|---|
|
|
138
|
+
| `apiKey` | `string` | Llave `sk_test_…` o `sk_live_…`. |
|
|
139
|
+
| `baseUrl` | `string` | URL base de la API, p. ej. `https://wallet.example.com`. |
|
|
140
|
+
| `retries` | `number` | Reintentos ante errores de red, 5xx o 429. Por defecto, 2. |
|
|
141
|
+
| `fetch` | `typeof fetch` | Implementación de `fetch` propia (para pruebas o proxies). |
|
|
142
|
+
|
|
143
|
+
## Licencia
|
|
144
|
+
|
|
145
|
+
MIT. Ver [LICENSE](./LICENSE).
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
export interface Member {
|
|
2
|
+
id: string;
|
|
3
|
+
customerId: string;
|
|
4
|
+
name: string;
|
|
5
|
+
email: string | null;
|
|
6
|
+
points: number;
|
|
7
|
+
tier: string | null;
|
|
8
|
+
state: 'ACTIVE' | 'INACTIVE';
|
|
9
|
+
mode: 'test' | 'live';
|
|
10
|
+
source: 'api' | 'join';
|
|
11
|
+
savedInWallet: boolean;
|
|
12
|
+
shortUrl: string;
|
|
13
|
+
qrUrl: string;
|
|
14
|
+
createdAt: string;
|
|
15
|
+
updatedAt: string;
|
|
16
|
+
}
|
|
17
|
+
export interface MemberDetail extends Member {
|
|
18
|
+
wallets: {
|
|
19
|
+
google?: {
|
|
20
|
+
saveUrl: string;
|
|
21
|
+
};
|
|
22
|
+
apple?: {
|
|
23
|
+
saveUrl: string;
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export interface PointTransaction {
|
|
28
|
+
id: number;
|
|
29
|
+
memberId: string;
|
|
30
|
+
delta: number;
|
|
31
|
+
balanceAfter: number;
|
|
32
|
+
reason: string | null;
|
|
33
|
+
idempotencyKey: string | null;
|
|
34
|
+
createdAt: string;
|
|
35
|
+
}
|
|
36
|
+
export interface Notification {
|
|
37
|
+
id: string;
|
|
38
|
+
scope: 'member' | 'program';
|
|
39
|
+
mode: 'test' | 'live';
|
|
40
|
+
header: string;
|
|
41
|
+
body: string;
|
|
42
|
+
expiresAt: string | null;
|
|
43
|
+
status: 'QUEUED' | 'SENT' | 'FAILED';
|
|
44
|
+
error: string | null;
|
|
45
|
+
sentAt: string | null;
|
|
46
|
+
createdAt: string;
|
|
47
|
+
}
|
|
48
|
+
export interface MessageInput {
|
|
49
|
+
header: string;
|
|
50
|
+
body: string;
|
|
51
|
+
/** ISO 8601; hasta cuándo se muestra el mensaje en la tarjeta. */
|
|
52
|
+
expiresAt?: string;
|
|
53
|
+
}
|
|
54
|
+
export interface PosSale {
|
|
55
|
+
id: string;
|
|
56
|
+
mode: 'test' | 'live';
|
|
57
|
+
receiptNumber: string;
|
|
58
|
+
/** Total del ticket antes del descuento por puntos. */
|
|
59
|
+
amountInCents: number;
|
|
60
|
+
discountInCents: number;
|
|
61
|
+
/** Lo que pagó el cliente. */
|
|
62
|
+
totalInCents: number;
|
|
63
|
+
currency: 'GTQ' | 'USD';
|
|
64
|
+
pointsEarned: number;
|
|
65
|
+
pointsRedeemed: number;
|
|
66
|
+
channel: 'api' | 'portal';
|
|
67
|
+
registerName: string;
|
|
68
|
+
status: 'COMPLETED' | 'REFUNDED';
|
|
69
|
+
refundDelta: number | null;
|
|
70
|
+
/** Puntos ganados que el cliente ya había gastado al devolver la venta. */
|
|
71
|
+
pointsNotRecovered: number;
|
|
72
|
+
refundedAt: string | null;
|
|
73
|
+
createdAt: string;
|
|
74
|
+
}
|
|
75
|
+
export interface PosQuote {
|
|
76
|
+
customerId: string;
|
|
77
|
+
currency: 'GTQ' | 'USD';
|
|
78
|
+
amountInCents: number;
|
|
79
|
+
pointsRedeemed: number;
|
|
80
|
+
discountInCents: number;
|
|
81
|
+
totalInCents: number;
|
|
82
|
+
pointsEarned: number;
|
|
83
|
+
balanceAfter: number;
|
|
84
|
+
maxRedeemPoints: number;
|
|
85
|
+
}
|
|
86
|
+
export interface PosSaleInput {
|
|
87
|
+
customerId: string;
|
|
88
|
+
/** Número de ticket o factura: identifica la venta, un reintento con el mismo número no duplica puntos. */
|
|
89
|
+
receiptNumber: string;
|
|
90
|
+
amountInCents: number;
|
|
91
|
+
redeemPoints?: number;
|
|
92
|
+
currency?: 'GTQ' | 'USD';
|
|
93
|
+
}
|
|
94
|
+
export interface Page<T> {
|
|
95
|
+
items: T[];
|
|
96
|
+
total: number;
|
|
97
|
+
page: number;
|
|
98
|
+
pageSize: number;
|
|
99
|
+
}
|
|
100
|
+
export interface WebhookEvent<T = Record<string, unknown>> {
|
|
101
|
+
id: string;
|
|
102
|
+
type: 'member.created' | 'member.updated' | 'member.deactivated' | 'member.saved' | 'member.removed' | 'points.changed' | 'sale.created' | 'sale.refunded' | 'company.approved' | 'company.suspended' | 'notification.sent' | 'webhook.test';
|
|
103
|
+
mode: 'test' | 'live';
|
|
104
|
+
createdAt: string;
|
|
105
|
+
data: T;
|
|
106
|
+
}
|
|
107
|
+
export declare class WalletError extends Error {
|
|
108
|
+
readonly status: number;
|
|
109
|
+
/** Código estable, p. ej. `insufficient_points`, `demo_limit_reached`, `member_not_found`. */
|
|
110
|
+
readonly code: string | undefined;
|
|
111
|
+
readonly body: unknown;
|
|
112
|
+
constructor(status: number, message: string,
|
|
113
|
+
/** Código estable, p. ej. `insufficient_points`, `demo_limit_reached`, `member_not_found`. */
|
|
114
|
+
code: string | undefined, body: unknown);
|
|
115
|
+
}
|
|
116
|
+
export interface WalletOptions {
|
|
117
|
+
/** Llave `sk_test_…` o `sk_live_…` (una llave de caja solo puede usar `pos`). */
|
|
118
|
+
apiKey: string;
|
|
119
|
+
/** URL base de la API, p. ej. https://wallet.miempresa.com */
|
|
120
|
+
baseUrl: string;
|
|
121
|
+
fetch?: typeof fetch;
|
|
122
|
+
/** Reintentos ante errores de red o 5xx/429 (por defecto 2). */
|
|
123
|
+
retries?: number;
|
|
124
|
+
}
|
|
125
|
+
export declare class Wallet {
|
|
126
|
+
private readonly options;
|
|
127
|
+
readonly members: MembersApi;
|
|
128
|
+
readonly messages: MessagesApi;
|
|
129
|
+
readonly pos: PosApi;
|
|
130
|
+
private readonly baseUrl;
|
|
131
|
+
private readonly fetchFn;
|
|
132
|
+
constructor(options: WalletOptions);
|
|
133
|
+
/** Empresa y modo de la llave. */
|
|
134
|
+
me(): Promise<{
|
|
135
|
+
company: {
|
|
136
|
+
id: string;
|
|
137
|
+
name: string;
|
|
138
|
+
slug: string;
|
|
139
|
+
status: string;
|
|
140
|
+
};
|
|
141
|
+
mode: string;
|
|
142
|
+
joinUrl: string;
|
|
143
|
+
}>;
|
|
144
|
+
/** @internal */
|
|
145
|
+
request<T>(method: string, path: string, body?: unknown, headers?: Record<string, string>): Promise<T>;
|
|
146
|
+
}
|
|
147
|
+
declare class MembersApi {
|
|
148
|
+
private readonly client;
|
|
149
|
+
constructor(client: Wallet);
|
|
150
|
+
/** Crea o actualiza el miembro (idempotente). Devuelve los links para guardar la tarjeta. */
|
|
151
|
+
upsert(customerId: string, input: {
|
|
152
|
+
name: string;
|
|
153
|
+
email?: string | null;
|
|
154
|
+
}): Promise<MemberDetail>;
|
|
155
|
+
get(customerId: string): Promise<MemberDetail>;
|
|
156
|
+
list(params?: {
|
|
157
|
+
page?: number;
|
|
158
|
+
pageSize?: number;
|
|
159
|
+
}): Promise<Page<Member>>;
|
|
160
|
+
deactivate(customerId: string): Promise<Member>;
|
|
161
|
+
/**
|
|
162
|
+
* Suma (delta > 0) o resta (delta < 0) puntos. Usa siempre `idempotencyKey` (p. ej. el nº de venta)
|
|
163
|
+
* para que un reintento no duplique puntos.
|
|
164
|
+
*/
|
|
165
|
+
addPoints(customerId: string, input: {
|
|
166
|
+
delta: number;
|
|
167
|
+
reason?: string;
|
|
168
|
+
idempotencyKey?: string;
|
|
169
|
+
}): Promise<{
|
|
170
|
+
transaction: PointTransaction;
|
|
171
|
+
member: Member;
|
|
172
|
+
}>;
|
|
173
|
+
transactions(customerId: string, params?: {
|
|
174
|
+
page?: number;
|
|
175
|
+
pageSize?: number;
|
|
176
|
+
}): Promise<Page<PointTransaction>>;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Notificaciones push en Google Wallet. Google permite 3 por tarjeta cada 24 h (las campañas cuentan);
|
|
180
|
+
* por encima del límite la API responde 429 `notification_limit`.
|
|
181
|
+
*/
|
|
182
|
+
declare class MessagesApi {
|
|
183
|
+
private readonly client;
|
|
184
|
+
constructor(client: Wallet);
|
|
185
|
+
/** Mensaje con notificación a un cliente. */
|
|
186
|
+
sendToMember(customerId: string, input: MessageInput): Promise<Notification>;
|
|
187
|
+
/** Campaña a todos los clientes del programa. */
|
|
188
|
+
broadcast(input: MessageInput): Promise<Notification>;
|
|
189
|
+
list(params?: {
|
|
190
|
+
page?: number;
|
|
191
|
+
pageSize?: number;
|
|
192
|
+
}): Promise<Page<Notification>>;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Cajas registradoras: los puntos los calcula la plataforma con las reglas configuradas en el portal.
|
|
196
|
+
* El QR de la tarjeta contiene el `customerId`.
|
|
197
|
+
*/
|
|
198
|
+
declare class PosApi {
|
|
199
|
+
private readonly client;
|
|
200
|
+
constructor(client: Wallet);
|
|
201
|
+
/** Cliente escaneado: saldo, nivel y valor de sus puntos en dinero. */
|
|
202
|
+
customer(customerId: string): Promise<{
|
|
203
|
+
customerId: string;
|
|
204
|
+
name: string;
|
|
205
|
+
points: number;
|
|
206
|
+
tier: string | null;
|
|
207
|
+
state: "ACTIVE" | "INACTIVE";
|
|
208
|
+
currency: "GTQ" | "USD" | null;
|
|
209
|
+
pointsValueInCents: number;
|
|
210
|
+
}>;
|
|
211
|
+
/** Antes de cobrar: descuento por los puntos a canjear y puntos que dará la compra. No guarda nada. */
|
|
212
|
+
quote(input: Omit<PosSaleInput, 'receiptNumber'>): Promise<PosQuote>;
|
|
213
|
+
/** Al cobrar: canjea y suma en una sola operación. */
|
|
214
|
+
createSale(input: PosSaleInput): Promise<{
|
|
215
|
+
sale: PosSale;
|
|
216
|
+
member: Member;
|
|
217
|
+
replayed: boolean;
|
|
218
|
+
}>;
|
|
219
|
+
getSale(receiptNumber: string): Promise<PosSale>;
|
|
220
|
+
/** Devolución total del ticket: quita los puntos ganados y regresa los canjeados. */
|
|
221
|
+
refund(receiptNumber: string): Promise<{
|
|
222
|
+
sale: PosSale;
|
|
223
|
+
member: Member;
|
|
224
|
+
replayed: boolean;
|
|
225
|
+
}>;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Verifica la cabecera `Wallet-Signature` de un webhook y devuelve el evento.
|
|
229
|
+
* `rawBody` debe ser el cuerpo exacto recibido (sin re-serializar).
|
|
230
|
+
*/
|
|
231
|
+
export declare function verifyWebhook<T = Record<string, unknown>>(rawBody: string | Buffer, signatureHeader: string | undefined, secret: string, toleranceSeconds?: number): WebhookEvent<T>;
|
|
232
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @dinnger/wallet: cliente de Node.js para la API de tarjetas de lealtad en Google Wallet.
|
|
3
|
+
* Miembros y puntos, cajas registradoras, notificaciones y verificación de webhooks.
|
|
4
|
+
*/
|
|
5
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
6
|
+
export class WalletError extends Error {
|
|
7
|
+
status;
|
|
8
|
+
code;
|
|
9
|
+
body;
|
|
10
|
+
constructor(status, message,
|
|
11
|
+
/** Código estable, p. ej. `insufficient_points`, `demo_limit_reached`, `member_not_found`. */
|
|
12
|
+
code, body) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.status = status;
|
|
15
|
+
this.code = code;
|
|
16
|
+
this.body = body;
|
|
17
|
+
this.name = 'WalletError';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export class Wallet {
|
|
21
|
+
options;
|
|
22
|
+
members;
|
|
23
|
+
messages;
|
|
24
|
+
pos;
|
|
25
|
+
baseUrl;
|
|
26
|
+
fetchFn;
|
|
27
|
+
constructor(options) {
|
|
28
|
+
this.options = options;
|
|
29
|
+
if (!/^sk_(test|live)_/.test(options.apiKey))
|
|
30
|
+
throw new Error('apiKey debe empezar con sk_test_ o sk_live_');
|
|
31
|
+
this.baseUrl = options.baseUrl.replace(/\/$/, '');
|
|
32
|
+
this.fetchFn = options.fetch ?? fetch;
|
|
33
|
+
this.members = new MembersApi(this);
|
|
34
|
+
this.messages = new MessagesApi(this);
|
|
35
|
+
this.pos = new PosApi(this);
|
|
36
|
+
}
|
|
37
|
+
/** Empresa y modo de la llave. */
|
|
38
|
+
me() {
|
|
39
|
+
return this.request('GET', '/v1/me');
|
|
40
|
+
}
|
|
41
|
+
/** @internal */
|
|
42
|
+
async request(method, path, body, headers = {}) {
|
|
43
|
+
const retries = this.options.retries ?? 2;
|
|
44
|
+
for (let attempt = 0;; attempt++) {
|
|
45
|
+
let res;
|
|
46
|
+
try {
|
|
47
|
+
res = await this.fetchFn(`${this.baseUrl}${path}`, {
|
|
48
|
+
method,
|
|
49
|
+
headers: {
|
|
50
|
+
Authorization: `Bearer ${this.options.apiKey}`,
|
|
51
|
+
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
|
52
|
+
...headers,
|
|
53
|
+
},
|
|
54
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
if (attempt < retries) {
|
|
59
|
+
await delay(attempt);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
throw err;
|
|
63
|
+
}
|
|
64
|
+
if ((res.status >= 500 || res.status === 429) && attempt < retries) {
|
|
65
|
+
await delay(attempt);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const data = await res.json().catch(() => undefined);
|
|
69
|
+
if (!res.ok) {
|
|
70
|
+
throw new WalletError(res.status, data?.error ?? res.statusText, data?.code, data);
|
|
71
|
+
}
|
|
72
|
+
return data;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
class MembersApi {
|
|
77
|
+
client;
|
|
78
|
+
constructor(client) {
|
|
79
|
+
this.client = client;
|
|
80
|
+
}
|
|
81
|
+
/** Crea o actualiza el miembro (idempotente). Devuelve los links para guardar la tarjeta. */
|
|
82
|
+
upsert(customerId, input) {
|
|
83
|
+
return this.client.request('PUT', `/v1/members/${encodeURIComponent(customerId)}`, input);
|
|
84
|
+
}
|
|
85
|
+
get(customerId) {
|
|
86
|
+
return this.client.request('GET', `/v1/members/${encodeURIComponent(customerId)}`);
|
|
87
|
+
}
|
|
88
|
+
list(params = {}) {
|
|
89
|
+
const qs = new URLSearchParams(Object.entries(params).map(([k, v]) => [k, String(v)])).toString();
|
|
90
|
+
return this.client.request('GET', `/v1/members${qs ? `?${qs}` : ''}`);
|
|
91
|
+
}
|
|
92
|
+
deactivate(customerId) {
|
|
93
|
+
return this.client.request('DELETE', `/v1/members/${encodeURIComponent(customerId)}`);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Suma (delta > 0) o resta (delta < 0) puntos. Usa siempre `idempotencyKey` (p. ej. el nº de venta)
|
|
97
|
+
* para que un reintento no duplique puntos.
|
|
98
|
+
*/
|
|
99
|
+
addPoints(customerId, input) {
|
|
100
|
+
return this.client.request('POST', `/v1/members/${encodeURIComponent(customerId)}/points`, input);
|
|
101
|
+
}
|
|
102
|
+
transactions(customerId, params = {}) {
|
|
103
|
+
const qs = new URLSearchParams(Object.entries(params).map(([k, v]) => [k, String(v)])).toString();
|
|
104
|
+
return this.client.request('GET', `/v1/members/${encodeURIComponent(customerId)}/transactions${qs ? `?${qs}` : ''}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Notificaciones push en Google Wallet. Google permite 3 por tarjeta cada 24 h (las campañas cuentan);
|
|
109
|
+
* por encima del límite la API responde 429 `notification_limit`.
|
|
110
|
+
*/
|
|
111
|
+
class MessagesApi {
|
|
112
|
+
client;
|
|
113
|
+
constructor(client) {
|
|
114
|
+
this.client = client;
|
|
115
|
+
}
|
|
116
|
+
/** Mensaje con notificación a un cliente. */
|
|
117
|
+
sendToMember(customerId, input) {
|
|
118
|
+
return this.client.request('POST', `/v1/members/${encodeURIComponent(customerId)}/messages`, input);
|
|
119
|
+
}
|
|
120
|
+
/** Campaña a todos los clientes del programa. */
|
|
121
|
+
broadcast(input) {
|
|
122
|
+
return this.client.request('POST', '/v1/messages', input);
|
|
123
|
+
}
|
|
124
|
+
list(params = {}) {
|
|
125
|
+
const qs = new URLSearchParams(Object.entries(params).map(([k, v]) => [k, String(v)])).toString();
|
|
126
|
+
return this.client.request('GET', `/v1/messages${qs ? `?${qs}` : ''}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Cajas registradoras: los puntos los calcula la plataforma con las reglas configuradas en el portal.
|
|
131
|
+
* El QR de la tarjeta contiene el `customerId`.
|
|
132
|
+
*/
|
|
133
|
+
class PosApi {
|
|
134
|
+
client;
|
|
135
|
+
constructor(client) {
|
|
136
|
+
this.client = client;
|
|
137
|
+
}
|
|
138
|
+
/** Cliente escaneado: saldo, nivel y valor de sus puntos en dinero. */
|
|
139
|
+
customer(customerId) {
|
|
140
|
+
return this.client.request('GET', `/v1/pos/members/${encodeURIComponent(customerId)}`);
|
|
141
|
+
}
|
|
142
|
+
/** Antes de cobrar: descuento por los puntos a canjear y puntos que dará la compra. No guarda nada. */
|
|
143
|
+
quote(input) {
|
|
144
|
+
return this.client.request('POST', '/v1/pos/quote', input);
|
|
145
|
+
}
|
|
146
|
+
/** Al cobrar: canjea y suma en una sola operación. */
|
|
147
|
+
createSale(input) {
|
|
148
|
+
return this.client.request('POST', '/v1/pos/sales', input);
|
|
149
|
+
}
|
|
150
|
+
getSale(receiptNumber) {
|
|
151
|
+
return this.client.request('GET', `/v1/pos/sales/${encodeURIComponent(receiptNumber)}`);
|
|
152
|
+
}
|
|
153
|
+
/** Devolución total del ticket: quita los puntos ganados y regresa los canjeados. */
|
|
154
|
+
refund(receiptNumber) {
|
|
155
|
+
return this.client.request('POST', `/v1/pos/sales/${encodeURIComponent(receiptNumber)}/refund`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
function delay(attempt) {
|
|
159
|
+
return new Promise((r) => setTimeout(r, 200 * 2 ** attempt));
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Verifica la cabecera `Wallet-Signature` de un webhook y devuelve el evento.
|
|
163
|
+
* `rawBody` debe ser el cuerpo exacto recibido (sin re-serializar).
|
|
164
|
+
*/
|
|
165
|
+
export function verifyWebhook(rawBody, signatureHeader, secret, toleranceSeconds = 300) {
|
|
166
|
+
const body = typeof rawBody === 'string' ? rawBody : rawBody.toString('utf8');
|
|
167
|
+
const parts = Object.fromEntries((signatureHeader ?? '').split(',').map((p) => p.split('=', 2)));
|
|
168
|
+
const t = Number(parts.t);
|
|
169
|
+
if (!t || !parts.v1)
|
|
170
|
+
throw new Error('Cabecera Wallet-Signature inválida');
|
|
171
|
+
if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds)
|
|
172
|
+
throw new Error('Webhook fuera de la ventana de tiempo');
|
|
173
|
+
const expected = createHmac('sha256', secret).update(`${t}.${body}`).digest();
|
|
174
|
+
const received = Buffer.from(parts.v1, 'hex');
|
|
175
|
+
if (received.length !== expected.length || !timingSafeEqual(received, expected)) {
|
|
176
|
+
throw new Error('Firma de webhook inválida');
|
|
177
|
+
}
|
|
178
|
+
return JSON.parse(body);
|
|
179
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,53 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dinnger/wallet",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "Cliente de Node.js para emitir tarjetas de lealtad en Google Wallet: clientes, puntos, cajas, notificaciones y webhooks.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"google-wallet",
|
|
7
|
+
"wallet",
|
|
8
|
+
"loyalty",
|
|
9
|
+
"lealtad",
|
|
10
|
+
"puntos",
|
|
11
|
+
"pos",
|
|
12
|
+
"webhooks"
|
|
13
|
+
],
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/Dinnger-IA/loyalty.git",
|
|
17
|
+
"directory": "packages/wallet"
|
|
18
|
+
},
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"type": "module",
|
|
21
|
+
"main": "./dist/index.js",
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
26
|
+
"default": "./dist/index.js"
|
|
27
|
+
},
|
|
28
|
+
"./package.json": "./package.json"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"dist",
|
|
32
|
+
"README.md"
|
|
33
|
+
],
|
|
34
|
+
"sideEffects": false,
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=20.19"
|
|
37
|
+
},
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json",
|
|
43
|
+
"test": "vitest",
|
|
44
|
+
"type-check": "tsc --noEmit",
|
|
45
|
+
"prepack": "npm run build",
|
|
46
|
+
"prepublishOnly": "npm run type-check && vitest --run"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@types/node": "^24.19.1",
|
|
50
|
+
"typescript": "~6.0.0",
|
|
51
|
+
"vitest": "^5.0.3"
|
|
52
|
+
}
|
|
6
53
|
}
|