@wiicode/youcanpay-sdk 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/README.md +848 -0
- package/dist/client.d.ts +17 -0
- package/dist/client.js +171 -0
- package/dist/client.js.map +1 -0
- package/dist/constants.d.ts +8 -0
- package/dist/constants.js +12 -0
- package/dist/constants.js.map +1 -0
- package/dist/enums/currency.enum.d.ts +8 -0
- package/dist/enums/currency.enum.js +13 -0
- package/dist/enums/currency.enum.js.map +1 -0
- package/dist/enums/index.d.ts +2 -0
- package/dist/enums/index.js +19 -0
- package/dist/enums/index.js.map +1 -0
- package/dist/enums/lang.enum.d.ts +5 -0
- package/dist/enums/lang.enum.js +10 -0
- package/dist/enums/lang.enum.js.map +1 -0
- package/dist/errors/index.d.ts +1 -0
- package/dist/errors/index.js +18 -0
- package/dist/errors/index.js.map +1 -0
- package/dist/errors/youcanpay.error.d.ts +16 -0
- package/dist/errors/youcanpay.error.js +32 -0
- package/dist/errors/youcanpay.error.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +57 -0
- package/dist/index.js.map +1 -0
- package/dist/interfaces/index.d.ts +5 -0
- package/dist/interfaces/index.js +22 -0
- package/dist/interfaces/index.js.map +1 -0
- package/dist/interfaces/options.interface.d.ts +13 -0
- package/dist/interfaces/options.interface.js +3 -0
- package/dist/interfaces/options.interface.js.map +1 -0
- package/dist/interfaces/payment.interface.d.ts +22 -0
- package/dist/interfaces/payment.interface.js +3 -0
- package/dist/interfaces/payment.interface.js.map +1 -0
- package/dist/interfaces/token.interface.d.ts +26 -0
- package/dist/interfaces/token.interface.js +3 -0
- package/dist/interfaces/token.interface.js.map +1 -0
- package/dist/interfaces/transaction.interface.d.ts +10 -0
- package/dist/interfaces/transaction.interface.js +3 -0
- package/dist/interfaces/transaction.interface.js.map +1 -0
- package/dist/interfaces/webhook.interface.d.ts +16 -0
- package/dist/interfaces/webhook.interface.js +10 -0
- package/dist/interfaces/webhook.interface.js.map +1 -0
- package/dist/logging/index.d.ts +3 -0
- package/dist/logging/index.js +20 -0
- package/dist/logging/index.js.map +1 -0
- package/dist/logging/interfaces.d.ts +22 -0
- package/dist/logging/interfaces.js +3 -0
- package/dist/logging/interfaces.js.map +1 -0
- package/dist/logging/logger.d.ts +6 -0
- package/dist/logging/logger.js +37 -0
- package/dist/logging/logger.js.map +1 -0
- package/dist/logging/sanitizer.d.ts +1 -0
- package/dist/logging/sanitizer.js +55 -0
- package/dist/logging/sanitizer.js.map +1 -0
- package/dist/nestjs/decorators.d.ts +2 -0
- package/dist/nestjs/decorators.js +8 -0
- package/dist/nestjs/decorators.js.map +1 -0
- package/dist/nestjs/guards/index.d.ts +1 -0
- package/dist/nestjs/guards/index.js +9 -0
- package/dist/nestjs/guards/index.js.map +1 -0
- package/dist/nestjs/guards/webhook.guard.d.ts +16 -0
- package/dist/nestjs/guards/webhook.guard.js +59 -0
- package/dist/nestjs/guards/webhook.guard.js.map +1 -0
- package/dist/nestjs/index.d.ts +5 -0
- package/dist/nestjs/index.js +22 -0
- package/dist/nestjs/index.js.map +1 -0
- package/dist/nestjs/pipes/index.d.ts +1 -0
- package/dist/nestjs/pipes/index.js +7 -0
- package/dist/nestjs/pipes/index.js.map +1 -0
- package/dist/nestjs/pipes/webhook.pipe.d.ts +6 -0
- package/dist/nestjs/pipes/webhook.pipe.js +34 -0
- package/dist/nestjs/pipes/webhook.pipe.js.map +1 -0
- package/dist/nestjs/youcanpay.module.d.ts +6 -0
- package/dist/nestjs/youcanpay.module.js +48 -0
- package/dist/nestjs/youcanpay.module.js.map +1 -0
- package/dist/nestjs/youcanpay.service.d.ts +5 -0
- package/dist/nestjs/youcanpay.service.js +30 -0
- package/dist/nestjs/youcanpay.service.js.map +1 -0
- package/dist/security/index.d.ts +2 -0
- package/dist/security/index.js +24 -0
- package/dist/security/index.js.map +1 -0
- package/dist/security/validators.d.ts +37 -0
- package/dist/security/validators.js +183 -0
- package/dist/security/validators.js.map +1 -0
- package/dist/security/webhook.d.ts +80 -0
- package/dist/security/webhook.js +146 -0
- package/dist/security/webhook.js.map +1 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +49 -0
package/README.md
ADDED
|
@@ -0,0 +1,848 @@
|
|
|
1
|
+
# YouCanPay SDK
|
|
2
|
+
|
|
3
|
+
Production-ready Node.js SDK for [YouCanPay](https://youcanpay.com) - Morocco's payment gateway.
|
|
4
|
+
|
|
5
|
+
Works with **any Node.js framework** (Express, Fastify, Hapi) and has first-class **NestJS integration**.
|
|
6
|
+
|
|
7
|
+
## Table of Contents
|
|
8
|
+
|
|
9
|
+
- [Installation](#installation)
|
|
10
|
+
- [Environment Setup](#environment-setup)
|
|
11
|
+
- [Quick Start](#quick-start)
|
|
12
|
+
- [Complete Payment Flow](#complete-payment-flow)
|
|
13
|
+
- [API Reference](#api-reference)
|
|
14
|
+
- [Webhook Handling](#webhook-handling)
|
|
15
|
+
- [Database Integration](#database-integration)
|
|
16
|
+
- [Validation & Security](#validation--security)
|
|
17
|
+
- [Error Handling](#error-handling)
|
|
18
|
+
- [Testing](#testing)
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm install youcanpay-sdk
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
yarn add youcanpay-sdk
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Peer Dependencies
|
|
33
|
+
|
|
34
|
+
For **NestJS** integration, ensure you have:
|
|
35
|
+
```bash
|
|
36
|
+
npm install @nestjs/common @nestjs/core
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Environment Setup
|
|
42
|
+
|
|
43
|
+
### Required Variables
|
|
44
|
+
|
|
45
|
+
```env
|
|
46
|
+
# YouCanPay API Credentials
|
|
47
|
+
YCP_PRIVATE_KEY=pri_sandbox_xxxxx # Your private key
|
|
48
|
+
YCP_PUBLIC_KEY=pub_sandbox_xxxxx # Your public key
|
|
49
|
+
YCP_SANDBOX=true # true = sandbox, false = production
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Optional Variables
|
|
53
|
+
|
|
54
|
+
```env
|
|
55
|
+
# Webhook security (generate a random string)
|
|
56
|
+
YCP_WEBHOOK_SECRET=your_random_secret_here
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Where to Get API Keys
|
|
60
|
+
|
|
61
|
+
1. Go to [YouCanPay Dashboard](https://youcanpay.com)
|
|
62
|
+
2. Create an account or login
|
|
63
|
+
3. Navigate to **Settings > API Keys**
|
|
64
|
+
4. Copy your **Sandbox** keys for testing or **Live** keys for production
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## Quick Start
|
|
69
|
+
|
|
70
|
+
### Plain Node.js / Express
|
|
71
|
+
|
|
72
|
+
```typescript
|
|
73
|
+
import { YouCanPayClient, CurrencyCode } from 'youcanpay-sdk';
|
|
74
|
+
|
|
75
|
+
// Initialize client
|
|
76
|
+
const client = new YouCanPayClient({
|
|
77
|
+
privateKey: process.env.YCP_PRIVATE_KEY!,
|
|
78
|
+
publicKey: process.env.YCP_PUBLIC_KEY!,
|
|
79
|
+
sandbox: process.env.YCP_SANDBOX === 'true',
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Create a payment
|
|
83
|
+
async function createPayment() {
|
|
84
|
+
const { token } = await client.createToken({
|
|
85
|
+
orderId: 'order-123',
|
|
86
|
+
amount: 50000, // 500.00 MAD (in centimes)
|
|
87
|
+
currency: CurrencyCode.MAD,
|
|
88
|
+
customerIp: '192.168.1.1',
|
|
89
|
+
successUrl: 'https://myapp.com/payment/success',
|
|
90
|
+
errorUrl: 'https://myapp.com/payment/error',
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Redirect user to YouCanPay checkout
|
|
94
|
+
const paymentUrl = client.getPaymentUrl(token.id);
|
|
95
|
+
return paymentUrl;
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### NestJS Integration
|
|
100
|
+
|
|
101
|
+
#### Option 1: Static Configuration
|
|
102
|
+
|
|
103
|
+
```typescript
|
|
104
|
+
// app.module.ts
|
|
105
|
+
import { Module } from '@nestjs/common';
|
|
106
|
+
import { YouCanPayModule } from 'youcanpay-sdk';
|
|
107
|
+
|
|
108
|
+
@Module({
|
|
109
|
+
imports: [
|
|
110
|
+
YouCanPayModule.forRoot({
|
|
111
|
+
privateKey: 'pri_sandbox_xxxxx',
|
|
112
|
+
publicKey: 'pub_sandbox_xxxxx',
|
|
113
|
+
sandbox: true,
|
|
114
|
+
}),
|
|
115
|
+
],
|
|
116
|
+
})
|
|
117
|
+
export class AppModule {}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
#### Option 2: Async Configuration (Recommended)
|
|
121
|
+
|
|
122
|
+
```typescript
|
|
123
|
+
// app.module.ts
|
|
124
|
+
import { Module } from '@nestjs/common';
|
|
125
|
+
import { ConfigModule, ConfigService } from '@nestjs/config';
|
|
126
|
+
import { YouCanPayModule } from 'youcanpay-sdk';
|
|
127
|
+
|
|
128
|
+
@Module({
|
|
129
|
+
imports: [
|
|
130
|
+
ConfigModule.forRoot(),
|
|
131
|
+
YouCanPayModule.forRootAsync({
|
|
132
|
+
inject: [ConfigService],
|
|
133
|
+
useFactory: (config: ConfigService) => ({
|
|
134
|
+
privateKey: config.get('YCP_PRIVATE_KEY')!,
|
|
135
|
+
publicKey: config.get('YCP_PUBLIC_KEY')!,
|
|
136
|
+
sandbox: config.get('YCP_SANDBOX') === 'true',
|
|
137
|
+
}),
|
|
138
|
+
}),
|
|
139
|
+
],
|
|
140
|
+
})
|
|
141
|
+
export class AppModule {}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
#### Using the Service
|
|
145
|
+
|
|
146
|
+
```typescript
|
|
147
|
+
// payments.service.ts
|
|
148
|
+
import { Injectable } from '@nestjs/common';
|
|
149
|
+
import { YouCanPayService, CurrencyCode } from 'youcanpay-sdk';
|
|
150
|
+
|
|
151
|
+
@Injectable()
|
|
152
|
+
export class PaymentsService {
|
|
153
|
+
constructor(private readonly youcanpay: YouCanPayService) {}
|
|
154
|
+
|
|
155
|
+
async createPayment(orderId: string, amount: number, customerIp: string) {
|
|
156
|
+
const { token } = await this.youcanpay.createToken({
|
|
157
|
+
orderId,
|
|
158
|
+
amount,
|
|
159
|
+
currency: CurrencyCode.MAD,
|
|
160
|
+
customerIp,
|
|
161
|
+
successUrl: 'https://myapp.com/success',
|
|
162
|
+
errorUrl: 'https://myapp.com/error',
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
tokenId: token.id,
|
|
167
|
+
paymentUrl: this.youcanpay.getPaymentUrl(token.id),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## Complete Payment Flow
|
|
176
|
+
|
|
177
|
+
```
|
|
178
|
+
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
|
|
179
|
+
│ User │ │ Your App │ │ SDK │ │ YouCanPay│
|
|
180
|
+
└──────────┘ └──────────┘ └──────────┘ └──────────┘
|
|
181
|
+
│ │ │ │
|
|
182
|
+
│ 1. Click Pay │ │ │
|
|
183
|
+
│───────────────>│ │ │
|
|
184
|
+
│ │ 2. createToken │ │
|
|
185
|
+
│ │───────────────>│ │
|
|
186
|
+
│ │ │ 3. POST /tokenize
|
|
187
|
+
│ │ │───────────────>│
|
|
188
|
+
│ │ │<───────────────│
|
|
189
|
+
│ │<───────────────│ { token } │
|
|
190
|
+
│ │ │ │
|
|
191
|
+
│ │ 4. Save to DB (PENDING) │
|
|
192
|
+
│ │ │ │
|
|
193
|
+
│ 5. Redirect to paymentUrl │ │
|
|
194
|
+
│<───────────────│ │ │
|
|
195
|
+
│ │ │ │
|
|
196
|
+
│ 6. User pays on YouCanPay │ │
|
|
197
|
+
│────────────────────────────────────────────────>│
|
|
198
|
+
│ │ │ │
|
|
199
|
+
│ │ 7. Webhook: transaction.paid │
|
|
200
|
+
│ │<───────────────────────────────│
|
|
201
|
+
│ │ 8. Update DB (COMPLETED) │
|
|
202
|
+
│ │ │ │
|
|
203
|
+
│ 9. Redirect back │ │
|
|
204
|
+
│<────────────────────────────────────────────────│
|
|
205
|
+
│ │ │ │
|
|
206
|
+
│ 10. Verify │ │ │
|
|
207
|
+
│───────────────>│ 11. Check DB │ │
|
|
208
|
+
│<───────────────│ │ │
|
|
209
|
+
│ Success! │ │ │
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
### Step-by-Step Implementation
|
|
213
|
+
|
|
214
|
+
#### Step 1-4: Create Payment
|
|
215
|
+
|
|
216
|
+
```typescript
|
|
217
|
+
// Create payment and store in database
|
|
218
|
+
async function initiatePayment(userId: string, amount: number) {
|
|
219
|
+
const orderId = generateOrderId(); // e.g., UUID
|
|
220
|
+
|
|
221
|
+
// Save to database first
|
|
222
|
+
await db.payment.create({
|
|
223
|
+
orderId,
|
|
224
|
+
amount,
|
|
225
|
+
currency: 'MAD',
|
|
226
|
+
userId,
|
|
227
|
+
status: 'PENDING',
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// Create token with YouCanPay
|
|
231
|
+
const { token } = await client.createToken({
|
|
232
|
+
orderId,
|
|
233
|
+
amount,
|
|
234
|
+
currency: CurrencyCode.MAD,
|
|
235
|
+
customerIp: getClientIp(),
|
|
236
|
+
successUrl: `https://myapp.com/payment/success`,
|
|
237
|
+
errorUrl: `https://myapp.com/payment/error`,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// Update with token ID
|
|
241
|
+
await db.payment.update({
|
|
242
|
+
where: { orderId },
|
|
243
|
+
data: { tokenId: token.id },
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
return client.getPaymentUrl(token.id);
|
|
247
|
+
}
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
#### Step 7-8: Handle Webhook
|
|
251
|
+
|
|
252
|
+
```typescript
|
|
253
|
+
import { parseWebhookPayload, verifyWebhookSecret } from 'youcanpay-sdk';
|
|
254
|
+
|
|
255
|
+
app.post('/webhook', async (req, res) => {
|
|
256
|
+
// Verify webhook secret
|
|
257
|
+
const isValid = verifyWebhookSecret({
|
|
258
|
+
secret: process.env.YCP_WEBHOOK_SECRET!,
|
|
259
|
+
query: req.query,
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
if (!isValid) {
|
|
263
|
+
return res.status(401).json({ error: 'Invalid secret' });
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Parse webhook payload
|
|
267
|
+
const webhook = parseWebhookPayload(req.body);
|
|
268
|
+
|
|
269
|
+
// Update database
|
|
270
|
+
await db.payment.update({
|
|
271
|
+
where: { orderId: webhook.orderId },
|
|
272
|
+
data: {
|
|
273
|
+
status: webhook.isSuccess ? 'COMPLETED' : 'FAILED',
|
|
274
|
+
transactionId: webhook.transactionId,
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
res.json({ received: true });
|
|
279
|
+
});
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
#### Step 10-11: Verify Payment
|
|
283
|
+
|
|
284
|
+
```typescript
|
|
285
|
+
app.get('/payment/success', async (req, res) => {
|
|
286
|
+
const { order_id, transaction_id } = req.query;
|
|
287
|
+
|
|
288
|
+
// NEVER trust URL params - verify from database
|
|
289
|
+
const payment = await db.payment.findUnique({
|
|
290
|
+
where: { orderId: order_id },
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
if (payment?.status === 'COMPLETED') {
|
|
294
|
+
// Payment verified!
|
|
295
|
+
res.render('success', { payment });
|
|
296
|
+
} else {
|
|
297
|
+
// Still pending or failed
|
|
298
|
+
res.render('pending');
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
---
|
|
304
|
+
|
|
305
|
+
## API Reference
|
|
306
|
+
|
|
307
|
+
### YouCanPayClient / YouCanPayService Methods
|
|
308
|
+
|
|
309
|
+
#### `createToken(params): Promise<TokenResponse>`
|
|
310
|
+
|
|
311
|
+
Create a payment token.
|
|
312
|
+
|
|
313
|
+
```typescript
|
|
314
|
+
const { token } = await client.createToken({
|
|
315
|
+
orderId: string, // Your unique order ID
|
|
316
|
+
amount: number, // Amount in centimes (5000 = 50.00 MAD)
|
|
317
|
+
currency: CurrencyCode, // 'MAD' | 'USD' | 'EUR'
|
|
318
|
+
customerIp: string, // Customer's IP address
|
|
319
|
+
successUrl: string, // Redirect URL on success
|
|
320
|
+
errorUrl?: string, // Redirect URL on error
|
|
321
|
+
customer?: { // Optional customer info
|
|
322
|
+
name?: string,
|
|
323
|
+
email?: string,
|
|
324
|
+
phone?: string,
|
|
325
|
+
address?: string,
|
|
326
|
+
city?: string,
|
|
327
|
+
state?: string,
|
|
328
|
+
zip_code?: string,
|
|
329
|
+
country_code?: string,
|
|
330
|
+
},
|
|
331
|
+
metadata?: Record<string, string>, // Custom data
|
|
332
|
+
});
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
#### `getPaymentUrl(tokenId, lang?): string`
|
|
336
|
+
|
|
337
|
+
Get the YouCanPay checkout page URL.
|
|
338
|
+
|
|
339
|
+
```typescript
|
|
340
|
+
const url = client.getPaymentUrl(token.id, 'fr'); // 'en' | 'fr' | 'ar'
|
|
341
|
+
// Returns: https://youcanpay.com/sandbox/payment-form/{tokenId}?lang=fr
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
#### `getTransaction(transactionId): Promise<Transaction>`
|
|
345
|
+
|
|
346
|
+
Fetch transaction details from YouCanPay.
|
|
347
|
+
|
|
348
|
+
```typescript
|
|
349
|
+
const transaction = await client.getTransaction('txn-123');
|
|
350
|
+
// { id, order_id, amount, currency, status, created_at, ... }
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
#### `payWithCreditCard(params): Promise<PaymentResponse>`
|
|
354
|
+
|
|
355
|
+
Process card payment server-side (for PCI-compliant setups).
|
|
356
|
+
|
|
357
|
+
```typescript
|
|
358
|
+
const result = await client.payWithCreditCard({
|
|
359
|
+
tokenId: string,
|
|
360
|
+
creditCard: string, // Card number
|
|
361
|
+
expireDate: string, // MM/YY
|
|
362
|
+
cvv: string,
|
|
363
|
+
cardHolderName: string,
|
|
364
|
+
});
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
#### `payWithCashPlus(params): Promise<CashPlusPaymentResponse>`
|
|
368
|
+
|
|
369
|
+
Initialize CashPlus payment.
|
|
370
|
+
|
|
371
|
+
```typescript
|
|
372
|
+
const result = await client.payWithCashPlus({
|
|
373
|
+
tokenId: string,
|
|
374
|
+
});
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
### Standalone Functions
|
|
378
|
+
|
|
379
|
+
#### Webhook Functions
|
|
380
|
+
|
|
381
|
+
```typescript
|
|
382
|
+
import {
|
|
383
|
+
parseWebhookPayload,
|
|
384
|
+
verifyWebhookSecret,
|
|
385
|
+
verifyWebhookHMAC,
|
|
386
|
+
createWebhookSignature,
|
|
387
|
+
} from 'youcanpay-sdk';
|
|
388
|
+
|
|
389
|
+
// Parse YouCanPay webhook payload
|
|
390
|
+
const webhook = parseWebhookPayload(requestBody);
|
|
391
|
+
// Returns: ParsedWebhookPayload
|
|
392
|
+
|
|
393
|
+
// Verify webhook secret from query parameter
|
|
394
|
+
const isValid = verifyWebhookSecret({
|
|
395
|
+
secret: 'your-secret',
|
|
396
|
+
query: { secret: '...' }, // From URL query
|
|
397
|
+
headers: { ... }, // Or from headers
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
// HMAC verification (if YouCanPay adds this)
|
|
401
|
+
const isValid = verifyWebhookHMAC(payload, signature, secret, 'sha256');
|
|
402
|
+
|
|
403
|
+
// Create HMAC signature
|
|
404
|
+
const signature = createWebhookSignature(payload, secret);
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
#### Validation Functions
|
|
408
|
+
|
|
409
|
+
```typescript
|
|
410
|
+
import {
|
|
411
|
+
validateAmount,
|
|
412
|
+
validateCurrency,
|
|
413
|
+
validateRedirectURL,
|
|
414
|
+
validateOrderId,
|
|
415
|
+
validateIP,
|
|
416
|
+
validateEmail,
|
|
417
|
+
validatePaymentInput,
|
|
418
|
+
} from 'youcanpay-sdk';
|
|
419
|
+
|
|
420
|
+
// All return: { valid: boolean, error?: string }
|
|
421
|
+
|
|
422
|
+
validateAmount(5000); // { valid: true }
|
|
423
|
+
validateAmount(50); // { valid: false, error: 'Amount must be at least 100' }
|
|
424
|
+
|
|
425
|
+
validateCurrency('MAD'); // { valid: true }
|
|
426
|
+
validateCurrency('GBP'); // { valid: false, error: 'Currency must be...' }
|
|
427
|
+
|
|
428
|
+
validateRedirectURL('https://app.com'); // { valid: true }
|
|
429
|
+
validateRedirectURL('javascript:...'); // { valid: false }
|
|
430
|
+
|
|
431
|
+
// Validate all at once
|
|
432
|
+
const result = validatePaymentInput({
|
|
433
|
+
amount: 5000,
|
|
434
|
+
currency: 'MAD',
|
|
435
|
+
orderId: 'order-123',
|
|
436
|
+
successUrl: 'https://myapp.com/success',
|
|
437
|
+
});
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
#### Utility Functions
|
|
441
|
+
|
|
442
|
+
```typescript
|
|
443
|
+
import {
|
|
444
|
+
toCentimes,
|
|
445
|
+
fromCentimes,
|
|
446
|
+
formatAmount,
|
|
447
|
+
sanitizeString,
|
|
448
|
+
SUPPORTED_CURRENCIES,
|
|
449
|
+
} from 'youcanpay-sdk';
|
|
450
|
+
|
|
451
|
+
toCentimes(50.00); // 5000
|
|
452
|
+
fromCentimes(5000); // 50.00
|
|
453
|
+
formatAmount(5000, 'MAD'); // "50.00 MAD"
|
|
454
|
+
sanitizeString('<script>'); // "script"
|
|
455
|
+
SUPPORTED_CURRENCIES; // ['MAD', 'USD', 'EUR']
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
### TypeScript Interfaces
|
|
459
|
+
|
|
460
|
+
```typescript
|
|
461
|
+
import type {
|
|
462
|
+
YouCanPayOptions,
|
|
463
|
+
CreateTokenParams,
|
|
464
|
+
TokenResponse,
|
|
465
|
+
Transaction,
|
|
466
|
+
ParsedWebhookPayload,
|
|
467
|
+
ValidationResult,
|
|
468
|
+
CurrencyCode,
|
|
469
|
+
} from 'youcanpay-sdk';
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
---
|
|
473
|
+
|
|
474
|
+
## Webhook Handling
|
|
475
|
+
|
|
476
|
+
### Webhook Payload Structure
|
|
477
|
+
|
|
478
|
+
YouCanPay sends webhooks with this structure:
|
|
479
|
+
|
|
480
|
+
```json
|
|
481
|
+
{
|
|
482
|
+
"id": "webhook-uuid",
|
|
483
|
+
"event_name": "transaction.paid",
|
|
484
|
+
"sandbox": true,
|
|
485
|
+
"payload": {
|
|
486
|
+
"transaction": {
|
|
487
|
+
"id": "txn-uuid",
|
|
488
|
+
"status": 1,
|
|
489
|
+
"order_id": "your-order-id",
|
|
490
|
+
"amount": "50000",
|
|
491
|
+
"currency": "MAD",
|
|
492
|
+
"created_at": "2024-01-01T12:00:00.000000Z"
|
|
493
|
+
},
|
|
494
|
+
"payment_method": { ... },
|
|
495
|
+
"customer": { ... }
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
### Parsing with SDK
|
|
501
|
+
|
|
502
|
+
```typescript
|
|
503
|
+
import { parseWebhookPayload, ParsedWebhookPayload } from 'youcanpay-sdk';
|
|
504
|
+
|
|
505
|
+
const webhook: ParsedWebhookPayload = parseWebhookPayload(requestBody);
|
|
506
|
+
|
|
507
|
+
console.log(webhook.transactionId); // 'txn-uuid'
|
|
508
|
+
console.log(webhook.orderId); // 'your-order-id'
|
|
509
|
+
console.log(webhook.amount); // 50000
|
|
510
|
+
console.log(webhook.isSuccess); // true
|
|
511
|
+
console.log(webhook.status); // 'paid' | 'failed' | 'refunded'
|
|
512
|
+
console.log(webhook.eventName); // 'transaction.paid'
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
### NestJS Webhook Handler
|
|
516
|
+
|
|
517
|
+
```typescript
|
|
518
|
+
import {
|
|
519
|
+
Controller,
|
|
520
|
+
Post,
|
|
521
|
+
Body,
|
|
522
|
+
Query,
|
|
523
|
+
UnauthorizedException,
|
|
524
|
+
HttpCode,
|
|
525
|
+
} from '@nestjs/common';
|
|
526
|
+
import {
|
|
527
|
+
ParseWebhookPipe,
|
|
528
|
+
ParsedWebhookPayload,
|
|
529
|
+
verifyWebhookSecret,
|
|
530
|
+
} from 'youcanpay-sdk';
|
|
531
|
+
|
|
532
|
+
@Controller('payments')
|
|
533
|
+
export class PaymentsController {
|
|
534
|
+
@Post('webhook')
|
|
535
|
+
@HttpCode(200)
|
|
536
|
+
async handleWebhook(
|
|
537
|
+
@Body(ParseWebhookPipe) webhook: ParsedWebhookPayload,
|
|
538
|
+
@Query() query: Record<string, string>,
|
|
539
|
+
) {
|
|
540
|
+
// Verify secret
|
|
541
|
+
if (!verifyWebhookSecret({ secret: process.env.YCP_WEBHOOK_SECRET!, query })) {
|
|
542
|
+
throw new UnauthorizedException('Invalid webhook secret');
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Process payment
|
|
546
|
+
if (webhook.isSuccess) {
|
|
547
|
+
await this.paymentService.markCompleted(webhook.orderId, webhook.transactionId);
|
|
548
|
+
} else {
|
|
549
|
+
await this.paymentService.markFailed(webhook.orderId);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
return { received: true };
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
### Webhook Security Checklist
|
|
558
|
+
|
|
559
|
+
- [ ] Add secret to webhook URL: `https://myapp.com/webhook?secret=xxx`
|
|
560
|
+
- [ ] Verify secret before processing
|
|
561
|
+
- [ ] Verify transaction with `getTransaction()` API
|
|
562
|
+
- [ ] Check idempotency (don't process same webhook twice)
|
|
563
|
+
- [ ] Verify amount matches your database
|
|
564
|
+
- [ ] Return 200 OK quickly (process async if needed)
|
|
565
|
+
- [ ] Log webhooks for debugging (sanitize sensitive data)
|
|
566
|
+
|
|
567
|
+
---
|
|
568
|
+
|
|
569
|
+
## Database Integration
|
|
570
|
+
|
|
571
|
+
### Recommended Schema
|
|
572
|
+
|
|
573
|
+
```sql
|
|
574
|
+
CREATE TABLE payments (
|
|
575
|
+
id UUID PRIMARY KEY,
|
|
576
|
+
order_id VARCHAR(255) UNIQUE NOT NULL,
|
|
577
|
+
token_id VARCHAR(255),
|
|
578
|
+
transaction_id VARCHAR(255),
|
|
579
|
+
amount INTEGER NOT NULL, -- In centimes
|
|
580
|
+
currency VARCHAR(3) NOT NULL,
|
|
581
|
+
status VARCHAR(20) NOT NULL, -- PENDING, COMPLETED, FAILED
|
|
582
|
+
user_id UUID NOT NULL,
|
|
583
|
+
metadata JSONB,
|
|
584
|
+
created_at TIMESTAMP DEFAULT NOW(),
|
|
585
|
+
updated_at TIMESTAMP DEFAULT NOW()
|
|
586
|
+
);
|
|
587
|
+
```
|
|
588
|
+
|
|
589
|
+
### Prisma Example
|
|
590
|
+
|
|
591
|
+
```prisma
|
|
592
|
+
// schema.prisma
|
|
593
|
+
model Payment {
|
|
594
|
+
id String @id @default(uuid())
|
|
595
|
+
orderId String @unique @map("order_id")
|
|
596
|
+
tokenId String? @map("token_id")
|
|
597
|
+
transactionId String? @map("transaction_id")
|
|
598
|
+
amount Int
|
|
599
|
+
currency String
|
|
600
|
+
status String @default("PENDING")
|
|
601
|
+
userId String @map("user_id")
|
|
602
|
+
metadata Json?
|
|
603
|
+
createdAt DateTime @default(now()) @map("created_at")
|
|
604
|
+
updatedAt DateTime @updatedAt @map("updated_at")
|
|
605
|
+
|
|
606
|
+
@@map("payments")
|
|
607
|
+
}
|
|
608
|
+
```
|
|
609
|
+
|
|
610
|
+
```typescript
|
|
611
|
+
// Usage
|
|
612
|
+
const payment = await prisma.payment.create({
|
|
613
|
+
data: {
|
|
614
|
+
orderId: 'order-123',
|
|
615
|
+
amount: 50000,
|
|
616
|
+
currency: 'MAD',
|
|
617
|
+
userId: user.id,
|
|
618
|
+
status: 'PENDING',
|
|
619
|
+
},
|
|
620
|
+
});
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
### TypeORM Example
|
|
624
|
+
|
|
625
|
+
```typescript
|
|
626
|
+
@Entity('payments')
|
|
627
|
+
export class Payment {
|
|
628
|
+
@PrimaryGeneratedColumn('uuid')
|
|
629
|
+
id: string;
|
|
630
|
+
|
|
631
|
+
@Column({ unique: true })
|
|
632
|
+
orderId: string;
|
|
633
|
+
|
|
634
|
+
@Column({ nullable: true })
|
|
635
|
+
tokenId: string;
|
|
636
|
+
|
|
637
|
+
@Column({ nullable: true })
|
|
638
|
+
transactionId: string;
|
|
639
|
+
|
|
640
|
+
@Column()
|
|
641
|
+
amount: number;
|
|
642
|
+
|
|
643
|
+
@Column()
|
|
644
|
+
currency: string;
|
|
645
|
+
|
|
646
|
+
@Column({ default: 'PENDING' })
|
|
647
|
+
status: 'PENDING' | 'COMPLETED' | 'FAILED';
|
|
648
|
+
|
|
649
|
+
@Column()
|
|
650
|
+
userId: string;
|
|
651
|
+
|
|
652
|
+
@CreateDateColumn()
|
|
653
|
+
createdAt: Date;
|
|
654
|
+
|
|
655
|
+
@UpdateDateColumn()
|
|
656
|
+
updatedAt: Date;
|
|
657
|
+
}
|
|
658
|
+
```
|
|
659
|
+
|
|
660
|
+
### Status Flow
|
|
661
|
+
|
|
662
|
+
```
|
|
663
|
+
PENDING ──────┬──────> COMPLETED (webhook: transaction.paid)
|
|
664
|
+
│
|
|
665
|
+
└──────> FAILED (webhook: transaction.failed)
|
|
666
|
+
```
|
|
667
|
+
|
|
668
|
+
---
|
|
669
|
+
|
|
670
|
+
## Validation & Security
|
|
671
|
+
|
|
672
|
+
### Amount Handling
|
|
673
|
+
|
|
674
|
+
YouCanPay uses **centimes** (smallest currency unit):
|
|
675
|
+
|
|
676
|
+
```typescript
|
|
677
|
+
import { toCentimes, fromCentimes, formatAmount } from 'youcanpay-sdk';
|
|
678
|
+
|
|
679
|
+
// User enters: 50.00 MAD
|
|
680
|
+
const centimes = toCentimes(50.00); // 5000
|
|
681
|
+
|
|
682
|
+
// Display from API response
|
|
683
|
+
const display = formatAmount(5000, 'MAD'); // "50.00 MAD"
|
|
684
|
+
```
|
|
685
|
+
|
|
686
|
+
### Input Validation
|
|
687
|
+
|
|
688
|
+
```typescript
|
|
689
|
+
import { validatePaymentInput } from 'youcanpay-sdk';
|
|
690
|
+
|
|
691
|
+
const validation = validatePaymentInput({
|
|
692
|
+
amount: userInput.amount,
|
|
693
|
+
currency: userInput.currency,
|
|
694
|
+
successUrl: userInput.successUrl,
|
|
695
|
+
errorUrl: userInput.errorUrl,
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
if (!validation.valid) {
|
|
699
|
+
throw new BadRequestException(validation.error);
|
|
700
|
+
}
|
|
701
|
+
```
|
|
702
|
+
|
|
703
|
+
### Security Best Practices
|
|
704
|
+
|
|
705
|
+
1. **Never trust redirect URL params** - Always verify payment status from your database
|
|
706
|
+
2. **Validate webhook secrets** - Reject webhooks without valid secret
|
|
707
|
+
3. **Verify with API** - Call `getTransaction()` to confirm transaction exists
|
|
708
|
+
4. **Check amounts** - Ensure webhook amount matches your database
|
|
709
|
+
5. **Idempotency** - Don't process the same webhook twice
|
|
710
|
+
6. **HTTPS only** - Never use HTTP in production
|
|
711
|
+
7. **Sanitize inputs** - Use `sanitizeString()` for user inputs
|
|
712
|
+
8. **URL whitelist** - Validate redirect URLs against allowed domains
|
|
713
|
+
|
|
714
|
+
---
|
|
715
|
+
|
|
716
|
+
## Error Handling
|
|
717
|
+
|
|
718
|
+
### YouCanPayError
|
|
719
|
+
|
|
720
|
+
```typescript
|
|
721
|
+
import { YouCanPayError, ErrorCodes } from 'youcanpay-sdk';
|
|
722
|
+
|
|
723
|
+
try {
|
|
724
|
+
await client.createToken({ ... });
|
|
725
|
+
} catch (error) {
|
|
726
|
+
if (error instanceof YouCanPayError) {
|
|
727
|
+
console.log(error.code); // ErrorCodes.VALIDATION_ERROR
|
|
728
|
+
console.log(error.status); // 422
|
|
729
|
+
console.log(error.message); // "The amount field is required"
|
|
730
|
+
console.log(error.details); // { amount: ['required'] }
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
```
|
|
734
|
+
|
|
735
|
+
### Error Codes
|
|
736
|
+
|
|
737
|
+
```typescript
|
|
738
|
+
enum ErrorCodes {
|
|
739
|
+
NETWORK_ERROR = 'NETWORK_ERROR',
|
|
740
|
+
UNAUTHORIZED = 'UNAUTHORIZED',
|
|
741
|
+
VALIDATION_ERROR = 'VALIDATION_ERROR',
|
|
742
|
+
PAYMENT_FAILED = 'PAYMENT_FAILED',
|
|
743
|
+
UNKNOWN_ERROR = 'UNKNOWN_ERROR',
|
|
744
|
+
}
|
|
745
|
+
```
|
|
746
|
+
|
|
747
|
+
### NestJS Exception Filter
|
|
748
|
+
|
|
749
|
+
```typescript
|
|
750
|
+
@Catch(YouCanPayError)
|
|
751
|
+
export class YouCanPayExceptionFilter implements ExceptionFilter {
|
|
752
|
+
catch(exception: YouCanPayError, host: ArgumentsHost) {
|
|
753
|
+
const response = host.switchToHttp().getResponse();
|
|
754
|
+
|
|
755
|
+
response.status(exception.status || 500).json({
|
|
756
|
+
error: exception.code,
|
|
757
|
+
message: exception.message,
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
```
|
|
762
|
+
|
|
763
|
+
---
|
|
764
|
+
|
|
765
|
+
## Testing
|
|
766
|
+
|
|
767
|
+
### Sandbox Mode
|
|
768
|
+
|
|
769
|
+
Always use sandbox for testing:
|
|
770
|
+
|
|
771
|
+
```typescript
|
|
772
|
+
const client = new YouCanPayClient({
|
|
773
|
+
privateKey: 'pri_sandbox_xxxxx',
|
|
774
|
+
publicKey: 'pub_sandbox_xxxxx',
|
|
775
|
+
sandbox: true, // <-- Important!
|
|
776
|
+
});
|
|
777
|
+
```
|
|
778
|
+
|
|
779
|
+
### Test Card Numbers
|
|
780
|
+
|
|
781
|
+
| Card Number | Result |
|
|
782
|
+
|-------------|--------|
|
|
783
|
+
| `4000 0000 0000 0002` | Success |
|
|
784
|
+
| `4000 0000 0000 0010` | 3D Secure |
|
|
785
|
+
| `4000 0000 0000 0036` | Declined |
|
|
786
|
+
|
|
787
|
+
Use any future expiry date and any 3-digit CVV.
|
|
788
|
+
|
|
789
|
+
### Mocking the SDK
|
|
790
|
+
|
|
791
|
+
```typescript
|
|
792
|
+
// Jest mock
|
|
793
|
+
jest.mock('youcanpay-sdk', () => ({
|
|
794
|
+
YouCanPayClient: jest.fn().mockImplementation(() => ({
|
|
795
|
+
createToken: jest.fn().mockResolvedValue({
|
|
796
|
+
token: { id: 'test-token-123' },
|
|
797
|
+
}),
|
|
798
|
+
getPaymentUrl: jest.fn().mockReturnValue('https://youcanpay.com/test'),
|
|
799
|
+
getTransaction: jest.fn().mockResolvedValue({
|
|
800
|
+
id: 'txn-123',
|
|
801
|
+
order_id: 'order-123',
|
|
802
|
+
amount: 5000,
|
|
803
|
+
status: 'paid',
|
|
804
|
+
}),
|
|
805
|
+
})),
|
|
806
|
+
}));
|
|
807
|
+
```
|
|
808
|
+
|
|
809
|
+
### Testing Webhooks Locally
|
|
810
|
+
|
|
811
|
+
Use [ngrok](https://ngrok.com) to expose your local server:
|
|
812
|
+
|
|
813
|
+
```bash
|
|
814
|
+
ngrok http 3000
|
|
815
|
+
# Returns: https://abc123.ngrok.io
|
|
816
|
+
|
|
817
|
+
# Set webhook URL in YouCanPay dashboard:
|
|
818
|
+
# https://abc123.ngrok.io/webhook?secret=your-secret
|
|
819
|
+
```
|
|
820
|
+
|
|
821
|
+
---
|
|
822
|
+
|
|
823
|
+
## Configuration Options
|
|
824
|
+
|
|
825
|
+
```typescript
|
|
826
|
+
interface YouCanPayOptions {
|
|
827
|
+
privateKey: string; // Required: Your private API key
|
|
828
|
+
publicKey: string; // Required: Your public API key
|
|
829
|
+
sandbox?: boolean; // Use sandbox environment (default: false)
|
|
830
|
+
timeout?: number; // Request timeout in ms (default: 30000)
|
|
831
|
+
logging?: { // Optional audit logging
|
|
832
|
+
enabled: boolean;
|
|
833
|
+
storage: 'database' | 'custom' | 'none';
|
|
834
|
+
handler?: (log: YouCanPayLogEntry) => Promise<void>;
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
```
|
|
838
|
+
|
|
839
|
+
---
|
|
840
|
+
|
|
841
|
+
## Support
|
|
842
|
+
|
|
843
|
+
- [YouCanPay Documentation](https://youcanpay.com/docs)
|
|
844
|
+
- [GitHub Issues](https://github.com/your-repo/youcanpay-sdk/issues)
|
|
845
|
+
|
|
846
|
+
## License
|
|
847
|
+
|
|
848
|
+
MIT
|