@paywallex/node 0.0.0-stage → 1.0.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/LICENSE +9 -0
- package/README.md +111 -2
- package/dist/index.d.ts +267 -0
- package/dist/index.js +225 -0
- package/package.json +19 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Paywallex
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,112 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @paywallex/node
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
SDK serveur officiel de l'API Paywallex `/v1`. Il sert à encaisser par Orange Money, MTN MoMo, Moov Money, Wave ou le solde Paywallex, à rembourser, à suivre les litiges et les abonnements, et à vérifier les webhooks signés.
|
|
4
|
+
|
|
5
|
+
Node 18 ou plus récent, Bun ou Deno. Aucune dépendance.
|
|
6
|
+
|
|
7
|
+
> Côté serveur uniquement. Ne mettez jamais une clé secrète `pwx_sk_…` dans un navigateur ou une application mobile.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
Installez le paquet :
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
npm install @paywallex/node
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Créer une session de paiement
|
|
18
|
+
|
|
19
|
+
Le SDK ne lit pas `PAYWALLEX_KEY` tout seul. Passez votre clé explicitement à la création du client. Une clé `pwx_sk_test_…` vise le bac à sable, une clé `pwx_sk_live_…` vise la production.
|
|
20
|
+
|
|
21
|
+
Créez une session, puis redirigez le client vers `session.url` :
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { Paywallex } from "@paywallex/node";
|
|
25
|
+
|
|
26
|
+
const paywallex = new Paywallex({ apiKey: process.env.PAYWALLEX_KEY! });
|
|
27
|
+
|
|
28
|
+
const session = await paywallex.checkoutSessions.create({
|
|
29
|
+
currency: "XOF",
|
|
30
|
+
line_items: [
|
|
31
|
+
{ name: "Attiéké poisson", unit_amount: 2500, quantity: 2 },
|
|
32
|
+
{ name: "Jus de bissap", unit_amount: 500 },
|
|
33
|
+
],
|
|
34
|
+
success_url: "https://maboutique.ci/merci",
|
|
35
|
+
metadata: { order: "CMD-1042" },
|
|
36
|
+
});
|
|
37
|
+
// session.amount = 5500, session.url = page de paiement Paywallex
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Les montants sont en XOF entiers : `2500` veut dire 2 500 F.
|
|
41
|
+
|
|
42
|
+
## Vérifier un webhook
|
|
43
|
+
|
|
44
|
+
Vérifiez la signature avant de traiter un événement. Le SDK compare en temps constant et refuse un horodatage de plus de 5 minutes.
|
|
45
|
+
|
|
46
|
+
Passez-lui le corps brut de la requête, pas un objet déjà lu par votre framework :
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
import express from "express";
|
|
50
|
+
import { Paywallex } from "@paywallex/node";
|
|
51
|
+
|
|
52
|
+
const app = express();
|
|
53
|
+
app.post("/webhooks/paywallex", express.raw({ type: "application/json" }), (req, res) => {
|
|
54
|
+
try {
|
|
55
|
+
const event = Paywallex.constructEvent(req.body, req.get("Paywallex-Signature"), process.env.PAYWALLEX_WEBHOOK_SECRET!);
|
|
56
|
+
if (event.type === "checkout_session.completed") { /* livrer la commande */ }
|
|
57
|
+
res.sendStatus(200);
|
|
58
|
+
} catch {
|
|
59
|
+
res.sendStatus(400);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Les webhooks partent des plages `74.220.51.0/24` et `74.220.59.0/24` (liste à jour : `GET /api/developer/webhook-ips`). Ces plages sont partagées : la signature reste la seule preuve.
|
|
65
|
+
|
|
66
|
+
## Les erreurs
|
|
67
|
+
|
|
68
|
+
Chaque erreur est une `PaywallexError` avec un `code` stable. Testez le code, jamais le message :
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
import { PaywallexError } from "@paywallex/node";
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
await paywallex.refunds.create({ payment: "…", amount: 1000 });
|
|
75
|
+
} catch (e) {
|
|
76
|
+
if (e instanceof PaywallexError && e.code === "refund_not_eligible") { /* … */ }
|
|
77
|
+
console.log(e.status, e.code, e.param, e.requestId);
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Passez `language: "en"` au client pour recevoir les messages en anglais.
|
|
82
|
+
|
|
83
|
+
## Nouvelles tentatives et idempotence
|
|
84
|
+
|
|
85
|
+
Le SDK réessaie 2 fois en cas de coupure réseau, de `429` ou d'erreur `5xx`, en respectant `Retry-After`. Chaque `POST` garde la même clé d'idempotence d'une tentative à l'autre : un paiement ou un remboursement n'est jamais doublé.
|
|
86
|
+
|
|
87
|
+
Pour rejouer vous-même une requête sans risque, donnez votre propre clé :
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
await paywallex.refunds.create({ payment: "…" }, { idempotencyKey: "remboursement-CMD-1042" });
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Ce que couvre le SDK
|
|
94
|
+
|
|
95
|
+
| Objet | Méthodes |
|
|
96
|
+
| --- | --- |
|
|
97
|
+
| `checkoutSessions` | `create`, `retrieve` |
|
|
98
|
+
| `payments` | `list`, `retrieve`, `all` (toutes les pages) |
|
|
99
|
+
| `refunds` | `create`, `list`, `retrieve`, `all` |
|
|
100
|
+
| `disputes` | `list`, `retrieve`, `all`, `addEvidence` |
|
|
101
|
+
| `files` | `upload` (justificatifs PDF, PNG, JPEG de 5 Mo au plus) |
|
|
102
|
+
| `payouts`, `events`, `statements` | `list`, `retrieve` (`statements.csv`) |
|
|
103
|
+
| `customers`, `paymentLinks`, `invoices`, `pots` | `list`, `create`, `retrieve`, `update`, `action` |
|
|
104
|
+
| `plans`, `coupons`, `subscriptions` | création, lecture, `cancel`, `pause`, `resume`, `changePlan`, `manageLink` |
|
|
105
|
+
| `webhookEndpoints` | `list`, `create`, `del`, `rotateSecret` |
|
|
106
|
+
| client | `account()`, `balance()`, `networkStatus()` |
|
|
107
|
+
|
|
108
|
+
Accepter un litige (rembourser) ou le contester se fait dans le portail Paywallex, jamais par l'API. Un abonnement ou une hausse de prix est toujours accepté par le client dans Paywallex.
|
|
109
|
+
|
|
110
|
+
## Licence
|
|
111
|
+
|
|
112
|
+
MIT.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
export declare const VERSION = "1.0.0";
|
|
2
|
+
/** Erreur de l'API : testez `code` (stable), jamais le message. */
|
|
3
|
+
export declare class PaywallexError extends Error {
|
|
4
|
+
readonly status: number;
|
|
5
|
+
readonly code: string;
|
|
6
|
+
readonly param: string | null;
|
|
7
|
+
readonly requestId: string | null;
|
|
8
|
+
constructor(message: string, status: number, code: string, param?: string | null, requestId?: string | null);
|
|
9
|
+
}
|
|
10
|
+
export type PaywallexOptions = {
|
|
11
|
+
/** pwx_sk_test_… (bac à sable) ou pwx_sk_live_… (production), ou un jeton OAuth. */
|
|
12
|
+
apiKey: string;
|
|
13
|
+
/** https://api.paywallex.app par défaut. */
|
|
14
|
+
baseUrl?: string;
|
|
15
|
+
/** 30 s par défaut. */
|
|
16
|
+
timeoutMs?: number;
|
|
17
|
+
/** 2 nouvelles tentatives par défaut (réseau, 429, 5xx), toujours avec la même clé d'idempotence. */
|
|
18
|
+
maxRetries?: number;
|
|
19
|
+
/** "fr" (défaut) ou "en" : langue des messages d'erreur. */
|
|
20
|
+
language?: "fr" | "en";
|
|
21
|
+
/** Remplace fetch (tests, proxy). */
|
|
22
|
+
fetch?: typeof fetch;
|
|
23
|
+
};
|
|
24
|
+
export type RequestOptions = {
|
|
25
|
+
idempotencyKey?: string;
|
|
26
|
+
timeoutMs?: number;
|
|
27
|
+
};
|
|
28
|
+
export type Params = Record<string, unknown>;
|
|
29
|
+
export type List<T> = {
|
|
30
|
+
object: "list";
|
|
31
|
+
data: T[];
|
|
32
|
+
has_more?: boolean;
|
|
33
|
+
[k: string]: unknown;
|
|
34
|
+
};
|
|
35
|
+
export type Obj = {
|
|
36
|
+
id: string;
|
|
37
|
+
object: string;
|
|
38
|
+
[k: string]: unknown;
|
|
39
|
+
};
|
|
40
|
+
export type LineItem = {
|
|
41
|
+
name: string;
|
|
42
|
+
unit_amount: number;
|
|
43
|
+
quantity?: number;
|
|
44
|
+
};
|
|
45
|
+
export type CheckoutSessionCreate = {
|
|
46
|
+
currency: "XOF";
|
|
47
|
+
amount?: number;
|
|
48
|
+
line_items?: LineItem[];
|
|
49
|
+
description?: string;
|
|
50
|
+
customer_email?: string;
|
|
51
|
+
customer_name?: string;
|
|
52
|
+
metadata?: Record<string, string>;
|
|
53
|
+
success_url?: string;
|
|
54
|
+
cancel_url?: string;
|
|
55
|
+
expires_at?: number;
|
|
56
|
+
payment_methods?: string[];
|
|
57
|
+
[k: string]: unknown;
|
|
58
|
+
};
|
|
59
|
+
export type CheckoutSession = Obj & {
|
|
60
|
+
url: string;
|
|
61
|
+
status: "open" | "complete" | "expired";
|
|
62
|
+
amount: number;
|
|
63
|
+
currency: string;
|
|
64
|
+
payment_id: string | null;
|
|
65
|
+
};
|
|
66
|
+
export type Payment = Obj & {
|
|
67
|
+
amount: number;
|
|
68
|
+
amount_charged: number;
|
|
69
|
+
fee: number;
|
|
70
|
+
net: number;
|
|
71
|
+
amount_refunded: number;
|
|
72
|
+
currency: string;
|
|
73
|
+
status: string;
|
|
74
|
+
method: string | null;
|
|
75
|
+
fee_details: {
|
|
76
|
+
paywallex: number;
|
|
77
|
+
network: number;
|
|
78
|
+
tax: number;
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
export type NetworkStatus = {
|
|
82
|
+
object: "network_status";
|
|
83
|
+
checked_at: number;
|
|
84
|
+
data: {
|
|
85
|
+
network: string;
|
|
86
|
+
status: "operational" | "degraded" | "unavailable";
|
|
87
|
+
success_rate_30m: number | null;
|
|
88
|
+
last_success_at: number | null;
|
|
89
|
+
}[];
|
|
90
|
+
};
|
|
91
|
+
export type WebhookEvent<T = Record<string, unknown>> = {
|
|
92
|
+
id: string;
|
|
93
|
+
type: string;
|
|
94
|
+
created: string | number;
|
|
95
|
+
environment: "sandbox" | "live";
|
|
96
|
+
data: T;
|
|
97
|
+
[k: string]: unknown;
|
|
98
|
+
};
|
|
99
|
+
export declare class Paywallex {
|
|
100
|
+
readonly checkoutSessions: {
|
|
101
|
+
create: (p: CheckoutSessionCreate, o?: RequestOptions) => Promise<CheckoutSession>;
|
|
102
|
+
retrieve: (id: string) => Promise<CheckoutSession>;
|
|
103
|
+
};
|
|
104
|
+
readonly payments: {
|
|
105
|
+
list: (p?: {
|
|
106
|
+
limit?: number;
|
|
107
|
+
starting_after?: string;
|
|
108
|
+
created?: string;
|
|
109
|
+
}) => Promise<List<Payment>>;
|
|
110
|
+
retrieve: (id: string) => Promise<Payment>;
|
|
111
|
+
/** Parcourt toutes les pages : for await (const p of paywallex.payments.all()) … */
|
|
112
|
+
all: (p?: {
|
|
113
|
+
limit?: number;
|
|
114
|
+
}) => AsyncGenerator<Payment, any, any>;
|
|
115
|
+
};
|
|
116
|
+
readonly refunds: {
|
|
117
|
+
create: (p: {
|
|
118
|
+
payment: string;
|
|
119
|
+
amount?: number;
|
|
120
|
+
reason?: string;
|
|
121
|
+
}, o?: RequestOptions) => Promise<Obj>;
|
|
122
|
+
list: (p?: {
|
|
123
|
+
payment?: string;
|
|
124
|
+
limit?: number;
|
|
125
|
+
starting_after?: string;
|
|
126
|
+
}) => Promise<List<Obj>>;
|
|
127
|
+
retrieve: (id: string) => Promise<Obj>;
|
|
128
|
+
all: (p?: {
|
|
129
|
+
payment?: string;
|
|
130
|
+
limit?: number;
|
|
131
|
+
}) => AsyncGenerator<Obj, any, any>;
|
|
132
|
+
};
|
|
133
|
+
readonly disputes: {
|
|
134
|
+
list: (p?: {
|
|
135
|
+
limit?: number;
|
|
136
|
+
starting_after?: string;
|
|
137
|
+
}) => Promise<List<Obj>>;
|
|
138
|
+
retrieve: (id: string) => Promise<Obj>;
|
|
139
|
+
all: (p?: {
|
|
140
|
+
limit?: number;
|
|
141
|
+
}) => AsyncGenerator<Obj, any, any>;
|
|
142
|
+
/** Ajoute un justificatif (texte + fichiers déposés avec files.upload). Accepter ou contester se fait dans le portail. */
|
|
143
|
+
addEvidence: (id: string, p: {
|
|
144
|
+
text: string;
|
|
145
|
+
files?: string[];
|
|
146
|
+
}, o?: RequestOptions) => Promise<Obj>;
|
|
147
|
+
};
|
|
148
|
+
readonly files: {
|
|
149
|
+
/** Dépose un PDF, PNG ou JPEG (5 Mo max) et renvoie son id, à passer dans disputes.addEvidence. */
|
|
150
|
+
upload: (p: {
|
|
151
|
+
name: string;
|
|
152
|
+
contentType: "application/pdf" | "image/png" | "image/jpeg";
|
|
153
|
+
data: Uint8Array | ArrayBuffer;
|
|
154
|
+
}) => Promise<{
|
|
155
|
+
id: string;
|
|
156
|
+
upload_url: string;
|
|
157
|
+
}>;
|
|
158
|
+
};
|
|
159
|
+
readonly payouts: {
|
|
160
|
+
list: (p?: Params) => Promise<List<Obj>>;
|
|
161
|
+
retrieve: (id: string) => Promise<Obj>;
|
|
162
|
+
};
|
|
163
|
+
readonly events: {
|
|
164
|
+
list: (p?: {
|
|
165
|
+
type?: string;
|
|
166
|
+
limit?: number;
|
|
167
|
+
starting_after?: string;
|
|
168
|
+
}) => Promise<List<Obj>>;
|
|
169
|
+
retrieve: (id: string) => Promise<Obj>;
|
|
170
|
+
};
|
|
171
|
+
readonly customers: {
|
|
172
|
+
list: (p?: Params) => Promise<List<Obj>>;
|
|
173
|
+
create: (p: Params, o?: RequestOptions) => Promise<Obj>;
|
|
174
|
+
retrieve: (id: string) => Promise<Obj>;
|
|
175
|
+
update: (id: string, p: Params, o?: RequestOptions) => Promise<Obj>;
|
|
176
|
+
action: (id: string, action: string, p?: Params, o?: RequestOptions) => Promise<Obj>;
|
|
177
|
+
};
|
|
178
|
+
readonly paymentLinks: {
|
|
179
|
+
list: (p?: Params) => Promise<List<Obj>>;
|
|
180
|
+
create: (p: Params, o?: RequestOptions) => Promise<Obj>;
|
|
181
|
+
retrieve: (id: string) => Promise<Obj>;
|
|
182
|
+
update: (id: string, p: Params, o?: RequestOptions) => Promise<Obj>;
|
|
183
|
+
action: (id: string, action: string, p?: Params, o?: RequestOptions) => Promise<Obj>;
|
|
184
|
+
};
|
|
185
|
+
readonly invoices: {
|
|
186
|
+
list: (p?: Params) => Promise<List<Obj>>;
|
|
187
|
+
create: (p: Params, o?: RequestOptions) => Promise<Obj>;
|
|
188
|
+
retrieve: (id: string) => Promise<Obj>;
|
|
189
|
+
update: (id: string, p: Params, o?: RequestOptions) => Promise<Obj>;
|
|
190
|
+
action: (id: string, action: string, p?: Params, o?: RequestOptions) => Promise<Obj>;
|
|
191
|
+
};
|
|
192
|
+
readonly pots: {
|
|
193
|
+
list: (p?: Params) => Promise<List<Obj>>;
|
|
194
|
+
create: (p: Params, o?: RequestOptions) => Promise<Obj>;
|
|
195
|
+
retrieve: (id: string) => Promise<Obj>;
|
|
196
|
+
update: (id: string, p: Params, o?: RequestOptions) => Promise<Obj>;
|
|
197
|
+
action: (id: string, action: string, p?: Params, o?: RequestOptions) => Promise<Obj>;
|
|
198
|
+
};
|
|
199
|
+
readonly statements: {
|
|
200
|
+
list: (p?: Params) => Promise<List<Obj>>;
|
|
201
|
+
retrieve: (id: string) => Promise<Obj>;
|
|
202
|
+
csv: (id: string) => Promise<string>;
|
|
203
|
+
};
|
|
204
|
+
readonly plans: {
|
|
205
|
+
list: (p?: Params) => Promise<List<Obj>>;
|
|
206
|
+
create: (p: Params, o?: RequestOptions) => Promise<Obj>;
|
|
207
|
+
retrieve: (id: string) => Promise<Obj>;
|
|
208
|
+
};
|
|
209
|
+
readonly coupons: {
|
|
210
|
+
list: (p?: Params) => Promise<List<Obj>>;
|
|
211
|
+
create: (p: Params, o?: RequestOptions) => Promise<Obj>;
|
|
212
|
+
archive: (id: string) => Promise<Obj>;
|
|
213
|
+
};
|
|
214
|
+
readonly subscriptions: {
|
|
215
|
+
create: (p: Params, o?: RequestOptions) => Promise<Obj>;
|
|
216
|
+
list: (p?: Params) => Promise<List<Obj>>;
|
|
217
|
+
retrieve: (id: string) => Promise<Obj>;
|
|
218
|
+
cancel: (id: string, p?: Params) => Promise<Obj>;
|
|
219
|
+
pause: (id: string, p?: Params) => Promise<Obj>;
|
|
220
|
+
resume: (id: string, p?: Params) => Promise<Obj>;
|
|
221
|
+
/** Une hausse doit être acceptée par l'abonné dans Paywallex. */
|
|
222
|
+
changePlan: (id: string, p: {
|
|
223
|
+
plan: string;
|
|
224
|
+
proration?: "next_period" | "immediate";
|
|
225
|
+
}) => Promise<Obj>;
|
|
226
|
+
manageLink: (id: string) => Promise<Obj & {
|
|
227
|
+
url: string;
|
|
228
|
+
}>;
|
|
229
|
+
};
|
|
230
|
+
readonly webhookEndpoints: {
|
|
231
|
+
list: () => Promise<List<Obj>>;
|
|
232
|
+
create: (p: {
|
|
233
|
+
url: string;
|
|
234
|
+
events?: string[];
|
|
235
|
+
}, o?: RequestOptions) => Promise<Obj & {
|
|
236
|
+
secret: string;
|
|
237
|
+
}>;
|
|
238
|
+
del: (id: string) => Promise<Obj>;
|
|
239
|
+
rotateSecret: (id: string) => Promise<Obj & {
|
|
240
|
+
secret: string;
|
|
241
|
+
}>;
|
|
242
|
+
};
|
|
243
|
+
readonly webhooks: {
|
|
244
|
+
constructEvent: typeof Paywallex.constructEvent;
|
|
245
|
+
};
|
|
246
|
+
private base;
|
|
247
|
+
private opts;
|
|
248
|
+
constructor(options: PaywallexOptions | string);
|
|
249
|
+
account(): Promise<Obj>;
|
|
250
|
+
balance(): Promise<Obj>;
|
|
251
|
+
/** Réseaux Mobile Money du pays : masquez ceux qui sont « unavailable » avant le paiement. */
|
|
252
|
+
networkStatus(): Promise<NetworkStatus>;
|
|
253
|
+
/**
|
|
254
|
+
* Vérifie la signature Paywallex-Signature (t=…,v1=…) et renvoie l'événement.
|
|
255
|
+
* Passez le corps BRUT de la requête (chaîne ou Buffer), jamais un objet déjà lu.
|
|
256
|
+
*/
|
|
257
|
+
static constructEvent<T = Record<string, unknown>>(rawBody: string | Uint8Array, signatureHeader: string | null | undefined, secret: string, toleranceSeconds?: number): WebhookEvent<T>;
|
|
258
|
+
paginate<T extends {
|
|
259
|
+
id: string;
|
|
260
|
+
}>(path: string, params?: Params): AsyncGenerator<T>;
|
|
261
|
+
get<T>(path: string, params?: Params): Promise<T>;
|
|
262
|
+
post<T>(path: string, body: unknown, o?: RequestOptions): Promise<T>;
|
|
263
|
+
private raw;
|
|
264
|
+
private headers;
|
|
265
|
+
request<T>(method: "GET" | "POST" | "DELETE", path: string, body?: unknown, o?: RequestOptions): Promise<T>;
|
|
266
|
+
}
|
|
267
|
+
export default Paywallex;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// @paywallex/node : SDK serveur officiel de l'API Paywallex /v1 (Node 18+, Bun, Deno).
|
|
2
|
+
// Côté serveur uniquement : ne mettez jamais une clé secrète pwx_sk_… dans un navigateur ou une app mobile.
|
|
3
|
+
// Référence : https://api.paywallex.app/api/developer/openapi-v1.json. Signé : Superagent (DV3, 24 oct. 2026).
|
|
4
|
+
import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
|
|
5
|
+
export const VERSION = "1.0.0";
|
|
6
|
+
/** Erreur de l'API : testez `code` (stable), jamais le message. */
|
|
7
|
+
export class PaywallexError extends Error {
|
|
8
|
+
status;
|
|
9
|
+
code;
|
|
10
|
+
param;
|
|
11
|
+
requestId;
|
|
12
|
+
constructor(message, status, code, param = null, requestId = null) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = "PaywallexError";
|
|
15
|
+
this.status = status;
|
|
16
|
+
this.code = code;
|
|
17
|
+
this.param = param;
|
|
18
|
+
this.requestId = requestId;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
22
|
+
const enc = encodeURIComponent;
|
|
23
|
+
function query(params) {
|
|
24
|
+
if (!params)
|
|
25
|
+
return "";
|
|
26
|
+
const q = new URLSearchParams();
|
|
27
|
+
for (const [k, v] of Object.entries(params))
|
|
28
|
+
if (v !== undefined && v !== null)
|
|
29
|
+
q.set(k, String(v));
|
|
30
|
+
const s = q.toString();
|
|
31
|
+
return s ? `?${s}` : "";
|
|
32
|
+
}
|
|
33
|
+
export class Paywallex {
|
|
34
|
+
checkoutSessions;
|
|
35
|
+
payments;
|
|
36
|
+
refunds;
|
|
37
|
+
disputes;
|
|
38
|
+
files;
|
|
39
|
+
payouts;
|
|
40
|
+
events;
|
|
41
|
+
customers;
|
|
42
|
+
paymentLinks;
|
|
43
|
+
invoices;
|
|
44
|
+
pots;
|
|
45
|
+
statements;
|
|
46
|
+
plans;
|
|
47
|
+
coupons;
|
|
48
|
+
subscriptions;
|
|
49
|
+
webhookEndpoints;
|
|
50
|
+
webhooks;
|
|
51
|
+
base;
|
|
52
|
+
opts;
|
|
53
|
+
constructor(options) {
|
|
54
|
+
const o = typeof options === "string" ? { apiKey: options } : options;
|
|
55
|
+
if (!o?.apiKey)
|
|
56
|
+
throw new PaywallexError("apiKey manquante : passez votre clé secrète pwx_sk_… explicitement.", 0, "configuration_error");
|
|
57
|
+
let base = o.baseUrl ?? "https://api.paywallex.app";
|
|
58
|
+
while (base.endsWith("/"))
|
|
59
|
+
base = base.slice(0, -1);
|
|
60
|
+
this.base = base;
|
|
61
|
+
this.opts = { apiKey: o.apiKey, timeoutMs: o.timeoutMs ?? 30000, maxRetries: o.maxRetries ?? 2, language: o.language ?? "fr", fetch: o.fetch ?? globalThis.fetch };
|
|
62
|
+
const r = this;
|
|
63
|
+
const crud = (path, actions = []) => ({
|
|
64
|
+
list: (p) => r.get(`/v1/${path}`, p),
|
|
65
|
+
create: (p, o) => r.post(`/v1/${path}`, p, o),
|
|
66
|
+
retrieve: (id) => r.get(`/v1/${path}/${enc(id)}`),
|
|
67
|
+
update: (id, p, o) => r.post(`/v1/${path}/${enc(id)}`, p, o),
|
|
68
|
+
action: (id, action, p = {}, o) => {
|
|
69
|
+
if (!actions.includes(action))
|
|
70
|
+
throw new PaywallexError(`Action inconnue : ${action}. Possibles : ${actions.join(", ")}.`, 0, "configuration_error");
|
|
71
|
+
return r.post(`/v1/${path}/${enc(id)}/${action}`, p, o);
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
this.checkoutSessions = {
|
|
75
|
+
create: (p, o) => r.post("/v1/checkout_sessions", p, o),
|
|
76
|
+
retrieve: (id) => r.get(`/v1/checkout_sessions/${enc(id)}`),
|
|
77
|
+
};
|
|
78
|
+
this.payments = {
|
|
79
|
+
list: (p) => r.get("/v1/payments", p),
|
|
80
|
+
retrieve: (id) => r.get(`/v1/payments/${enc(id)}`),
|
|
81
|
+
/** Parcourt toutes les pages : for await (const p of paywallex.payments.all()) … */
|
|
82
|
+
all: (p) => r.paginate("/v1/payments", p),
|
|
83
|
+
};
|
|
84
|
+
this.refunds = {
|
|
85
|
+
create: (p, o) => r.post("/v1/refunds", p, o),
|
|
86
|
+
list: (p) => r.get("/v1/refunds", p),
|
|
87
|
+
retrieve: (id) => r.get(`/v1/refunds/${enc(id)}`),
|
|
88
|
+
all: (p) => r.paginate("/v1/refunds", p),
|
|
89
|
+
};
|
|
90
|
+
this.disputes = {
|
|
91
|
+
list: (p) => r.get("/v1/disputes", p),
|
|
92
|
+
retrieve: (id) => r.get(`/v1/disputes/${enc(id)}`),
|
|
93
|
+
all: (p) => r.paginate("/v1/disputes", p),
|
|
94
|
+
/** Ajoute un justificatif (texte + fichiers déposés avec files.upload). Accepter ou contester se fait dans le portail. */
|
|
95
|
+
addEvidence: (id, p, o) => r.post(`/v1/disputes/${enc(id)}/evidence`, p, o),
|
|
96
|
+
};
|
|
97
|
+
this.files = {
|
|
98
|
+
/** Dépose un PDF, PNG ou JPEG (5 Mo max) et renvoie son id, à passer dans disputes.addEvidence. */
|
|
99
|
+
upload: async (p) => {
|
|
100
|
+
const bytes = p.data instanceof Uint8Array ? p.data : new Uint8Array(p.data);
|
|
101
|
+
const f = await r.post("/v1/files", { name: p.name, content_type: p.contentType, size: bytes.byteLength });
|
|
102
|
+
const put = await r.opts.fetch(f.upload_url, { method: "PUT", headers: { "Content-Type": p.contentType }, body: bytes,
|
|
103
|
+
signal: AbortSignal.timeout(r.opts.timeoutMs) });
|
|
104
|
+
if (!put.ok)
|
|
105
|
+
throw new PaywallexError(`Dépôt du fichier refusé (${put.status}).`, put.status, "file_upload_failed");
|
|
106
|
+
return f;
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
this.payouts = { list: (p) => r.get("/v1/payouts", p), retrieve: (id) => r.get(`/v1/payouts/${enc(id)}`) };
|
|
110
|
+
this.events = {
|
|
111
|
+
list: (p) => r.get("/v1/events", p),
|
|
112
|
+
retrieve: (id) => r.get(`/v1/events/${enc(id)}`),
|
|
113
|
+
};
|
|
114
|
+
this.customers = crud("customers", ["archive"]);
|
|
115
|
+
this.paymentLinks = crud("payment_links", ["cancel"]);
|
|
116
|
+
this.invoices = crud("invoices", ["issue", "cancel"]);
|
|
117
|
+
this.pots = crud("pots", ["close", "cancel"]);
|
|
118
|
+
this.statements = {
|
|
119
|
+
list: (p) => r.get("/v1/statements", p),
|
|
120
|
+
retrieve: (id) => r.get(`/v1/statements/${enc(id)}`),
|
|
121
|
+
csv: (id) => r.raw("GET", `/v1/statements/${enc(id)}/csv`).then((res) => res.text()),
|
|
122
|
+
};
|
|
123
|
+
this.plans = { list: (p) => r.get("/v1/plans", p), create: (p, o) => r.post("/v1/plans", p, o),
|
|
124
|
+
retrieve: (id) => r.get(`/v1/plans/${enc(id)}`) };
|
|
125
|
+
this.coupons = { list: (p) => r.get("/v1/coupons", p), create: (p, o) => r.post("/v1/coupons", p, o),
|
|
126
|
+
archive: (id) => r.post(`/v1/coupons/${enc(id)}/archive`, {}) };
|
|
127
|
+
this.subscriptions = {
|
|
128
|
+
create: (p, o) => r.post("/v1/subscriptions", p, o),
|
|
129
|
+
list: (p) => r.get("/v1/subscriptions", p),
|
|
130
|
+
retrieve: (id) => r.get(`/v1/subscriptions/${enc(id)}`),
|
|
131
|
+
cancel: (id, p = {}) => r.post(`/v1/subscriptions/${enc(id)}/cancel`, p),
|
|
132
|
+
pause: (id, p = {}) => r.post(`/v1/subscriptions/${enc(id)}/pause`, p),
|
|
133
|
+
resume: (id, p = {}) => r.post(`/v1/subscriptions/${enc(id)}/resume`, p),
|
|
134
|
+
/** Une hausse doit être acceptée par l'abonné dans Paywallex. */
|
|
135
|
+
changePlan: (id, p) => r.post(`/v1/subscriptions/${enc(id)}/change_plan`, p),
|
|
136
|
+
manageLink: (id) => r.post(`/v1/subscriptions/${enc(id)}/manage_link`, {}),
|
|
137
|
+
};
|
|
138
|
+
this.webhookEndpoints = {
|
|
139
|
+
list: () => r.get("/v1/webhook_endpoints"),
|
|
140
|
+
create: (p, o) => r.post("/v1/webhook_endpoints", p, o),
|
|
141
|
+
del: (id) => r.request("DELETE", `/v1/webhook_endpoints/${enc(id)}`),
|
|
142
|
+
rotateSecret: (id) => r.post(`/v1/webhook_endpoints/${enc(id)}/rotate_secret`, {}),
|
|
143
|
+
};
|
|
144
|
+
this.webhooks = { constructEvent: Paywallex.constructEvent };
|
|
145
|
+
}
|
|
146
|
+
account() { return this.get("/v1/account"); }
|
|
147
|
+
balance() { return this.get("/v1/balance"); }
|
|
148
|
+
/** Réseaux Mobile Money du pays : masquez ceux qui sont « unavailable » avant le paiement. */
|
|
149
|
+
networkStatus() { return this.get("/v1/network_status"); }
|
|
150
|
+
/**
|
|
151
|
+
* Vérifie la signature Paywallex-Signature (t=…,v1=…) et renvoie l'événement.
|
|
152
|
+
* Passez le corps BRUT de la requête (chaîne ou Buffer), jamais un objet déjà lu.
|
|
153
|
+
*/
|
|
154
|
+
static constructEvent(rawBody, signatureHeader, secret, toleranceSeconds = 300) {
|
|
155
|
+
const m = /^t=([0-9]+),v1=([a-f0-9]{64})$/.exec((signatureHeader ?? "").trim());
|
|
156
|
+
if (!m)
|
|
157
|
+
throw new PaywallexError("Signature absente ou mal formée.", 400, "invalid_signature");
|
|
158
|
+
if (Math.abs(Date.now() / 1000 - Number(m[1])) > toleranceSeconds)
|
|
159
|
+
throw new PaywallexError("Signature expirée.", 400, "signature_expired");
|
|
160
|
+
const body = typeof rawBody === "string" ? rawBody : new TextDecoder().decode(rawBody);
|
|
161
|
+
const expected = createHmac("sha256", secret).update(`${m[1]}.${body}`).digest();
|
|
162
|
+
const given = Buffer.from(m[2], "hex");
|
|
163
|
+
if (given.length !== expected.length || !timingSafeEqual(given, expected))
|
|
164
|
+
throw new PaywallexError("Signature invalide.", 400, "invalid_signature");
|
|
165
|
+
return JSON.parse(body);
|
|
166
|
+
}
|
|
167
|
+
async *paginate(path, params = {}) {
|
|
168
|
+
let after;
|
|
169
|
+
for (let page = 0; page < 10000; page++) {
|
|
170
|
+
const list = await this.get(path, { ...params, ...(after ? { starting_after: after } : {}) });
|
|
171
|
+
for (const item of list.data)
|
|
172
|
+
yield item;
|
|
173
|
+
if (!list.has_more || !list.data.length)
|
|
174
|
+
return;
|
|
175
|
+
after = list.data[list.data.length - 1].id;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
get(path, params) { return this.request("GET", path + query(params)); }
|
|
179
|
+
post(path, body, o) { return this.request("POST", path, body, o); }
|
|
180
|
+
raw(method, path) {
|
|
181
|
+
return this.opts.fetch(this.base + path, { method, headers: this.headers(), signal: AbortSignal.timeout(this.opts.timeoutMs) });
|
|
182
|
+
}
|
|
183
|
+
headers(extra = {}) {
|
|
184
|
+
return { Authorization: `Bearer ${this.opts.apiKey}`, "Accept-Language": this.opts.language, "User-Agent": `paywallex-node/${VERSION}`, "Paywallex-Sdk": `node/${VERSION}`, ...extra };
|
|
185
|
+
}
|
|
186
|
+
async request(method, path, body, o = {}) {
|
|
187
|
+
// Une seule clé d'idempotence pour toutes les tentatives : un POST rejoué n'est jamais doublé.
|
|
188
|
+
const idem = method === "POST" ? (o.idempotencyKey ?? randomUUID()) : undefined;
|
|
189
|
+
for (let attempt = 0;; attempt++) {
|
|
190
|
+
let res;
|
|
191
|
+
try {
|
|
192
|
+
res = await this.opts.fetch(this.base + path, {
|
|
193
|
+
method, signal: AbortSignal.timeout(o.timeoutMs ?? this.opts.timeoutMs),
|
|
194
|
+
headers: this.headers({ ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...(idem ? { "Idempotency-Key": idem } : {}) }),
|
|
195
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
catch (e) {
|
|
199
|
+
if (attempt < this.opts.maxRetries) {
|
|
200
|
+
await sleep(500 * 2 ** attempt);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
throw new PaywallexError(`Paywallex injoignable : ${e instanceof Error ? e.message : String(e)}`, 0, "network_error");
|
|
204
|
+
}
|
|
205
|
+
const text = await res.text();
|
|
206
|
+
let data = {};
|
|
207
|
+
try {
|
|
208
|
+
data = text ? JSON.parse(text) : {};
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
data = {};
|
|
212
|
+
}
|
|
213
|
+
if (res.ok)
|
|
214
|
+
return data;
|
|
215
|
+
if ((res.status === 429 || res.status >= 500) && attempt < this.opts.maxRetries) {
|
|
216
|
+
const ra = Number(res.headers.get("Retry-After"));
|
|
217
|
+
await sleep(Number.isFinite(ra) && ra > 0 ? Math.min(ra, 30) * 1000 : 500 * 2 ** attempt);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
const e = data.error;
|
|
221
|
+
throw new PaywallexError(e?.message ?? `Requête refusée (${res.status}).`, res.status, e?.code ?? "unknown_error", e?.param ?? null, e?.request_id ?? res.headers.get("Request-Id"));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
export default Paywallex;
|
package/package.json
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@paywallex/node",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "SDK serveur officiel de l'API Paywallex /v1 : sessions de paiement Mobile Money, remboursements, litiges, abonnements, webhooks signés.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
|
|
9
|
+
"files": ["dist", "README.md", "LICENSE"],
|
|
10
|
+
"engines": { "node": ">=18" },
|
|
11
|
+
"sideEffects": false,
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"homepage": "https://paywallex.app/developpeurs",
|
|
14
|
+
"repository": { "type": "git", "url": "git+https://github.com/Paywallex/paywallex-kit.git", "directory": "sdk/node" },
|
|
15
|
+
"keywords": ["paywallex", "paiement", "mobile money", "orange money", "mtn momo", "moov money", "wave", "xof", "afrique de l'ouest", "api"],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsc -p tsconfig.json",
|
|
18
|
+
"test": "node --test test/*.test.mjs",
|
|
19
|
+
"prepublishOnly": "npm run build && npm test"
|
|
20
|
+
}
|
|
21
|
+
}
|