@paykit-sdk/paypal 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 +207 -0
- package/package.json +22 -0
- package/src/controllers/subscription.ts +118 -0
- package/src/controllers/webhook.ts +45 -0
- package/src/index.ts +20 -0
- package/src/paypal-provider.ts +504 -0
- package/src/schema.ts +230 -0
- package/src/types.ts +126 -0
- package/src/utils/mapper.ts +111 -0
- package/tsconfig.json +17 -0
package/README.md
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# @paykit-sdk/paypal
|
|
2
|
+
|
|
3
|
+
PayPal provider for PayKit
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
import { createEndpointHandlers, PayKit } from '@paykit-sdk/core';
|
|
9
|
+
import { paypal, createPayPal } from '@paykit-sdk/paypal';
|
|
10
|
+
|
|
11
|
+
// Method 1: Using environment variables
|
|
12
|
+
const provider = paypal(); // Ensure PAYPAL_CLIENT_ID, PAYPAL_CLIENT_SECRET, PAYPAL_SANDBOX are set
|
|
13
|
+
|
|
14
|
+
// Method 2: Direct configuration
|
|
15
|
+
const provider = createPayPal({
|
|
16
|
+
clientId: process.env.PAYPAL_CLIENT_ID,
|
|
17
|
+
clientSecret: process.env.PAYPAL_CLIENT_SECRET,
|
|
18
|
+
isSandbox: true,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export const paykit = new PayKit(provider);
|
|
22
|
+
export const endpoints = createEndpointHandlers(paykit);
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### Next.js Catch All API Route (/api/paykit/[...endpoint]/route.ts)
|
|
26
|
+
|
|
27
|
+
```typescript
|
|
28
|
+
import { endpoints } from '@/lib/paykit';
|
|
29
|
+
import { EndpointPath } from '@paykit-sdk/core';
|
|
30
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
31
|
+
|
|
32
|
+
export async function POST(
|
|
33
|
+
request: NextRequest,
|
|
34
|
+
{ params }: { params: { endpoint: string[] } },
|
|
35
|
+
) {
|
|
36
|
+
try {
|
|
37
|
+
// Construct the endpoint path with full type safety
|
|
38
|
+
const endpoint = ('/' + params.endpoint.join('/')) as EndpointPath;
|
|
39
|
+
const handler = endpoints[endpoint];
|
|
40
|
+
|
|
41
|
+
if (!handler) {
|
|
42
|
+
return NextResponse.json({ message: 'Endpoint not found' }, { status: 404 });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Parse request body
|
|
46
|
+
const body = await request.json();
|
|
47
|
+
const { args } = body;
|
|
48
|
+
|
|
49
|
+
const result = await handler(...args);
|
|
50
|
+
|
|
51
|
+
return NextResponse.json({ result });
|
|
52
|
+
} catch (error) {
|
|
53
|
+
console.error('PayKit API Error:', error);
|
|
54
|
+
return NextResponse.json(
|
|
55
|
+
{ message: error instanceof Error ? error.message : 'Internal server error' },
|
|
56
|
+
{ status: 500 },
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Next.js Webhooks (/api/paykit/webhooks/route.ts)
|
|
63
|
+
|
|
64
|
+
```typescript
|
|
65
|
+
import { paykit } from '@/lib/paykit';
|
|
66
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
67
|
+
|
|
68
|
+
export async function POST(request: NextRequest) {
|
|
69
|
+
const webhookSecret = process.env.PAYPAL_WEBHOOK_ID;
|
|
70
|
+
|
|
71
|
+
if (!webhookSecret) {
|
|
72
|
+
return NextResponse.json({ error: 'Webhook secret not configured' }, { status: 500 });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const webhook = paykit.webhooks
|
|
76
|
+
.setup({ webhookSecret })
|
|
77
|
+
.on('customer.created', async event => {
|
|
78
|
+
console.log('Customer created:', event.data);
|
|
79
|
+
})
|
|
80
|
+
.on('subscription.created', async event => {
|
|
81
|
+
console.log('Subscription created:', event.data);
|
|
82
|
+
})
|
|
83
|
+
.on('payment.created', async event => {
|
|
84
|
+
console.log('Payment created:', event.data);
|
|
85
|
+
})
|
|
86
|
+
.on('refund.created', async event => {
|
|
87
|
+
console.log('Refund created:', event.data);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const body = await request.text();
|
|
91
|
+
const headers = Object.fromEntries(request.headers.entries());
|
|
92
|
+
const url = request.url;
|
|
93
|
+
await webhook.handle({ body, headers, fullUrl: url });
|
|
94
|
+
|
|
95
|
+
// Return immediately, processing happens in background
|
|
96
|
+
return NextResponse.json({ success: true });
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Express.js with Webhook Handler
|
|
101
|
+
|
|
102
|
+
```typescript
|
|
103
|
+
import { endpoints, paykit } from '@/lib/paykit';
|
|
104
|
+
import { EndpointPath } from '@paykit-sdk/core';
|
|
105
|
+
import express from 'express';
|
|
106
|
+
|
|
107
|
+
const app = express();
|
|
108
|
+
|
|
109
|
+
// IMPORTANT: Webhook route must come BEFORE express.json() middleware
|
|
110
|
+
// This ensures we get the raw body for signature verification
|
|
111
|
+
app.post(
|
|
112
|
+
'/api/webhooks/paypal',
|
|
113
|
+
express.raw({ type: 'application/json' }),
|
|
114
|
+
async (req, res) => {
|
|
115
|
+
try {
|
|
116
|
+
const webhookSecret = process.env.PAYPAL_WEBHOOK_ID;
|
|
117
|
+
|
|
118
|
+
if (!webhookSecret) {
|
|
119
|
+
return res.status(500).json({ error: 'Webhook secret not configured' });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const webhook = paykit.webhooks
|
|
123
|
+
.setup({ webhookSecret })
|
|
124
|
+
.on('customer.created', async event => {
|
|
125
|
+
console.log('Customer created:', event.data);
|
|
126
|
+
})
|
|
127
|
+
.on('subscription.created', async event => {
|
|
128
|
+
console.log('Subscription created:', event.data);
|
|
129
|
+
})
|
|
130
|
+
.on('payment.created', async event => {
|
|
131
|
+
console.log('Payment created:', event.data);
|
|
132
|
+
})
|
|
133
|
+
.on('refund.created', async event => {
|
|
134
|
+
console.log('Refund created:', event.data);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const body = req.body; // Raw buffer from express.raw()
|
|
138
|
+
const headers = req.headers;
|
|
139
|
+
const url = req.url;
|
|
140
|
+
await webhook.handle({ body, headers, fullUrl: url });
|
|
141
|
+
|
|
142
|
+
// Return immediately, processing happens in background
|
|
143
|
+
res.json({ success: true });
|
|
144
|
+
} catch (error) {
|
|
145
|
+
console.error('Webhook error:', error);
|
|
146
|
+
res.status(500).json({
|
|
147
|
+
message: error instanceof Error ? error.message : 'Webhook processing failed',
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
// Regular API routes use JSON middleware
|
|
154
|
+
app.use(express.json());
|
|
155
|
+
|
|
156
|
+
app.post('/api/paykit/*', async (req, res) => {
|
|
157
|
+
try {
|
|
158
|
+
const endpoint = req.path.replace('/api/paykit', '') as EndpointPath;
|
|
159
|
+
const handler = endpoints[endpoint];
|
|
160
|
+
|
|
161
|
+
if (!handler) {
|
|
162
|
+
return res.status(404).json({ message: 'Endpoint not found' });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const { args } = req.body;
|
|
166
|
+
const result = await handler(...args);
|
|
167
|
+
|
|
168
|
+
res.json({ result });
|
|
169
|
+
} catch (error) {
|
|
170
|
+
res.status(500).json({
|
|
171
|
+
message: error instanceof Error ? error.message : 'Internal server error',
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
app.listen(3000, () => {
|
|
177
|
+
console.log('Server running on port 3000');
|
|
178
|
+
});
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## Subscription Management
|
|
182
|
+
|
|
183
|
+
```typescript
|
|
184
|
+
// Update subscription
|
|
185
|
+
await paykit.subscriptions.update('sub_123', {
|
|
186
|
+
metadata: { plan: 'enterprise' },
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// Cancel subscription
|
|
190
|
+
await paykit.subscriptions.cancel('sub_123');
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## Environment Variables
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
PAYPAL_CLIENT_ID=your_client_id
|
|
197
|
+
PAYPAL_CLIENT_SECRET=your_client_secret
|
|
198
|
+
PAYPAL_WEBHOOK_ID=your_webhook_id
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
## Support
|
|
202
|
+
|
|
203
|
+
- [PayPal Documentation](https://developer.paypal.com/docs/api/overview/)
|
|
204
|
+
|
|
205
|
+
## License
|
|
206
|
+
|
|
207
|
+
ISC
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@paykit-sdk/paypal",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"keywords": [],
|
|
7
|
+
"author": "Emmanuel Odii",
|
|
8
|
+
"license": "ISC",
|
|
9
|
+
"peerDependencies": {
|
|
10
|
+
"@paykit-sdk/core": ">=1.1.92"
|
|
11
|
+
},
|
|
12
|
+
"devDependencies": {
|
|
13
|
+
"@paykit-sdk/core": "1.1.92"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@paypal/paypal-server-sdk": "^1.1.0",
|
|
17
|
+
"zod": "^3.24.2"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { CreateSubscriptionSchema } from '@paykit-sdk/core';
|
|
2
|
+
import { ApiResponse, CustomError } from '@paypal/paypal-server-sdk';
|
|
3
|
+
import { BaseController } from '@paypal/paypal-server-sdk/dist/types/controllers/baseController';
|
|
4
|
+
import {
|
|
5
|
+
createSubscriptionApticSchema,
|
|
6
|
+
resumeSubscriptionApticSchemaRequest,
|
|
7
|
+
resumeSubscriptionApticSchemaRequest as cancelSubscriptionApticSchemaRequest,
|
|
8
|
+
subscriptionApticSchema,
|
|
9
|
+
} from '../schema';
|
|
10
|
+
|
|
11
|
+
export class SubscriptionsController extends BaseController {
|
|
12
|
+
private catchAllErrors(req: ReturnType<BaseController['createRequest']>) {
|
|
13
|
+
req.throwOn(
|
|
14
|
+
400,
|
|
15
|
+
CustomError,
|
|
16
|
+
'Request is not well-formed, syntactically incorrect, or violates schema.',
|
|
17
|
+
);
|
|
18
|
+
req.throwOn(
|
|
19
|
+
401,
|
|
20
|
+
CustomError,
|
|
21
|
+
'Authentication failed due to missing authorization header, or invalid authentication credentials.',
|
|
22
|
+
);
|
|
23
|
+
req.throwOn(
|
|
24
|
+
422,
|
|
25
|
+
CustomError,
|
|
26
|
+
'The requested action could not be performed, semantically incorrect, or failed business validation.',
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @return Response from the API call
|
|
32
|
+
*/
|
|
33
|
+
async createSubscription({
|
|
34
|
+
body,
|
|
35
|
+
}: {
|
|
36
|
+
body: CreateSubscriptionSchema;
|
|
37
|
+
}): Promise<ApiResponse<any>> {
|
|
38
|
+
const req = this.createRequest('POST', '/v1/billing/subscriptions');
|
|
39
|
+
const mapped = req.prepareArgs({ body: [body, createSubscriptionApticSchema] });
|
|
40
|
+
req.header('Content-Type', 'application/json');
|
|
41
|
+
req.header('PayPal-Request-Id', Math.random().toString(36).substring(2, 15));
|
|
42
|
+
|
|
43
|
+
req.json(mapped.body);
|
|
44
|
+
this.catchAllErrors(req);
|
|
45
|
+
req.authenticate([{ oauth2: true }]);
|
|
46
|
+
return req.callAsJson(subscriptionApticSchema);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @returns Response from the API call
|
|
51
|
+
*/
|
|
52
|
+
async resumeSubscription({
|
|
53
|
+
body,
|
|
54
|
+
subscriptionId,
|
|
55
|
+
}: {
|
|
56
|
+
body: { reason: string };
|
|
57
|
+
subscriptionId: string;
|
|
58
|
+
}) {
|
|
59
|
+
const req = this.createRequest(
|
|
60
|
+
'POST',
|
|
61
|
+
`v1/billing/subscriptions/${subscriptionId}/activate `,
|
|
62
|
+
);
|
|
63
|
+
const mapped = req.prepareArgs({
|
|
64
|
+
body: [body, resumeSubscriptionApticSchemaRequest],
|
|
65
|
+
});
|
|
66
|
+
req.header('Content-Type', 'application/json');
|
|
67
|
+
req.header('PayPal-Request-Id', Math.random().toString(36).substring(2, 15));
|
|
68
|
+
|
|
69
|
+
req.json(mapped.body);
|
|
70
|
+
this.catchAllErrors(req);
|
|
71
|
+
req.authenticate([{ oauth2: true }]);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async retrieveSubscription({ subscriptionId }: { subscriptionId: string }) {
|
|
75
|
+
const req = this.createRequest('GET', `v1/billing/subscriptions/${subscriptionId}`);
|
|
76
|
+
req.header('PayPal-Request-Id', Math.random().toString(36).substring(2, 15));
|
|
77
|
+
this.catchAllErrors(req);
|
|
78
|
+
req.authenticate([{ oauth2: true }]);
|
|
79
|
+
return req.callAsJson(subscriptionApticSchema);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async cancelSubscription({
|
|
83
|
+
subscriptionId,
|
|
84
|
+
reason,
|
|
85
|
+
}: {
|
|
86
|
+
subscriptionId: string;
|
|
87
|
+
reason: string;
|
|
88
|
+
}) {
|
|
89
|
+
const req = this.createRequest(
|
|
90
|
+
'POST',
|
|
91
|
+
`v1/billing/subscriptions/${subscriptionId}/cancel`,
|
|
92
|
+
);
|
|
93
|
+
const mapped = req.prepareArgs({
|
|
94
|
+
body: [reason, cancelSubscriptionApticSchemaRequest],
|
|
95
|
+
});
|
|
96
|
+
req.header('PayPal-Request-Id', Math.random().toString(36).substring(2, 15));
|
|
97
|
+
req.json(mapped.body);
|
|
98
|
+
this.catchAllErrors(req);
|
|
99
|
+
req.authenticate([{ oauth2: true }]);
|
|
100
|
+
return req.callAsJson(subscriptionApticSchema);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async updateSubscription({
|
|
104
|
+
subscriptionId,
|
|
105
|
+
metadata,
|
|
106
|
+
}: {
|
|
107
|
+
subscriptionId: string;
|
|
108
|
+
metadata: Record<string, unknown>;
|
|
109
|
+
}) {
|
|
110
|
+
const req = this.createRequest('PATCH', `v1/billing/subscriptions/${subscriptionId}`);
|
|
111
|
+
req.header('PayPal-Request-Id', Math.random().toString(36).substring(2, 15));
|
|
112
|
+
|
|
113
|
+
req.json({ op: 'replace', path: '/custom_id', value: JSON.stringify(metadata) });
|
|
114
|
+
|
|
115
|
+
this.catchAllErrors(req);
|
|
116
|
+
req.authenticate([{ oauth2: true }]);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { ApiResponse, CustomError } from '@paypal/paypal-server-sdk';
|
|
2
|
+
import { BaseController } from '@paypal/paypal-server-sdk/dist/types/controllers/baseController';
|
|
3
|
+
import { VerifyWebhookSchema, verifyWebhookSchema } from '../schema';
|
|
4
|
+
|
|
5
|
+
export class WebhookController extends BaseController {
|
|
6
|
+
async verifyWebhook(body: {
|
|
7
|
+
authAlgo: string;
|
|
8
|
+
certUrl: string;
|
|
9
|
+
transmissionId: string;
|
|
10
|
+
transmissionSig: string;
|
|
11
|
+
transmissionTime: string;
|
|
12
|
+
webhookId: string;
|
|
13
|
+
webhookEvent: string;
|
|
14
|
+
}): Promise<ApiResponse<VerifyWebhookSchema>> {
|
|
15
|
+
const req = this.createRequest('POST', '/v1/notifications/verify-webhook-signature');
|
|
16
|
+
req.header('Content-Type', 'application/json');
|
|
17
|
+
|
|
18
|
+
req.throwOn(
|
|
19
|
+
400,
|
|
20
|
+
CustomError,
|
|
21
|
+
'Request is not well-formed, syntactically incorrect, or violates schema.',
|
|
22
|
+
);
|
|
23
|
+
req.throwOn(
|
|
24
|
+
401,
|
|
25
|
+
CustomError,
|
|
26
|
+
'Authentication failed due to missing authorization header, or invalid authentication credentials.',
|
|
27
|
+
);
|
|
28
|
+
req.throwOn(
|
|
29
|
+
422,
|
|
30
|
+
CustomError,
|
|
31
|
+
'The requested action could not be performed, semantically incorrect, or failed business validation.',
|
|
32
|
+
);
|
|
33
|
+
req.json(
|
|
34
|
+
Object.fromEntries(
|
|
35
|
+
Object.entries(body).map(([key, value]) => [
|
|
36
|
+
key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`),
|
|
37
|
+
value,
|
|
38
|
+
]),
|
|
39
|
+
),
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
req.authenticate([{ oauth2: true }]);
|
|
43
|
+
return req.callAsJson(verifyWebhookSchema);
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { validateRequiredKeys } from '@paykit-sdk/core';
|
|
2
|
+
import { PayPalOptions, PayPalProvider } from './paypal-provider';
|
|
3
|
+
|
|
4
|
+
export const paypal = () => {
|
|
5
|
+
const envVars = validateRequiredKeys(
|
|
6
|
+
['PAYPAL_CLIENT_ID', 'PAYPAL_CLIENT_SECRET', 'PAYPAL_SANDBOX'],
|
|
7
|
+
process.env as Record<string, string>,
|
|
8
|
+
'Missing required environment variables: {keys}',
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
return new PayPalProvider({
|
|
12
|
+
clientId: envVars.PAYPAL_CLIENT_ID,
|
|
13
|
+
clientSecret: envVars.PAYPAL_CLIENT_SECRET,
|
|
14
|
+
isSandbox: envVars.PAYPAL_SANDBOX === 'true',
|
|
15
|
+
});
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const createPayPal = (config: PayPalOptions) => {
|
|
19
|
+
return new PayPalProvider(config);
|
|
20
|
+
};
|
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
import {
|
|
2
|
+
PayKitProvider,
|
|
3
|
+
Checkout,
|
|
4
|
+
CreateCheckoutSchema,
|
|
5
|
+
CreateCustomerParams,
|
|
6
|
+
Customer,
|
|
7
|
+
UpdateCustomerParams,
|
|
8
|
+
Subscription,
|
|
9
|
+
UpdateSubscriptionSchema,
|
|
10
|
+
paykitEvent$InboundSchema,
|
|
11
|
+
WebhookEventPayload,
|
|
12
|
+
PaykitProviderOptions,
|
|
13
|
+
HandleWebhookParams,
|
|
14
|
+
UpdateCheckoutSchema,
|
|
15
|
+
CreateSubscriptionSchema,
|
|
16
|
+
CreatePaymentSchema,
|
|
17
|
+
Payment,
|
|
18
|
+
UpdatePaymentSchema,
|
|
19
|
+
CreateRefundSchema,
|
|
20
|
+
Refund,
|
|
21
|
+
validateRequiredKeys,
|
|
22
|
+
ProviderNotSupportedError,
|
|
23
|
+
ConstraintViolationError,
|
|
24
|
+
ResourceNotFoundError,
|
|
25
|
+
WebhookError,
|
|
26
|
+
NotImplementedError,
|
|
27
|
+
schema,
|
|
28
|
+
AbstractPayKitProvider,
|
|
29
|
+
} from '@paykit-sdk/core';
|
|
30
|
+
import {
|
|
31
|
+
CheckoutPaymentIntent,
|
|
32
|
+
Client,
|
|
33
|
+
Environment,
|
|
34
|
+
LogLevel,
|
|
35
|
+
Order,
|
|
36
|
+
OrdersController,
|
|
37
|
+
PaymentsController,
|
|
38
|
+
Refund as PayPalRefund,
|
|
39
|
+
OrderApplicationContextUserAction,
|
|
40
|
+
} from '@paypal/paypal-server-sdk';
|
|
41
|
+
import { z } from 'zod';
|
|
42
|
+
import { SubscriptionsController } from './controllers/subscription';
|
|
43
|
+
import { WebhookController } from './controllers/webhook';
|
|
44
|
+
import { VerifyWebhookStatus } from './schema';
|
|
45
|
+
import {
|
|
46
|
+
paykitCheckout$InboundSchema,
|
|
47
|
+
paykitPayment$InboundSchema,
|
|
48
|
+
paykitRefund$InboundSchema,
|
|
49
|
+
} from './utils/mapper';
|
|
50
|
+
|
|
51
|
+
const PAYPAL_METADATA_MAX_LENGTH = 127;
|
|
52
|
+
|
|
53
|
+
export interface PayPalOptions extends PaykitProviderOptions {
|
|
54
|
+
/**
|
|
55
|
+
* The client ID for the PayPal API
|
|
56
|
+
*/
|
|
57
|
+
clientId: string;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The client secret for the PayPal API
|
|
61
|
+
*/
|
|
62
|
+
clientSecret: string;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Whether to use the sandbox environment
|
|
66
|
+
*/
|
|
67
|
+
isSandbox: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const paypalOptionsSchema = schema<PayPalOptions>()(
|
|
71
|
+
z.object({
|
|
72
|
+
clientId: z.string(),
|
|
73
|
+
clientSecret: z.string(),
|
|
74
|
+
isSandbox: z.boolean(),
|
|
75
|
+
debug: z.boolean().optional(),
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const providerName = 'paypal';
|
|
80
|
+
export class PayPalProvider extends AbstractPayKitProvider implements PayKitProvider {
|
|
81
|
+
readonly providerName = providerName;
|
|
82
|
+
|
|
83
|
+
private client: Client;
|
|
84
|
+
private ordersController: OrdersController;
|
|
85
|
+
private paymentsController: PaymentsController;
|
|
86
|
+
private subscriptionsController: SubscriptionsController;
|
|
87
|
+
private webhookController: WebhookController;
|
|
88
|
+
|
|
89
|
+
constructor(config: PayPalOptions) {
|
|
90
|
+
super(paypalOptionsSchema, config, providerName);
|
|
91
|
+
|
|
92
|
+
const { clientId, clientSecret, isSandbox = true, debug } = config;
|
|
93
|
+
|
|
94
|
+
const environment = isSandbox ? Environment.Sandbox : Environment.Production;
|
|
95
|
+
|
|
96
|
+
this.client = new Client({
|
|
97
|
+
clientCredentialsAuthCredentials: {
|
|
98
|
+
oAuthClientId: clientId,
|
|
99
|
+
oAuthClientSecret: clientSecret,
|
|
100
|
+
},
|
|
101
|
+
timeout: 0,
|
|
102
|
+
environment,
|
|
103
|
+
logging: {
|
|
104
|
+
logLevel: debug ? LogLevel.Info : LogLevel.Error,
|
|
105
|
+
logRequest: { logBody: debug },
|
|
106
|
+
logResponse: { logHeaders: debug },
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
this.ordersController = new OrdersController(this.client);
|
|
111
|
+
this.paymentsController = new PaymentsController(this.client);
|
|
112
|
+
this.subscriptionsController = new SubscriptionsController(this.client);
|
|
113
|
+
this.webhookController = new WebhookController(this.client);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Checkout management
|
|
118
|
+
* In PayPal, Order IS the checkout
|
|
119
|
+
*/
|
|
120
|
+
createCheckout = async (params: CreateCheckoutSchema): Promise<Checkout> => {
|
|
121
|
+
const stringifiedMetadata = JSON.stringify(params.metadata);
|
|
122
|
+
|
|
123
|
+
if (stringifiedMetadata.length > PAYPAL_METADATA_MAX_LENGTH) {
|
|
124
|
+
throw new ConstraintViolationError('Metadata exceeds maximum length', {
|
|
125
|
+
value: stringifiedMetadata.length,
|
|
126
|
+
limit: PAYPAL_METADATA_MAX_LENGTH,
|
|
127
|
+
provider: this.providerName,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
const {
|
|
131
|
+
currency = 'USD',
|
|
132
|
+
amount = '0',
|
|
133
|
+
itemName = 'Untitled Item',
|
|
134
|
+
} = validateRequiredKeys(
|
|
135
|
+
['currency', 'amount', 'itemName'],
|
|
136
|
+
params.provider_metadata as Record<string, string>,
|
|
137
|
+
'Missing required parameters: {keys}',
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const orderOptionsBody: Parameters<OrdersController['createOrder']>[0]['body'] = {
|
|
141
|
+
intent: CheckoutPaymentIntent.Capture,
|
|
142
|
+
payer: {
|
|
143
|
+
...(typeof params.customer === 'string' && { payerId: params.customer }),
|
|
144
|
+
...(typeof params.customer === 'object' &&
|
|
145
|
+
'email' in params.customer && { emailAddress: params.customer.email }),
|
|
146
|
+
},
|
|
147
|
+
purchaseUnits: [
|
|
148
|
+
{
|
|
149
|
+
amount: { currencyCode: currency, value: amount },
|
|
150
|
+
customId: stringifiedMetadata,
|
|
151
|
+
items: [
|
|
152
|
+
{
|
|
153
|
+
sku: params.item_id,
|
|
154
|
+
quantity: params.quantity.toString(),
|
|
155
|
+
name: itemName,
|
|
156
|
+
unitAmount: { currencyCode: currency, value: amount },
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
applicationContext: { userAction: OrderApplicationContextUserAction.PayNow },
|
|
162
|
+
...(params.provider_metadata && { ...params.provider_metadata }),
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
if (params.billing) {
|
|
166
|
+
orderOptionsBody.purchaseUnits[0].shipping = {
|
|
167
|
+
name: { fullName: params.billing.address.name },
|
|
168
|
+
address: {
|
|
169
|
+
addressLine1: params.billing.address.line1,
|
|
170
|
+
addressLine2: params.billing.address.line2,
|
|
171
|
+
adminArea1: params.billing.address.city,
|
|
172
|
+
adminArea2: params.billing.address.state,
|
|
173
|
+
postalCode: params.billing.address.postal_code,
|
|
174
|
+
countryCode: params.billing.address.country,
|
|
175
|
+
},
|
|
176
|
+
...(params.billing.address.phone && {
|
|
177
|
+
phoneNumber: {
|
|
178
|
+
nationalNumber: params.billing.address.phone,
|
|
179
|
+
countryCode: params.billing.address.country,
|
|
180
|
+
},
|
|
181
|
+
}),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const order = await this.ordersController.createOrder({ body: orderOptionsBody });
|
|
186
|
+
|
|
187
|
+
return paykitCheckout$InboundSchema(order.result);
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
retrieveCheckout = async (id: string): Promise<Checkout> => {
|
|
191
|
+
const order = await this.ordersController.getOrder({ id });
|
|
192
|
+
|
|
193
|
+
return paykitCheckout$InboundSchema(order.result);
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
updateCheckout = async (
|
|
197
|
+
id: string,
|
|
198
|
+
params: UpdateCheckoutSchema,
|
|
199
|
+
): Promise<Checkout> => {
|
|
200
|
+
throw new ProviderNotSupportedError('updateCheckout', this.providerName, {
|
|
201
|
+
reason:
|
|
202
|
+
'PayPal does not support updating orders. Cancel and create a new order instead.',
|
|
203
|
+
});
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
deleteCheckout = async (id: string): Promise<null> => {
|
|
207
|
+
throw new ProviderNotSupportedError('deleteCheckout', this.providerName, {
|
|
208
|
+
reason: 'PayPal orders cannot be deleted. They expire automatically.',
|
|
209
|
+
});
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
createCustomer = async (params: CreateCustomerParams): Promise<Customer> => {
|
|
213
|
+
throw new ProviderNotSupportedError('customer management', this.providerName, {
|
|
214
|
+
reason: 'PayPal does not have standalone customer entities.',
|
|
215
|
+
alternative: 'Use Payer information within orders or implement PayPal Vault API',
|
|
216
|
+
});
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
updateCustomer = async (
|
|
220
|
+
id: string,
|
|
221
|
+
params: UpdateCustomerParams,
|
|
222
|
+
): Promise<Customer> => {
|
|
223
|
+
throw new ProviderNotSupportedError('updateCustomer', this.providerName, {
|
|
224
|
+
reason: 'PayPal does not support standalone customer management.',
|
|
225
|
+
});
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
deleteCustomer = async (id: string): Promise<null> => {
|
|
229
|
+
throw new ProviderNotSupportedError('deleteCustomer', this.providerName, {
|
|
230
|
+
reason: 'PayPal does not support standalone customer management.',
|
|
231
|
+
});
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
retrieveCustomer = async (id: string): Promise<Customer | null> => {
|
|
235
|
+
throw new ProviderNotSupportedError('retrieveCustomer', this.providerName, {
|
|
236
|
+
reason: 'PayPal does not support standalone customer management.',
|
|
237
|
+
});
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Subscription management
|
|
242
|
+
* Would need PayPal Subscriptions API - different from Orders
|
|
243
|
+
*/
|
|
244
|
+
createSubscription = async (
|
|
245
|
+
params: CreateSubscriptionSchema,
|
|
246
|
+
): Promise<Subscription> => {
|
|
247
|
+
const subscription = await this.subscriptionsController.createSubscription({
|
|
248
|
+
body: params,
|
|
249
|
+
});
|
|
250
|
+
return subscription as unknown as Subscription;
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
cancelSubscription = async (id: string): Promise<Subscription> => {
|
|
254
|
+
const subscription = await this.subscriptionsController.cancelSubscription({
|
|
255
|
+
subscriptionId: id,
|
|
256
|
+
reason: 'Customer requested cancellation',
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
return subscription as unknown as Subscription;
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
updateSubscription = async (
|
|
263
|
+
id: string,
|
|
264
|
+
params: UpdateSubscriptionSchema,
|
|
265
|
+
): Promise<Subscription> => {
|
|
266
|
+
const stringifiedMetadata = JSON.stringify(params.metadata);
|
|
267
|
+
|
|
268
|
+
if (stringifiedMetadata.length > PAYPAL_METADATA_MAX_LENGTH) {
|
|
269
|
+
throw new ConstraintViolationError('Metadata exceeds maximum length', {
|
|
270
|
+
value: stringifiedMetadata.length,
|
|
271
|
+
limit: PAYPAL_METADATA_MAX_LENGTH,
|
|
272
|
+
provider: this.providerName,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
const subscription = await this.subscriptionsController.updateSubscription({
|
|
276
|
+
subscriptionId: id,
|
|
277
|
+
metadata: params.metadata ?? {},
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
return subscription as unknown as Subscription;
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
retrieveSubscription = async (id: string): Promise<Subscription> => {
|
|
284
|
+
const subscription = await this.subscriptionsController.retrieveSubscription({
|
|
285
|
+
subscriptionId: id,
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
return subscription as unknown as Subscription;
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
deleteSubscription = async (id: string): Promise<null> => {
|
|
292
|
+
throw new NotImplementedError('deleteSubscription', 'PayPal', {
|
|
293
|
+
futureSupport: false,
|
|
294
|
+
});
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Payment management
|
|
299
|
+
* In PayPal, Order IS the payment
|
|
300
|
+
*/
|
|
301
|
+
createPayment = async (params: CreatePaymentSchema): Promise<Payment> => {
|
|
302
|
+
const stringifiedMetadata = JSON.stringify(params.metadata);
|
|
303
|
+
|
|
304
|
+
if (stringifiedMetadata.length > PAYPAL_METADATA_MAX_LENGTH) {
|
|
305
|
+
throw new ConstraintViolationError('Metadata exceeds maximum length', {
|
|
306
|
+
value: stringifiedMetadata.length,
|
|
307
|
+
limit: PAYPAL_METADATA_MAX_LENGTH,
|
|
308
|
+
provider: this.providerName,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const orderOptionsBody: Parameters<OrdersController['createOrder']>[0]['body'] = {
|
|
313
|
+
intent: CheckoutPaymentIntent.Capture,
|
|
314
|
+
payer: {
|
|
315
|
+
...(typeof params.customer === 'string' && { payerId: params.customer }),
|
|
316
|
+
...(typeof params.customer === 'object' &&
|
|
317
|
+
'email' in params.customer && { emailAddress: params.customer.email }),
|
|
318
|
+
},
|
|
319
|
+
purchaseUnits: [
|
|
320
|
+
{
|
|
321
|
+
amount: { currencyCode: params.currency, value: params.amount.toString() },
|
|
322
|
+
customId: stringifiedMetadata,
|
|
323
|
+
},
|
|
324
|
+
],
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
if (params.billing) {
|
|
328
|
+
orderOptionsBody.purchaseUnits[0].shipping = {
|
|
329
|
+
name: { fullName: params.billing.address.name },
|
|
330
|
+
address: {
|
|
331
|
+
addressLine1: params.billing.address.line1,
|
|
332
|
+
addressLine2: params.billing.address.line2,
|
|
333
|
+
adminArea1: params.billing.address.city,
|
|
334
|
+
adminArea2: params.billing.address.state,
|
|
335
|
+
postalCode: params.billing.address.postal_code,
|
|
336
|
+
countryCode: params.billing.address.country,
|
|
337
|
+
},
|
|
338
|
+
...(params.billing.address.phone && {
|
|
339
|
+
phoneNumber: {
|
|
340
|
+
nationalNumber: params.billing.address.phone,
|
|
341
|
+
countryCode: params.billing.address.country,
|
|
342
|
+
},
|
|
343
|
+
}),
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const order = await this.ordersController.createOrder({ body: orderOptionsBody });
|
|
348
|
+
|
|
349
|
+
return paykitPayment$InboundSchema(order.result);
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
updatePayment = async (id: string, params: UpdatePaymentSchema): Promise<Payment> => {
|
|
353
|
+
throw new ProviderNotSupportedError('updatePayment', this.providerName, {
|
|
354
|
+
reason: 'PayPal does not support updating orders.',
|
|
355
|
+
});
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
retrievePayment = async (id: string): Promise<Payment | null> => {
|
|
359
|
+
const order = await this.ordersController.getOrder({ id });
|
|
360
|
+
|
|
361
|
+
return paykitPayment$InboundSchema(order.result);
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
deletePayment = async (id: string): Promise<null> => {
|
|
365
|
+
throw new ProviderNotSupportedError('deletePayment', this.providerName, {
|
|
366
|
+
reason: 'PayPal orders cannot be deleted. They expire automatically.',
|
|
367
|
+
});
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
capturePayment = async (id: string): Promise<Payment> => {
|
|
371
|
+
const captured = await this.ordersController.captureOrder({ id });
|
|
372
|
+
return paykitPayment$InboundSchema(captured.result);
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
cancelPayment = async (id: string): Promise<Payment> => {
|
|
376
|
+
// PayPal doesn't have explicit cancel, but you can void authorizations
|
|
377
|
+
throw new ProviderNotSupportedError('cancelPayment', this.providerName, {
|
|
378
|
+
reason:
|
|
379
|
+
'PayPal order cancellation not directly supported. Orders expire automatically.',
|
|
380
|
+
});
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Refund management
|
|
385
|
+
*/
|
|
386
|
+
createRefund = async (params: CreateRefundSchema): Promise<Refund> => {
|
|
387
|
+
const order = await this.ordersController.getOrder({ id: params.payment_id });
|
|
388
|
+
|
|
389
|
+
const captureIds =
|
|
390
|
+
order.result.purchaseUnits?.[0]?.payments?.captures?.map(c => c.id!) || [];
|
|
391
|
+
|
|
392
|
+
if (captureIds.length === 0) {
|
|
393
|
+
throw new ResourceNotFoundError('Capture', params.payment_id, this.providerName);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const currencyCode = order.result.purchaseUnits?.[0]?.amount?.currencyCode || 'USD';
|
|
397
|
+
const amount = params.amount
|
|
398
|
+
? params.amount.toString()
|
|
399
|
+
: order.result.purchaseUnits?.[0]?.amount?.value || '0';
|
|
400
|
+
|
|
401
|
+
const refund = await this.paymentsController.refundCapturedPayment({
|
|
402
|
+
captureId: captureIds[0],
|
|
403
|
+
body: { amount: { currencyCode, value: amount } },
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
return paykitRefund$InboundSchema(refund.result);
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Webhook management
|
|
411
|
+
*/
|
|
412
|
+
handleWebhook = async (
|
|
413
|
+
params: HandleWebhookParams,
|
|
414
|
+
): Promise<Array<WebhookEventPayload>> => {
|
|
415
|
+
const { body, headers, webhookSecret: webhookId } = params;
|
|
416
|
+
|
|
417
|
+
const { result } = await this.webhookController.verifyWebhook({
|
|
418
|
+
authAlgo: headers.get('paypal-auth-algo') as string,
|
|
419
|
+
certUrl: headers.get('paypal-cert-url') as string,
|
|
420
|
+
transmissionId: headers.get('paypal-transmission-id') as string,
|
|
421
|
+
transmissionSig: headers.get('paypal-transmission-sig') as string,
|
|
422
|
+
transmissionTime: headers.get('paypal-transmission-time') as string,
|
|
423
|
+
webhookId,
|
|
424
|
+
webhookEvent: JSON.parse(body),
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
if (result.verification_status !== VerifyWebhookStatus.SUCCESS) {
|
|
428
|
+
throw new WebhookError('Webhook verification failed', {
|
|
429
|
+
provider: this.providerName,
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const event = JSON.parse(body);
|
|
434
|
+
const eventType = event.event_type;
|
|
435
|
+
|
|
436
|
+
const webhookHandlers: Record<string, () => Promise<Array<WebhookEventPayload>>> = {
|
|
437
|
+
'CHECKOUT.ORDER.APPROVED': async () => {
|
|
438
|
+
const orderData = event.resource as Order;
|
|
439
|
+
const payment = paykitPayment$InboundSchema(orderData);
|
|
440
|
+
|
|
441
|
+
return [
|
|
442
|
+
paykitEvent$InboundSchema<Payment>({
|
|
443
|
+
type: 'payment.created',
|
|
444
|
+
created: Date.now() / 1000,
|
|
445
|
+
id: event.id,
|
|
446
|
+
data: payment,
|
|
447
|
+
}),
|
|
448
|
+
];
|
|
449
|
+
},
|
|
450
|
+
|
|
451
|
+
'CHECKOUT.ORDER.COMPLETED': async () => {
|
|
452
|
+
const orderData = event.resource as Order;
|
|
453
|
+
const payment = paykitPayment$InboundSchema(orderData);
|
|
454
|
+
|
|
455
|
+
return [
|
|
456
|
+
paykitEvent$InboundSchema<Payment>({
|
|
457
|
+
type: 'payment.updated',
|
|
458
|
+
created: Date.now() / 1000,
|
|
459
|
+
id: event.id,
|
|
460
|
+
data: payment,
|
|
461
|
+
}),
|
|
462
|
+
];
|
|
463
|
+
},
|
|
464
|
+
|
|
465
|
+
'PAYMENT.CAPTURE.COMPLETED': async () => {
|
|
466
|
+
const orderData = event.resource as Order;
|
|
467
|
+
const payment = paykitPayment$InboundSchema(orderData);
|
|
468
|
+
|
|
469
|
+
return [
|
|
470
|
+
paykitEvent$InboundSchema<Payment>({
|
|
471
|
+
type: 'payment.updated',
|
|
472
|
+
created: Date.now() / 1000,
|
|
473
|
+
id: event.id,
|
|
474
|
+
data: payment,
|
|
475
|
+
}),
|
|
476
|
+
];
|
|
477
|
+
},
|
|
478
|
+
|
|
479
|
+
'PAYMENT.CAPTURE.REFUNDED': async () => {
|
|
480
|
+
const refundData = event.resource as PayPalRefund;
|
|
481
|
+
const refund = paykitRefund$InboundSchema(refundData);
|
|
482
|
+
|
|
483
|
+
return [
|
|
484
|
+
paykitEvent$InboundSchema<Refund>({
|
|
485
|
+
type: 'refund.created',
|
|
486
|
+
created: Date.now() / 1000,
|
|
487
|
+
id: event.id,
|
|
488
|
+
data: refund,
|
|
489
|
+
}),
|
|
490
|
+
];
|
|
491
|
+
},
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
const handler = webhookHandlers[eventType];
|
|
495
|
+
|
|
496
|
+
if (!handler) {
|
|
497
|
+
throw new WebhookError(`Unhandled event type: ${eventType}`, {
|
|
498
|
+
provider: this.providerName,
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
return await handler();
|
|
503
|
+
};
|
|
504
|
+
}
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import {
|
|
2
|
+
lazy,
|
|
3
|
+
object,
|
|
4
|
+
Schema,
|
|
5
|
+
number,
|
|
6
|
+
string,
|
|
7
|
+
stringEnum,
|
|
8
|
+
boolean,
|
|
9
|
+
} from '@paypal/paypal-server-sdk/dist/types/schema';
|
|
10
|
+
|
|
11
|
+
export interface ShippingAmount {
|
|
12
|
+
/**
|
|
13
|
+
* The currency code.
|
|
14
|
+
*/
|
|
15
|
+
currency_code: string;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The value which might be an integer for currencies like JPY that are not typically fractional.
|
|
19
|
+
*/
|
|
20
|
+
value: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const shippingAmountSchema: Schema<ShippingAmount> = object({
|
|
24
|
+
currency_code: ['currency_code', string()],
|
|
25
|
+
value: ['value', string()],
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export interface CreateSubscriptionSchema {
|
|
29
|
+
/**
|
|
30
|
+
* The ID of the plan, maps to `item_id` in the Paykit SDK.
|
|
31
|
+
*/
|
|
32
|
+
plan_id: string;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The quantity of the plan.
|
|
36
|
+
*/
|
|
37
|
+
quantity: number;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The custom ID of the subscription.
|
|
41
|
+
*/
|
|
42
|
+
custom_id: string;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The start time of the subscription.
|
|
46
|
+
*/
|
|
47
|
+
start_time: string;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The subscriber of the subscription.
|
|
51
|
+
*/
|
|
52
|
+
subscriber: {
|
|
53
|
+
email_address: string;
|
|
54
|
+
name: { given_name: string; surname: string };
|
|
55
|
+
phone: { phone_number: string };
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const createSubscriptionApticSchema: Schema<CreateSubscriptionSchema> = object({
|
|
60
|
+
plan_id: ['plan_id', string()],
|
|
61
|
+
quantity: ['quantity', number()],
|
|
62
|
+
custom_id: ['custom_id', string()],
|
|
63
|
+
start_time: ['start_time', string()],
|
|
64
|
+
subscriber: [
|
|
65
|
+
'subscriber',
|
|
66
|
+
lazy(() =>
|
|
67
|
+
object({
|
|
68
|
+
email_address: ['email_address', string()],
|
|
69
|
+
name: [
|
|
70
|
+
'name',
|
|
71
|
+
lazy(() =>
|
|
72
|
+
object({
|
|
73
|
+
given_name: ['given_name', string()],
|
|
74
|
+
surname: ['surname', string()],
|
|
75
|
+
}),
|
|
76
|
+
),
|
|
77
|
+
],
|
|
78
|
+
phone: [
|
|
79
|
+
'phone',
|
|
80
|
+
lazy(() => object({ phone_number: ['phone_number', string()] })),
|
|
81
|
+
],
|
|
82
|
+
}),
|
|
83
|
+
),
|
|
84
|
+
],
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
enum SubscriptionStatus {
|
|
88
|
+
APPROVAL_PENDING = 'APPROVAL_PENDING',
|
|
89
|
+
APPROVED = 'APPROVED',
|
|
90
|
+
ACTIVE = 'ACTIVE',
|
|
91
|
+
SUSPENDED = 'SUSPENDED',
|
|
92
|
+
CANCELLED = 'CANCELLED',
|
|
93
|
+
EXPIRED = 'EXPIRED',
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface Subscription {
|
|
97
|
+
/**
|
|
98
|
+
* The ID of the subscription.
|
|
99
|
+
*/
|
|
100
|
+
id: string;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The ID of the plan.
|
|
104
|
+
*/
|
|
105
|
+
plan_id: string;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The quantity of the subscription.
|
|
109
|
+
*/
|
|
110
|
+
quantity: number;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The status of the subscription.
|
|
114
|
+
*/
|
|
115
|
+
status: SubscriptionStatus;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The time the status was updated.
|
|
119
|
+
*/
|
|
120
|
+
status_update_time: string;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The note for the status change.
|
|
124
|
+
*/
|
|
125
|
+
status_change_note: string;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The time the subscription started.
|
|
129
|
+
*/
|
|
130
|
+
start_time: string;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The subscriber of the subscription.
|
|
134
|
+
*/
|
|
135
|
+
subscriber: {
|
|
136
|
+
/**
|
|
137
|
+
* The email address of the subscriber.
|
|
138
|
+
*/
|
|
139
|
+
email_address: string;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The name of the subscriber.
|
|
143
|
+
*/
|
|
144
|
+
name: {
|
|
145
|
+
/**
|
|
146
|
+
* The given name of the subscriber.
|
|
147
|
+
*/
|
|
148
|
+
given_name: string;
|
|
149
|
+
/**
|
|
150
|
+
* The surname of the subscriber.
|
|
151
|
+
*/
|
|
152
|
+
surname: string;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The ID of the payer.
|
|
157
|
+
*/
|
|
158
|
+
payer_id: string;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Whether the plan was overridden.
|
|
163
|
+
*/
|
|
164
|
+
plan_overridden: boolean;
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The custom ID of the subscription.
|
|
168
|
+
*/
|
|
169
|
+
custom_id: string;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export const subscriptionApticSchema: Schema<Subscription> = object({
|
|
173
|
+
id: ['id', string()],
|
|
174
|
+
plan_id: ['plan_id', string()],
|
|
175
|
+
quantity: ['quantity', number()],
|
|
176
|
+
custom_id: ['custom_id', string()],
|
|
177
|
+
plan_overridden: ['plan_overridden', boolean()],
|
|
178
|
+
start_time: ['start_time', string()],
|
|
179
|
+
subscriber: [
|
|
180
|
+
'subscriber',
|
|
181
|
+
lazy(() =>
|
|
182
|
+
object({
|
|
183
|
+
email_address: ['email_address', string()],
|
|
184
|
+
name: [
|
|
185
|
+
'name',
|
|
186
|
+
lazy(() =>
|
|
187
|
+
object({
|
|
188
|
+
given_name: ['given_name', string()],
|
|
189
|
+
surname: ['surname', string()],
|
|
190
|
+
}),
|
|
191
|
+
),
|
|
192
|
+
],
|
|
193
|
+
payer_id: ['payer_id', string()],
|
|
194
|
+
}),
|
|
195
|
+
),
|
|
196
|
+
],
|
|
197
|
+
status: ['status', stringEnum(SubscriptionStatus)],
|
|
198
|
+
status_change_note: ['status_change_note', string()],
|
|
199
|
+
status_update_time: ['status_update_time', string()],
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
interface ResumeSubscriptionSchema {
|
|
203
|
+
/**
|
|
204
|
+
* The reason for resuming the subscription.
|
|
205
|
+
*/
|
|
206
|
+
reason: string;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export const resumeSubscriptionApticSchemaRequest: Schema<ResumeSubscriptionSchema> =
|
|
210
|
+
object({
|
|
211
|
+
reason: ['reason', string()],
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// WEBHOOK
|
|
215
|
+
|
|
216
|
+
export enum VerifyWebhookStatus {
|
|
217
|
+
SUCCESS = 'SUCCESS',
|
|
218
|
+
FAILURE = 'FAILURE',
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export interface VerifyWebhookSchema {
|
|
222
|
+
/**
|
|
223
|
+
* The status of the verification.
|
|
224
|
+
*/
|
|
225
|
+
verification_status: VerifyWebhookStatus;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export const verifyWebhookSchema: Schema<VerifyWebhookSchema> = object({
|
|
229
|
+
verification_status: ['verification_status', stringEnum(VerifyWebhookStatus)],
|
|
230
|
+
});
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
export type SubscriptionStatus =
|
|
2
|
+
| 'APPROVAL_PENDING'
|
|
3
|
+
| 'APPROVED'
|
|
4
|
+
| 'ACTIVE'
|
|
5
|
+
| 'SUSPENDED'
|
|
6
|
+
| 'CANCELLED'
|
|
7
|
+
| 'EXPIRED';
|
|
8
|
+
|
|
9
|
+
export interface PayPalSubscription {
|
|
10
|
+
/**
|
|
11
|
+
* The ID of the subscription.
|
|
12
|
+
*/
|
|
13
|
+
id: string;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The ID of the plan.
|
|
17
|
+
*/
|
|
18
|
+
plan_id: string;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The status of the subscription.
|
|
22
|
+
*/
|
|
23
|
+
status: SubscriptionStatus;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The time the status was updated.
|
|
27
|
+
*/
|
|
28
|
+
status_update_time?: string;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The time the subscription started.
|
|
32
|
+
*/
|
|
33
|
+
start_time?: string;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The time the subscription was created.
|
|
37
|
+
*/
|
|
38
|
+
create_time?: string;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The subscriber of the subscription.
|
|
42
|
+
*/
|
|
43
|
+
subscriber?: {
|
|
44
|
+
/**
|
|
45
|
+
* The email of the subscriber.
|
|
46
|
+
*/
|
|
47
|
+
email_address?: string;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The name of the subscriber.
|
|
51
|
+
*/
|
|
52
|
+
name?: {
|
|
53
|
+
/**
|
|
54
|
+
* The given name of the subscriber.
|
|
55
|
+
*/
|
|
56
|
+
given_name?: string;
|
|
57
|
+
/**
|
|
58
|
+
* The surname of the subscriber.
|
|
59
|
+
*/
|
|
60
|
+
surname?: string;
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The billing info of the subscription.
|
|
66
|
+
*/
|
|
67
|
+
billing_info?: {
|
|
68
|
+
/**
|
|
69
|
+
* The outstanding balance of the subscription.
|
|
70
|
+
*/
|
|
71
|
+
outstanding_balance?: {
|
|
72
|
+
/**
|
|
73
|
+
* The currency code of the outstanding balance.
|
|
74
|
+
*/
|
|
75
|
+
currency_code?: string;
|
|
76
|
+
/**
|
|
77
|
+
* The value of the outstanding balance.
|
|
78
|
+
*/
|
|
79
|
+
value?: string;
|
|
80
|
+
};
|
|
81
|
+
cycle_executions?: Array<{
|
|
82
|
+
/**
|
|
83
|
+
* The tenure type of the cycle execution.
|
|
84
|
+
*/
|
|
85
|
+
tenure_type?: string;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The sequence of the cycle execution.
|
|
89
|
+
*/
|
|
90
|
+
sequence?: number;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The number of cycles completed.
|
|
94
|
+
*/
|
|
95
|
+
cycles_completed?: number;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The total number of cycles.
|
|
99
|
+
*/
|
|
100
|
+
total_cycles?: number;
|
|
101
|
+
}>;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The links of the subscription.
|
|
106
|
+
*/
|
|
107
|
+
links?: Array<{
|
|
108
|
+
/**
|
|
109
|
+
* The href of the link.
|
|
110
|
+
*/
|
|
111
|
+
href?: string;
|
|
112
|
+
/**
|
|
113
|
+
* The rel of the link.
|
|
114
|
+
*/
|
|
115
|
+
rel?: string;
|
|
116
|
+
/**
|
|
117
|
+
* The method of the link.
|
|
118
|
+
*/
|
|
119
|
+
method?: string;
|
|
120
|
+
}>;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The custom ID of the subscription, i.e metadata
|
|
124
|
+
*/
|
|
125
|
+
customId?: string;
|
|
126
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Checkout,
|
|
3
|
+
omitInternalMetadata,
|
|
4
|
+
Payment,
|
|
5
|
+
Refund,
|
|
6
|
+
Subscription,
|
|
7
|
+
} from '@paykit-sdk/core';
|
|
8
|
+
import { Order, Refund as PayPalRefund } from '@paypal/paypal-server-sdk';
|
|
9
|
+
import { PayPalSubscription } from '../types';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Refund
|
|
13
|
+
*/
|
|
14
|
+
export const paykitRefund$InboundSchema = (refund: PayPalRefund): Refund => {
|
|
15
|
+
return {
|
|
16
|
+
id: refund.id!,
|
|
17
|
+
amount: refund.amount?.value ? parseFloat(refund.amount.value) : 0,
|
|
18
|
+
currency: refund.amount?.currencyCode || 'USD',
|
|
19
|
+
metadata: refund.customId ? omitInternalMetadata(JSON.parse(refund.customId)) : {},
|
|
20
|
+
reason: refund.noteToPayer ?? null,
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Checkout
|
|
26
|
+
*/
|
|
27
|
+
export const paykitCheckout$InboundSchema = (order: Order): Checkout => {
|
|
28
|
+
return {
|
|
29
|
+
id: order.id!,
|
|
30
|
+
payment_url: order.links?.find(l => l.rel === 'approve')?.href || '',
|
|
31
|
+
amount: parseFloat(order.purchaseUnits?.[0]?.amount?.value || '0'),
|
|
32
|
+
currency: order.purchaseUnits?.[0]?.amount?.currencyCode || 'USD',
|
|
33
|
+
customer: order.payer?.payerId
|
|
34
|
+
? order.payer?.payerId
|
|
35
|
+
: { email: order.payer?.emailAddress ?? '' },
|
|
36
|
+
session_type: 'one_time',
|
|
37
|
+
products: [{ id: order.purchaseUnits?.[0]?.items?.[0]?.sku || '', quantity: 1 }],
|
|
38
|
+
metadata: order.purchaseUnits?.[0]?.customId
|
|
39
|
+
? omitInternalMetadata(JSON.parse(order.purchaseUnits?.[0]?.customId))
|
|
40
|
+
: {},
|
|
41
|
+
subscription: null,
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Payment
|
|
47
|
+
*/
|
|
48
|
+
export const paykitPayment$InboundSchema = (order: Order): Payment => {
|
|
49
|
+
const statusMap: Record<string, Payment['status']> = {
|
|
50
|
+
CREATED: 'pending',
|
|
51
|
+
SAVED: 'pending',
|
|
52
|
+
APPROVED: 'requires_capture',
|
|
53
|
+
VOIDED: 'canceled',
|
|
54
|
+
COMPLETED: 'succeeded',
|
|
55
|
+
PAYER_ACTION_REQUIRED: 'requires_action',
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const status = statusMap[order.status ?? statusMap.CREATED];
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
id: order.id!,
|
|
62
|
+
status,
|
|
63
|
+
amount: parseFloat(order.purchaseUnits?.[0]?.amount?.value || '0'),
|
|
64
|
+
currency: order.purchaseUnits?.[0]?.amount?.currencyCode || 'USD',
|
|
65
|
+
metadata: order.purchaseUnits?.[0]?.customId
|
|
66
|
+
? omitInternalMetadata(JSON.parse(order.purchaseUnits?.[0]?.customId))
|
|
67
|
+
: {},
|
|
68
|
+
customer: order.payer?.payerId
|
|
69
|
+
? order.payer?.payerId
|
|
70
|
+
: { email: order.payer?.emailAddress ?? '' },
|
|
71
|
+
item_id: order.purchaseUnits?.[0]?.items?.[0]?.sku || '',
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Subscription
|
|
77
|
+
*/
|
|
78
|
+
export const paykitSubscription$InboundSchema = (
|
|
79
|
+
subscription: PayPalSubscription,
|
|
80
|
+
): Subscription => {
|
|
81
|
+
const statusMap: Record<string, Subscription['status']> = {
|
|
82
|
+
APPROVAL_PENDING: 'pending',
|
|
83
|
+
APPROVED: 'active',
|
|
84
|
+
ACTIVE: 'active',
|
|
85
|
+
SUSPENDED: 'past_due',
|
|
86
|
+
CANCELLED: 'canceled',
|
|
87
|
+
EXPIRED: 'canceled',
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const status = statusMap[subscription.status ?? statusMap.APPROVAL_PENDING];
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
id: subscription.id,
|
|
94
|
+
customer: { email: subscription.subscriber?.email_address ?? '' },
|
|
95
|
+
status,
|
|
96
|
+
item_id: subscription.plan_id,
|
|
97
|
+
current_period_start: subscription.start_time
|
|
98
|
+
? new Date(subscription.start_time)
|
|
99
|
+
: new Date(),
|
|
100
|
+
current_period_end: subscription.status_update_time
|
|
101
|
+
? new Date(subscription.status_update_time)
|
|
102
|
+
: new Date(), // todo: Would need to calculate based on billing cycle
|
|
103
|
+
metadata: subscription.customId
|
|
104
|
+
? omitInternalMetadata(JSON.parse(subscription.customId))
|
|
105
|
+
: {},
|
|
106
|
+
billing_interval: 'month',
|
|
107
|
+
amount: 0,
|
|
108
|
+
currency: 'USD',
|
|
109
|
+
custom_fields: null,
|
|
110
|
+
};
|
|
111
|
+
};
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"declaration": true,
|
|
7
|
+
"outDir": "./dist",
|
|
8
|
+
"rootDir": "./src",
|
|
9
|
+
"strict": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"forceConsistentCasingInFileNames": true,
|
|
13
|
+
"resolveJsonModule": true
|
|
14
|
+
},
|
|
15
|
+
"include": ["src/**/*"],
|
|
16
|
+
"exclude": ["node_modules", "dist"]
|
|
17
|
+
}
|