@commercetools/checkout-payments-processor-fastify 0.0.6
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/CHANGELOG.md +15 -0
- package/README.md +42 -0
- package/biome.json +38 -0
- package/package.json +43 -0
- package/src/context/context.ts +69 -0
- package/src/dtos/error.dto.ts +36 -0
- package/src/dtos/payment-componets.dto.ts +24 -0
- package/src/dtos/payment-intents.dto.ts +74 -0
- package/src/dtos/status.dto.ts +18 -0
- package/src/dummy.test.ts +3 -0
- package/src/error-handler.ts +149 -0
- package/src/global.d.ts +23 -0
- package/src/ip/ip.ts +56 -0
- package/src/plugin.ts +201 -0
- package/tsconfig.json +15 -0
- package/tsconfig.prod.json +9 -0
- package/typedoc.json +6 -0
- package/vitest.config.ts +15 -0
- package/wiki/getting-started.md +13 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# @commercetools/checkout-payments-processor-sdk
|
|
2
|
+
|
|
3
|
+
## 0.0.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- af90f7f: Sample change for all the packages
|
|
8
|
+
- Updated dependencies [af90f7f]
|
|
9
|
+
- @commercetools/checkout-payments-processor-sdk@0.0.2
|
|
10
|
+
|
|
11
|
+
## 0.0.1
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- Initialising repository
|
package/README.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# @commercetools/checkout-payments-processor-sdk
|
|
2
|
+
|
|
3
|
+
The Processor Module acts as the middleware between commercetools and the payment service provider, handling transaction processing and payment updates. It handles key operations such as payment authorization, capture, refund, and cancellation directly with the payment service provider while ensuring transactions are processed and saved accurately in commercetools composable commerce.
|
|
4
|
+
|
|
5
|
+
## Key Responsibilities
|
|
6
|
+
* Receives payment requests from the enabler.
|
|
7
|
+
* Communicates with the PSP for authorization, capture, refund, and cancellation.
|
|
8
|
+
* Updates commercetools payment objects based on PSP responses.
|
|
9
|
+
* Ensures secure and idempotent transaction handling.
|
|
10
|
+
|
|
11
|
+
## Prerequisites
|
|
12
|
+
|
|
13
|
+
commercetools Checkout has support for two component integration types (payment components and drop-in components)
|
|
14
|
+
|
|
15
|
+
The payment connector requires:
|
|
16
|
+
|
|
17
|
+
* A commercetools Composable Commerce API Client with permissions for:
|
|
18
|
+
* `manage_payments`
|
|
19
|
+
* `manage_orders`
|
|
20
|
+
* `view_sessions`
|
|
21
|
+
* `view_api_clients`
|
|
22
|
+
* `manage_checkout_payment_intents`
|
|
23
|
+
* `introspect_oauth_tokens`
|
|
24
|
+
|
|
25
|
+
* Additional API credentials capable of managing connnector-related operations. This includes tasks like:
|
|
26
|
+
* Installing the connector
|
|
27
|
+
* Deploying and updating the connector
|
|
28
|
+
* Performing connector-related maintenance operations
|
|
29
|
+
|
|
30
|
+
For more details on setting up and managing connectors, refer to the commercetools Connector Deployment Documentation.
|
|
31
|
+
|
|
32
|
+
# Access & Authentication
|
|
33
|
+
The integration uses several authentication methods to ensure secure communication between different components. These include:
|
|
34
|
+
|
|
35
|
+
## Session Authentication
|
|
36
|
+
The integration will use session-based authentication for frontend-to-backend communication. The session information is passed via the X-Session-Id header, which is validated by the Processor to ensure the request is trusted and associated with the correct session.
|
|
37
|
+
|
|
38
|
+
## JWT Validation
|
|
39
|
+
JSON Web Tokens (JWT) are used to validate operations performed within the commercetools Merchant Center. The Processor supports JWT validation to authenticate and authorize requests for internal operations.
|
|
40
|
+
|
|
41
|
+
## OAuth Authentication
|
|
42
|
+
OAuth 2.0 is used for server-to-server communication to manage secure operations such as capture, cancel, and refund of payments. The OAuth credentials are validated via the introspect_oauth_tokens scope to ensure that the Processor has the necessary permissions for these operations.
|
package/biome.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://biomejs.dev/schemas/2.2.4/schema.json",
|
|
3
|
+
"vcs": {
|
|
4
|
+
"enabled": false,
|
|
5
|
+
"clientKind": "git",
|
|
6
|
+
"useIgnoreFile": false
|
|
7
|
+
},
|
|
8
|
+
"files": {
|
|
9
|
+
"ignoreUnknown": false
|
|
10
|
+
},
|
|
11
|
+
"formatter": {
|
|
12
|
+
"enabled": true,
|
|
13
|
+
"indentStyle": "space",
|
|
14
|
+
"indentWidth": 2
|
|
15
|
+
},
|
|
16
|
+
"linter": {
|
|
17
|
+
"enabled": true,
|
|
18
|
+
"rules": {
|
|
19
|
+
"recommended": true,
|
|
20
|
+
"style": {
|
|
21
|
+
"useImportType": "error"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"javascript": {
|
|
26
|
+
"formatter": {
|
|
27
|
+
"quoteStyle": "single"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"assist": {
|
|
31
|
+
"enabled": true,
|
|
32
|
+
"actions": {
|
|
33
|
+
"source": {
|
|
34
|
+
"organizeImports": "on"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@commercetools/checkout-payments-processor-fastify",
|
|
3
|
+
"version": "0.0.6",
|
|
4
|
+
"description": "Checkout payments SDK for processor",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/plugin.js",
|
|
7
|
+
"types": "dist/plugin.d.ts",
|
|
8
|
+
"publishConfig": {
|
|
9
|
+
"access": "public"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [],
|
|
12
|
+
"author": "commercetools",
|
|
13
|
+
"license": "ISC",
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"@biomejs/biome": "2.2.5",
|
|
16
|
+
"@tsconfig/node24": "24.0.1",
|
|
17
|
+
"@types/node": "24.7.0",
|
|
18
|
+
"@vitest/coverage-istanbul": "^3.2.4",
|
|
19
|
+
"tsc-alias": "1.8.16",
|
|
20
|
+
"tsx": "4.20.6",
|
|
21
|
+
"typedoc": "0.28.13",
|
|
22
|
+
"typescript": "5.9.3",
|
|
23
|
+
"vite-tsconfig-paths": "5.1.4",
|
|
24
|
+
"vitest": "3.2.4"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@fastify/request-context": "6.2.1",
|
|
28
|
+
"@sinclair/typebox": "0.34.41",
|
|
29
|
+
"fastify": "5.7.3",
|
|
30
|
+
"fastify-plugin": "5.1.0",
|
|
31
|
+
"schema": "link:fastify/types/schema",
|
|
32
|
+
"@commercetools/checkout-payments-processor-sdk": "0.0.6"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "rm -rf ./dist && tsc -p tsconfig.prod.json && tsc-alias",
|
|
36
|
+
"clean": "rm -rf ./dist ./coverage ./node_modules",
|
|
37
|
+
"format": "biome check --write ./src",
|
|
38
|
+
"lint": "biome check ./src",
|
|
39
|
+
"test": "vitest run --coverage",
|
|
40
|
+
"test:watch": "vitest",
|
|
41
|
+
"doc": "typedoc --options typedoc.json"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import * as paymentSdk from '@commercetools/checkout-payments-processor-sdk';
|
|
2
|
+
import { requestContext } from '@fastify/request-context';
|
|
3
|
+
|
|
4
|
+
export type ContextData = {
|
|
5
|
+
anonymousId?: string;
|
|
6
|
+
customerId?: string;
|
|
7
|
+
path?: string;
|
|
8
|
+
pathTemplate?: string;
|
|
9
|
+
// biome-ignore lint/suspicious/noExplicitAny: path params is any
|
|
10
|
+
pathParams?: any;
|
|
11
|
+
// biome-ignore lint/suspicious/noExplicitAny: query is any
|
|
12
|
+
query?: any;
|
|
13
|
+
correlationId: string;
|
|
14
|
+
requestId: string;
|
|
15
|
+
authentication?: paymentSdk.Authentication;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const getRequestContext = (): Partial<ContextData> => {
|
|
19
|
+
return requestContext.get('request') ?? {};
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const setRequestContext = (ctx: ContextData) => {
|
|
23
|
+
requestContext.set('request', ctx);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const updateRequestContext = (ctx: Partial<ContextData>) => {
|
|
27
|
+
const currentContext = getRequestContext();
|
|
28
|
+
setRequestContext({
|
|
29
|
+
...(currentContext as ContextData),
|
|
30
|
+
...ctx,
|
|
31
|
+
});
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const getCtSessionIdFromContext = (): string => {
|
|
35
|
+
const contextData = getRequestContext() as ContextData;
|
|
36
|
+
return paymentSdk.getCtSessionIdFromContext(contextData) as string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const getCartIdFromContext = (): string => {
|
|
40
|
+
const contextData = getRequestContext() as ContextData;
|
|
41
|
+
return paymentSdk.getCartIdFromContext(contextData) as string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const getAllowedPaymentMethodsFromContext = (): string[] => {
|
|
45
|
+
const contextData = getRequestContext() as ContextData;
|
|
46
|
+
return paymentSdk.getAllowedPaymentMethodsFromContext(
|
|
47
|
+
contextData,
|
|
48
|
+
) as string[];
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const getPaymentInterfaceFromContext = (): string | undefined => {
|
|
52
|
+
const contextData = getRequestContext() as ContextData;
|
|
53
|
+
return paymentSdk.getPaymentInterfaceFromContext(contextData);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const getProcessorUrlFromContext = (): string => {
|
|
57
|
+
const contextData = getRequestContext() as ContextData;
|
|
58
|
+
return paymentSdk.getProcessorUrlFromContext(contextData) as string;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const getMerchantReturnUrlFromContext = (): string | undefined => {
|
|
62
|
+
const contextData = getRequestContext() as ContextData;
|
|
63
|
+
return paymentSdk.getMerchantReturnUrlFromContext(contextData);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export const getFutureOrderNumberFromContext = (): string | undefined => {
|
|
67
|
+
const contextData = getRequestContext() as ContextData;
|
|
68
|
+
return paymentSdk.getFutureOrderNumberFromContext(contextData);
|
|
69
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type Static, Type } from '@sinclair/typebox';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Represents https://docs.commercetools.com/api/errors#errorobject
|
|
5
|
+
*/
|
|
6
|
+
export const ErrorObject = Type.Object(
|
|
7
|
+
{
|
|
8
|
+
code: Type.String(),
|
|
9
|
+
message: Type.String(),
|
|
10
|
+
},
|
|
11
|
+
{ additionalProperties: true },
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Represents https://docs.commercetools.com/api/errors#errorresponse
|
|
16
|
+
*/
|
|
17
|
+
export const ErrorResponse = Type.Object({
|
|
18
|
+
statusCode: Type.Integer(),
|
|
19
|
+
message: Type.String(),
|
|
20
|
+
errors: Type.Array(ErrorObject),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Represents https://docs.commercetools.com/api/errors#autherrorresponse
|
|
25
|
+
*/
|
|
26
|
+
export const AuthErrorResponse = Type.Composite([
|
|
27
|
+
ErrorResponse,
|
|
28
|
+
Type.Object({
|
|
29
|
+
error: Type.String(),
|
|
30
|
+
error_description: Type.Optional(Type.String()),
|
|
31
|
+
}),
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
export type TErrorObject = Static<typeof ErrorObject>;
|
|
35
|
+
export type TErrorResponse = Static<typeof ErrorResponse>;
|
|
36
|
+
export type TAuthErrorResponse = Static<typeof AuthErrorResponse>;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type Static, Type } from '@sinclair/typebox';
|
|
2
|
+
|
|
3
|
+
const DropinType = Type.Enum({
|
|
4
|
+
EMBEDDED: 'embedded',
|
|
5
|
+
HPP: 'hpp',
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
export const SupportedPaymentDropinsData = Type.Object({
|
|
9
|
+
type: DropinType,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export const SupportedPaymentComponentsData = Type.Object({
|
|
13
|
+
type: Type.String(),
|
|
14
|
+
subtypes: Type.Optional(Type.Array(Type.String())),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export const SupportedPaymentComponentsSchema = Type.Object({
|
|
18
|
+
dropins: Type.Array(SupportedPaymentDropinsData),
|
|
19
|
+
components: Type.Array(SupportedPaymentComponentsData),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export type SupportedPaymentComponentsSchemaDTO = Static<
|
|
23
|
+
typeof SupportedPaymentComponentsSchema
|
|
24
|
+
>;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { type Static, Type } from '@sinclair/typebox';
|
|
2
|
+
|
|
3
|
+
export const AmountSchema = Type.Object({
|
|
4
|
+
centAmount: Type.Integer(),
|
|
5
|
+
currencyCode: Type.String(),
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
export const ActionCapturePaymentSchema = Type.Composite([
|
|
9
|
+
Type.Object({
|
|
10
|
+
action: Type.Literal('capturePayment'),
|
|
11
|
+
}),
|
|
12
|
+
Type.Object({
|
|
13
|
+
amount: AmountSchema,
|
|
14
|
+
merchantReference: Type.Optional(Type.String()),
|
|
15
|
+
}),
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
export const ActionRefundPaymentSchema = Type.Composite([
|
|
19
|
+
Type.Object({
|
|
20
|
+
action: Type.Literal('refundPayment'),
|
|
21
|
+
}),
|
|
22
|
+
Type.Object({
|
|
23
|
+
amount: AmountSchema,
|
|
24
|
+
merchantReference: Type.Optional(Type.String()),
|
|
25
|
+
transactionId: Type.Optional(Type.String()),
|
|
26
|
+
}),
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
export const ActionCancelPaymentSchema = Type.Composite([
|
|
30
|
+
Type.Object({
|
|
31
|
+
action: Type.Literal('cancelPayment'),
|
|
32
|
+
merchantReference: Type.Optional(Type.String()),
|
|
33
|
+
}),
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
export const ActionReversePaymentSchema = Type.Composite([
|
|
37
|
+
Type.Object({
|
|
38
|
+
action: Type.Literal('reversePayment'),
|
|
39
|
+
merchantReference: Type.Optional(Type.String()),
|
|
40
|
+
}),
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
export const PaymentIntentRequestSchema = Type.Object({
|
|
44
|
+
actions: Type.Array(
|
|
45
|
+
Type.Union([
|
|
46
|
+
ActionCapturePaymentSchema,
|
|
47
|
+
ActionRefundPaymentSchema,
|
|
48
|
+
ActionCancelPaymentSchema,
|
|
49
|
+
ActionReversePaymentSchema,
|
|
50
|
+
]),
|
|
51
|
+
{
|
|
52
|
+
maxItems: 1,
|
|
53
|
+
},
|
|
54
|
+
),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
export enum PaymentModificationStatus {
|
|
58
|
+
APPROVED = 'approved',
|
|
59
|
+
REJECTED = 'rejected',
|
|
60
|
+
RECEIVED = 'received',
|
|
61
|
+
}
|
|
62
|
+
const PaymentModificationSchema = Type.Enum(PaymentModificationStatus);
|
|
63
|
+
|
|
64
|
+
export const PaymentIntentResponseSchema = Type.Object({
|
|
65
|
+
outcome: PaymentModificationSchema,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
export type PaymentIntentRequestSchemaDTO = Static<
|
|
69
|
+
typeof PaymentIntentRequestSchema
|
|
70
|
+
>;
|
|
71
|
+
export type PaymentIntentResponseSchemaDTO = Static<
|
|
72
|
+
typeof PaymentIntentResponseSchema
|
|
73
|
+
>;
|
|
74
|
+
export type AmountSchemaDTO = Static<typeof AmountSchema>;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type Static, Type } from '@sinclair/typebox';
|
|
2
|
+
|
|
3
|
+
export const StatusResponseSchema = Type.Object({
|
|
4
|
+
status: Type.String(),
|
|
5
|
+
timestamp: Type.String(),
|
|
6
|
+
version: Type.String(),
|
|
7
|
+
metadata: Type.Optional(Type.Any()),
|
|
8
|
+
checks: Type.Array(
|
|
9
|
+
Type.Object({
|
|
10
|
+
name: Type.String(),
|
|
11
|
+
status: Type.String(),
|
|
12
|
+
details: Type.Optional(Type.Any()),
|
|
13
|
+
message: Type.Optional(Type.String()),
|
|
14
|
+
}),
|
|
15
|
+
),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export type StatusResponseSchemaDTO = Static<typeof StatusResponseSchema>;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type { Logger } from '@commercetools/checkout-payments-processor-sdk';
|
|
2
|
+
import {
|
|
3
|
+
ErrorAuthErrorResponse,
|
|
4
|
+
ErrorGeneral,
|
|
5
|
+
ErrorInvalidField,
|
|
6
|
+
ErrorInvalidJsonInput,
|
|
7
|
+
ErrorRequiredField,
|
|
8
|
+
Errorx,
|
|
9
|
+
MultiErrorx,
|
|
10
|
+
} from '@commercetools/checkout-payments-processor-sdk';
|
|
11
|
+
import type { FastifyError, FastifyReply, FastifyRequest } from 'fastify';
|
|
12
|
+
import type { FastifySchemaValidationError } from 'fastify/types/schema.js';
|
|
13
|
+
import type {
|
|
14
|
+
TAuthErrorResponse,
|
|
15
|
+
TErrorObject,
|
|
16
|
+
TErrorResponse,
|
|
17
|
+
} from './dtos/error.dto.js';
|
|
18
|
+
|
|
19
|
+
export const errorHandler = ({ logger }: { logger: Logger }) => {
|
|
20
|
+
function isFastifyValidationError(error: Error): error is FastifyError {
|
|
21
|
+
return (error as unknown as FastifyError).validation !== undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const handleAuthError = (
|
|
25
|
+
error: ErrorAuthErrorResponse,
|
|
26
|
+
reply: FastifyReply,
|
|
27
|
+
) => {
|
|
28
|
+
const transformedErrors: TErrorObject[] = transformErrorxToHTTPModel([
|
|
29
|
+
error,
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
const response: TAuthErrorResponse = {
|
|
33
|
+
message: error.message,
|
|
34
|
+
statusCode: error.httpErrorStatus,
|
|
35
|
+
errors: transformedErrors,
|
|
36
|
+
error: transformedErrors[0].code,
|
|
37
|
+
error_description: transformedErrors[0].message,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return reply.code(error.httpErrorStatus).send(response);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const handleErrors = (errorxList: Errorx[], reply: FastifyReply) => {
|
|
44
|
+
const transformedErrors: TErrorObject[] =
|
|
45
|
+
transformErrorxToHTTPModel(errorxList);
|
|
46
|
+
|
|
47
|
+
// Based on CoCo specs, the root level message attribute is always set to the values from the first error. MultiErrorx enforces the same HTTP status code.
|
|
48
|
+
const response: TErrorResponse = {
|
|
49
|
+
message: errorxList[0].message,
|
|
50
|
+
statusCode: errorxList[0].httpErrorStatus,
|
|
51
|
+
errors: transformedErrors,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
return reply.code(errorxList[0].httpErrorStatus).send(response);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const transformErrorxToHTTPModel = (errors: Errorx[]): TErrorObject[] => {
|
|
58
|
+
const errorObjectList: TErrorObject[] = [];
|
|
59
|
+
|
|
60
|
+
for (const err of errors) {
|
|
61
|
+
if (err.skipLog) {
|
|
62
|
+
logger.debug({ err }, err.message);
|
|
63
|
+
} else {
|
|
64
|
+
logger.error({ err }, err.message);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const tErrObj: TErrorObject = {
|
|
68
|
+
code: err.code,
|
|
69
|
+
message: err.message,
|
|
70
|
+
...(err.fields ? err.fields : {}), // Add any additional field to the response object (which will differ per type of error)
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
errorObjectList.push(tErrObj);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return errorObjectList;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const transformValidationErrors = (
|
|
80
|
+
errors: FastifySchemaValidationError[],
|
|
81
|
+
req: FastifyRequest,
|
|
82
|
+
): Errorx[] => {
|
|
83
|
+
const errorxList: Errorx[] = [];
|
|
84
|
+
|
|
85
|
+
for (const err of errors) {
|
|
86
|
+
switch (err.keyword) {
|
|
87
|
+
case 'required':
|
|
88
|
+
errorxList.push(
|
|
89
|
+
new ErrorRequiredField(err.params.missingProperty as string),
|
|
90
|
+
);
|
|
91
|
+
break;
|
|
92
|
+
case 'enum':
|
|
93
|
+
errorxList.push(
|
|
94
|
+
new ErrorInvalidField(
|
|
95
|
+
getKeys(err.instancePath).join('.'),
|
|
96
|
+
getPropertyFromPath(err.instancePath, req.body),
|
|
97
|
+
err.params.allowedValues as string,
|
|
98
|
+
),
|
|
99
|
+
);
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// If we cannot map the validation error to a CoCo error then return a general InvalidJsonError
|
|
105
|
+
if (errorxList.length === 0) {
|
|
106
|
+
errorxList.push(new ErrorInvalidJsonInput());
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return errorxList;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const getKeys = (path: string) => path.replace(/^\//, '').split('/');
|
|
113
|
+
|
|
114
|
+
// biome-ignore lint/suspicious/noExplicitAny: obj is any
|
|
115
|
+
const getPropertyFromPath = (path: string, obj: any): any => {
|
|
116
|
+
const keys = getKeys(path);
|
|
117
|
+
let value = obj;
|
|
118
|
+
for (const key of keys) {
|
|
119
|
+
value = value[key];
|
|
120
|
+
}
|
|
121
|
+
return value;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
return (error: Error, req: FastifyRequest, reply: FastifyReply) => {
|
|
125
|
+
if (isFastifyValidationError(error) && error.validation) {
|
|
126
|
+
return handleErrors(
|
|
127
|
+
transformValidationErrors(error.validation, req),
|
|
128
|
+
reply,
|
|
129
|
+
);
|
|
130
|
+
} else if (error instanceof ErrorAuthErrorResponse) {
|
|
131
|
+
return handleAuthError(error, reply);
|
|
132
|
+
} else if (error instanceof Errorx) {
|
|
133
|
+
return handleErrors([error], reply);
|
|
134
|
+
} else if (error instanceof MultiErrorx) {
|
|
135
|
+
return handleErrors(error.errors, reply);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// If it isn't any of the cases above (for example a normal Error is thrown) then fallback to a general 500 internal server error
|
|
139
|
+
return handleErrors(
|
|
140
|
+
[
|
|
141
|
+
new ErrorGeneral('Internal server error.', {
|
|
142
|
+
cause: error,
|
|
143
|
+
skipLog: false,
|
|
144
|
+
}),
|
|
145
|
+
],
|
|
146
|
+
reply,
|
|
147
|
+
);
|
|
148
|
+
};
|
|
149
|
+
};
|
package/src/global.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import '@fastify/request-context';
|
|
2
|
+
import {
|
|
3
|
+
ContextData,
|
|
4
|
+
SessionContextData,
|
|
5
|
+
} from './libs/fastify/context/context';
|
|
6
|
+
|
|
7
|
+
declare module '@fastify/request-context' {
|
|
8
|
+
interface RequestContextData {
|
|
9
|
+
request: ContextData;
|
|
10
|
+
session?: SessionContextData;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
declare module 'fastify' {
|
|
15
|
+
interface FastifyInstance {
|
|
16
|
+
// biome-ignore lint/suspicious/noExplicitAny: vite is any
|
|
17
|
+
vite: any;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface FastifyRequest {
|
|
21
|
+
correlationId?: string;
|
|
22
|
+
}
|
|
23
|
+
}
|
package/src/ip/ip.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify';
|
|
2
|
+
import fp from 'fastify-plugin';
|
|
3
|
+
|
|
4
|
+
const HEADERS_ORDER = [
|
|
5
|
+
'x-client-ip', // Most common
|
|
6
|
+
'x-forwarded-for', // Mostly used by proxies
|
|
7
|
+
'cf-connecting-ip', // Cloudflare
|
|
8
|
+
'Cf-Pseudo-IPv4', // Cloudflare
|
|
9
|
+
'fastly-client-ip',
|
|
10
|
+
'true-client-ip', // Akamai and Cloudflare
|
|
11
|
+
'x-real-ip', // Nginx
|
|
12
|
+
'x-cluser-client-ip', // Rackspace LB
|
|
13
|
+
'forwarded-for',
|
|
14
|
+
'x-forwarded',
|
|
15
|
+
'forwarded',
|
|
16
|
+
'x-appengine-user-ip', // GCP App Engine
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
type HeaderKey = (typeof HEADERS_ORDER)[number];
|
|
20
|
+
|
|
21
|
+
declare module 'fastify' {
|
|
22
|
+
interface FastifyRequest {
|
|
23
|
+
clientIp: string;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function extractIpFromHeader(
|
|
28
|
+
value: string | string[] | undefined,
|
|
29
|
+
): string | null {
|
|
30
|
+
if (!value) return null;
|
|
31
|
+
if (Array.isArray(value)) value = value[0];
|
|
32
|
+
return value.split(',')[0].trim();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const requestIpPlugin = fp(async (fastify: FastifyInstance) => {
|
|
36
|
+
fastify.decorateRequest('clientIp', '');
|
|
37
|
+
|
|
38
|
+
fastify.addHook('onRequest', async (request) => {
|
|
39
|
+
let ip = '';
|
|
40
|
+
|
|
41
|
+
for (const header of HEADERS_ORDER) {
|
|
42
|
+
const value = request.headers[header as HeaderKey] as string | undefined;
|
|
43
|
+
const extracted = extractIpFromHeader(value);
|
|
44
|
+
if (extracted) {
|
|
45
|
+
ip = extracted;
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!ip) {
|
|
51
|
+
ip = request.ip;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
request.clientIp = ip;
|
|
55
|
+
});
|
|
56
|
+
});
|
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import type {
|
|
3
|
+
AuthType,
|
|
4
|
+
CheckoutPaymentsProcessor,
|
|
5
|
+
PaymentComponentsHandler,
|
|
6
|
+
PaymentComponentsIncomingHttpHeaders,
|
|
7
|
+
PaymentComponentsRequest,
|
|
8
|
+
PaymentComponentsResponse,
|
|
9
|
+
PaymentIntentsHandler,
|
|
10
|
+
PaymentIntentsRequest,
|
|
11
|
+
PaymentIntentsResponse,
|
|
12
|
+
StatusHandler,
|
|
13
|
+
StatusIncomingHttpHeaders,
|
|
14
|
+
StatusRequest,
|
|
15
|
+
StatusResponse,
|
|
16
|
+
TransactionHandler,
|
|
17
|
+
TransactionIncomingHttpHeaders,
|
|
18
|
+
TransactionRequest,
|
|
19
|
+
TransactionResponse,
|
|
20
|
+
} from '@commercetools/checkout-payments-processor-sdk';
|
|
21
|
+
|
|
22
|
+
import fastifyRequestContext from '@fastify/request-context';
|
|
23
|
+
import { Type } from '@sinclair/typebox';
|
|
24
|
+
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify';
|
|
25
|
+
import { SupportedPaymentComponentsSchema } from './dtos/payment-componets.dto.js';
|
|
26
|
+
import {
|
|
27
|
+
PaymentIntentRequestSchema,
|
|
28
|
+
PaymentIntentResponseSchema,
|
|
29
|
+
} from './dtos/payment-intents.dto.js';
|
|
30
|
+
import { StatusResponseSchema } from './dtos/status.dto.js';
|
|
31
|
+
import { errorHandler } from './error-handler.js';
|
|
32
|
+
|
|
33
|
+
interface CheckoutPaymentsProcessorFastifyPluginOptions {
|
|
34
|
+
processorSDK: CheckoutPaymentsProcessor;
|
|
35
|
+
paymentComponentsHandler: PaymentComponentsHandler;
|
|
36
|
+
paymentIntentsHandler: PaymentIntentsHandler;
|
|
37
|
+
statusHandler: StatusHandler;
|
|
38
|
+
transactionHandler: TransactionHandler;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type HookFn<Request, Response> = (
|
|
42
|
+
request: FastifyRequest<{ Body: Request; Reply: Response }>,
|
|
43
|
+
reply: FastifyReply,
|
|
44
|
+
) => Promise<void>;
|
|
45
|
+
|
|
46
|
+
export const CheckoutPaymentsProcessorFastifyPluginOptions: FastifyPluginAsync<
|
|
47
|
+
CheckoutPaymentsProcessorFastifyPluginOptions
|
|
48
|
+
> = async (fastify, options) => {
|
|
49
|
+
const {
|
|
50
|
+
processorSDK,
|
|
51
|
+
statusHandler,
|
|
52
|
+
paymentComponentsHandler,
|
|
53
|
+
paymentIntentsHandler,
|
|
54
|
+
transactionHandler,
|
|
55
|
+
} = options;
|
|
56
|
+
|
|
57
|
+
fastify.setErrorHandler(errorHandler({ logger: processorSDK.getLogger() }));
|
|
58
|
+
|
|
59
|
+
fastify.decorateRequest('correlationId', '');
|
|
60
|
+
|
|
61
|
+
// Propagate the correlationId from the request header to the request object
|
|
62
|
+
fastify.addHook('onRequest', (req, _reply, done) => {
|
|
63
|
+
req.correlationId = req.headers['x-correlation-id']
|
|
64
|
+
? (req.headers['x-correlation-id'] as string)
|
|
65
|
+
: undefined;
|
|
66
|
+
done();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Register the request context
|
|
70
|
+
await fastify.register(fastifyRequestContext, {
|
|
71
|
+
defaultStoreValues: (req: FastifyRequest) => ({
|
|
72
|
+
request: {
|
|
73
|
+
path: req.url,
|
|
74
|
+
pathTemplate: req.routeOptions.url,
|
|
75
|
+
pathParams: req.params,
|
|
76
|
+
query: req.query,
|
|
77
|
+
correlationId: req.correlationId || randomUUID().toString(),
|
|
78
|
+
requestId: req.id,
|
|
79
|
+
},
|
|
80
|
+
}),
|
|
81
|
+
hook: 'onRequest',
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const preHandlersMapper = <Request, Response>(
|
|
85
|
+
auths: AuthType[],
|
|
86
|
+
): HookFn<Request, Response>[] => {
|
|
87
|
+
const result: HookFn<Request, Response>[] = [];
|
|
88
|
+
for (const auth of auths) {
|
|
89
|
+
if (auth.type === 'jwt') {
|
|
90
|
+
result.push(processorSDK.getJwtAuthHookFn().authenticate());
|
|
91
|
+
} else if (auth.type === 'oauth2') {
|
|
92
|
+
result.push(processorSDK.getOauth2AuthHookFn().authenticate());
|
|
93
|
+
result.push(
|
|
94
|
+
processorSDK
|
|
95
|
+
.getAuthorityAuthorizationHookFn()
|
|
96
|
+
.authorize(...auth.allowedScopes),
|
|
97
|
+
);
|
|
98
|
+
} else if (auth.type === 'session') {
|
|
99
|
+
result.push(processorSDK.getSessionHeaderAuthHookFn().authenticate());
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return result;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
fastify.route<{ Body: StatusRequest; Reply: StatusResponse }>({
|
|
106
|
+
method: statusHandler.method(),
|
|
107
|
+
url: statusHandler.endpoint(),
|
|
108
|
+
preHandler: preHandlersMapper<StatusRequest, StatusResponse>(
|
|
109
|
+
statusHandler.auth(),
|
|
110
|
+
),
|
|
111
|
+
schema: {
|
|
112
|
+
response: {
|
|
113
|
+
200: StatusResponseSchema,
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
handler: async (request, reply) => {
|
|
117
|
+
const response = await statusHandler.handler({
|
|
118
|
+
request: request.body as StatusRequest,
|
|
119
|
+
headers: request.headers as StatusIncomingHttpHeaders,
|
|
120
|
+
});
|
|
121
|
+
reply.code(200).send(response.body);
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
fastify.route<{
|
|
126
|
+
Body: PaymentIntentsRequest;
|
|
127
|
+
Reply: PaymentIntentsResponse;
|
|
128
|
+
Params: { id: string };
|
|
129
|
+
}>({
|
|
130
|
+
method: paymentIntentsHandler.method(),
|
|
131
|
+
url: paymentIntentsHandler.endpoint(),
|
|
132
|
+
preHandler: preHandlersMapper<
|
|
133
|
+
PaymentIntentsRequest,
|
|
134
|
+
PaymentIntentsResponse
|
|
135
|
+
>(paymentIntentsHandler.auth()),
|
|
136
|
+
schema: {
|
|
137
|
+
params: {
|
|
138
|
+
$id: 'paramsSchema',
|
|
139
|
+
type: 'object',
|
|
140
|
+
properties: {
|
|
141
|
+
id: Type.String(),
|
|
142
|
+
},
|
|
143
|
+
required: ['id'],
|
|
144
|
+
},
|
|
145
|
+
body: PaymentIntentRequestSchema,
|
|
146
|
+
response: {
|
|
147
|
+
200: PaymentIntentResponseSchema,
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
handler: async (request, reply) => {
|
|
151
|
+
const { id } = request.params;
|
|
152
|
+
const body = request.body as PaymentIntentsRequest;
|
|
153
|
+
const response = await paymentIntentsHandler.handler({
|
|
154
|
+
request: {
|
|
155
|
+
id,
|
|
156
|
+
actions: body.actions,
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
reply.code(200).send(response.body);
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
fastify.route<{
|
|
164
|
+
Body: PaymentComponentsRequest;
|
|
165
|
+
Reply: PaymentComponentsResponse;
|
|
166
|
+
}>({
|
|
167
|
+
method: paymentComponentsHandler.method(),
|
|
168
|
+
url: paymentComponentsHandler.endpoint(),
|
|
169
|
+
preHandler: preHandlersMapper<
|
|
170
|
+
PaymentComponentsRequest,
|
|
171
|
+
PaymentComponentsResponse
|
|
172
|
+
>(paymentComponentsHandler.auth()),
|
|
173
|
+
schema: {
|
|
174
|
+
response: {
|
|
175
|
+
200: SupportedPaymentComponentsSchema,
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
handler: async (request, reply) => {
|
|
179
|
+
const response = await paymentComponentsHandler.handler({
|
|
180
|
+
request: request.body as PaymentComponentsRequest,
|
|
181
|
+
headers: request.headers as PaymentComponentsIncomingHttpHeaders,
|
|
182
|
+
});
|
|
183
|
+
reply.code(200).send(response.body);
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
fastify.route<{ Body: TransactionRequest; Reply: TransactionResponse }>({
|
|
188
|
+
method: transactionHandler.method(),
|
|
189
|
+
url: transactionHandler.endpoint(),
|
|
190
|
+
preHandler: preHandlersMapper<TransactionRequest, TransactionResponse>(
|
|
191
|
+
transactionHandler.auth(),
|
|
192
|
+
),
|
|
193
|
+
handler: async (request, reply) => {
|
|
194
|
+
const response = await transactionHandler.handler({
|
|
195
|
+
request: request.body as TransactionRequest,
|
|
196
|
+
headers: request.headers as TransactionIncomingHttpHeaders,
|
|
197
|
+
});
|
|
198
|
+
reply.code(response.status).send(response.body);
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
};
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "@tsconfig/node24/tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"types": ["vitest/globals"],
|
|
5
|
+
"outDir": "./dist",
|
|
6
|
+
"rootDir": "./src",
|
|
7
|
+
"declaration": true,
|
|
8
|
+
"declarationMap": true,
|
|
9
|
+
"paths": {
|
|
10
|
+
"@/*": ["./src/*"],
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"exclude": ["src/**/*.[test|spec|mock].ts"],
|
|
14
|
+
"include": ["src/**/*.ts"]
|
|
15
|
+
}
|
package/typedoc.json
ADDED
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import tsconfigPaths from "vite-tsconfig-paths";
|
|
2
|
+
import { defineConfig } from "vitest/config";
|
|
3
|
+
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
plugins: [tsconfigPaths()],
|
|
6
|
+
test: {
|
|
7
|
+
globals: true,
|
|
8
|
+
coverage: {
|
|
9
|
+
provider: "istanbul", // or 'v8'
|
|
10
|
+
reporter: ["text", "json-summary"],
|
|
11
|
+
exclude: ["./generated-doc/**/*", "./dist/**/*", "./src/**/*.test.ts", "./src/**/*.spec.ts"],
|
|
12
|
+
},
|
|
13
|
+
include: ["./src/**/*.test.ts", "./src/**/*.spec.ts"],
|
|
14
|
+
},
|
|
15
|
+
});
|