diff --git a/eslint.config.mjs b/eslint.config.mjs index 985cffd..2901e68 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,12 +1,12 @@ -import eslint from '@eslint/js'; -import tseslint from 'typescript-eslint'; -import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; +import eslint from "@eslint/js"; +import tseslint from "typescript-eslint"; +import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended"; export default tseslint.config( - eslint.configs.recommended, - tseslint.configs.recommended, - { - ignores: ["dist", "node_modules"], - }, - eslintPluginPrettierRecommended, -); \ No newline at end of file + eslint.configs.recommended, + tseslint.configs.recommended, + { + ignores: ["dist", "node_modules"], + }, + eslintPluginPrettierRecommended, +); diff --git a/package-lock.json b/package-lock.json index 9e2b3fe..d86fbe3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "@eslint/js": "^9.19.0", "@types/node": "^22.13.1", "@vitest/coverage-v8": "^3.0.6", - "dotenv": "^16.4.7", + "dotenv": "^16.5.0", "eslint": "^9.19.0", "eslint-config-prettier": "^10.0.1", "eslint-plugin-prettier": "^5.2.3", @@ -2516,11 +2516,10 @@ } }, "node_modules/dotenv": { - "version": "16.4.7", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", - "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", + "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==", "dev": true, - "license": "BSD-2-Clause", "engines": { "node": ">=12" }, diff --git a/package.json b/package.json index f7ed545..50d4059 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "@eslint/js": "^9.19.0", "@types/node": "^22.13.1", "@vitest/coverage-v8": "^3.0.6", - "dotenv": "^16.4.7", + "dotenv": "^16.5.0", "eslint": "^9.19.0", "eslint-config-prettier": "^10.0.1", "eslint-plugin-prettier": "^5.2.3", diff --git a/src/enums.ts b/src/enums.ts index 65522b7..937d259 100644 --- a/src/enums.ts +++ b/src/enums.ts @@ -16,7 +16,8 @@ export enum PaymentMethod { } export enum SimpleStatus { - ACCEPTED = "ACCEPTED", + AUTHORIZED = "AUTHORIZED", PENDING = "PENDING", + CAPTURE = "CAPTURE", REJECTED = "REJECTED", } diff --git a/src/helpers.ts b/src/helpers.ts index 9ad1d3e..5627448 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -61,9 +61,17 @@ export function getSimpleStatus( if ( paymentMethodStatuses[ paymentStatus.selectedPaymentMethod - ]?.accepted.includes(plainStatus) + ]?.authorized.includes(plainStatus) ) { - return SimpleStatus.ACCEPTED; + return SimpleStatus.AUTHORIZED; + } + + if ( + paymentMethodStatuses[ + paymentStatus.selectedPaymentMethod + ]?.capture.includes(plainStatus) + ) { + return SimpleStatus.CAPTURE; } if ( @@ -107,23 +115,28 @@ export function getSavedCardData( export const paymentMethodStatuses: Record = { [PaymentMethod.QR_PAY]: { - accepted: ["ACSC", "ACCC"], + capture: ["ACSC", "ACCC"], rejected: ["CANC", "RJCT"], + authorized: [], }, [PaymentMethod.BANK_TRANSFER]: { - accepted: ["ACSC", "ACCC"], + capture: ["ACSC", "ACCC"], rejected: ["CANC", "RJCT"], + authorized: [], }, [PaymentMethod.PAY_LATER]: { - accepted: ["LOAN_APPLICATION_FINISHED", "LOAN_DISBURSED"], + capture: ["LOAN_APPLICATION_FINISHED", "LOAN_DISBURSED"], rejected: ["CANCEL", "EXPIRED"], + authorized: [], }, [PaymentMethod.CARD_PAY]: { - accepted: ["OK", "CB"], + capture: ["OK", "CB"], rejected: ["FAIL"], + authorized: ["PA"], }, [PaymentMethod.DIRECT_API]: { - accepted: ["OK", "CB"], + capture: ["OK", "CB"], rejected: ["FAIL"], + authorized: [], }, }; diff --git a/src/index.ts b/src/index.ts index 01fb205..9fa3c8d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ import { GatewayMode, Scopes } from "./enums"; +import { TBPlusLogger } from "./logger"; import createClient, { ClientOptions, Middleware } from "openapi-fetch"; import { operations, paths } from "./paths"; import { constants, publicEncrypt, randomUUID } from "node:crypto"; @@ -18,7 +19,6 @@ export * from "./enums"; export class TBPlusSDK { private clientId: string; private clientSecret: string; - private clientIp: string; private mode: GatewayMode; private originalRequest: Request | undefined; private scopes: Scopes[]; @@ -29,16 +29,17 @@ export class TBPlusSDK { public apiClient; public accessToken: string | undefined = undefined; UNPROTECTED_ROUTES = ["/auth/oauth/v2/token"]; + private logger: TBPlusLogger | undefined = undefined; constructor( clientId: string, clientSecret: string, - clientIp: string, sdkOptions: { mode?: GatewayMode; scopes?: Scopes[]; createClientParams?: ClientOptions; } = {}, + logger: TBPlusLogger | undefined = undefined, ) { this.mode = sdkOptions.mode ?? GatewayMode.SANDBOX; if (this.mode == GatewayMode.PRODUCTION) { @@ -51,12 +52,12 @@ export class TBPlusSDK { this.clientId = clientId; this.clientSecret = clientSecret; - this.clientIp = clientIp; this.scopes = sdkOptions.scopes ?? [Scopes.TATRAPAYPLUS]; this.apiClient = createClient({ baseUrl: this.baseUrl, ...sdkOptions.createClientParams, }); + this.logger = logger; this.apiClient.use(this.getAuthMiddleware()); this.apiClient.use(this.getRetryMiddleware()); } @@ -88,6 +89,9 @@ export class TBPlusSDK { return request.clone(); }, onResponse: async ({ response }) => { + if (this.originalRequest && this.logger && typeof this.logger.log === 'function') { + await this.logger.log(this.originalRequest, response); + } if ( !this.retryStatues.includes(response.status) || !this.originalRequest @@ -116,12 +120,8 @@ export class TBPlusSDK { } private getDefaultHeaders() { - const defaultHeaders: Record< - "X-Request-ID" | "IP-Address" | "User-Agent", - string - > = { + const defaultHeaders: Record<"X-Request-ID" | "User-Agent", string> = { "X-Request-ID": randomUUID(), - "IP-Address": this.clientIp, "User-Agent": `Tatrapayplus-plugin/${this.clientVersion}/Node.js`, }; return defaultHeaders; @@ -167,6 +167,7 @@ export class TBPlusSDK { public async createPayment( body: paths["/v1/payments"]["post"]["requestBody"]["content"]["application/json"], redirectUri: string, + clientIp: string, language: operations["initiatePayment"]["parameters"]["header"]["Accept-Language"] = undefined, preferredMethod: operations["initiatePayment"]["parameters"]["header"]["Preferred-Method"] = undefined, fetchOptions = {}, @@ -175,6 +176,7 @@ export class TBPlusSDK { const headers: operations["initiatePayment"]["parameters"]["header"] = { ...this.getDefaultHeaders(), "Redirect-URI": redirectUri, + "IP-Address": clientIp, }; if (language) { headers["Accept-Language"] = language; @@ -304,10 +306,15 @@ export class TBPlusSDK { public async createPaymentDirect( body: paths["/v1/payments-direct"]["post"]["requestBody"]["content"]["application/json"], redirectUri: string, + clientIp: string, ) { return this.apiClient.POST("/v1/payments-direct", { params: { - header: { ...this.getDefaultHeaders(), "Redirect-URI": redirectUri }, + header: { + ...this.getDefaultHeaders(), + "Redirect-URI": redirectUri, + "IP-Address": clientIp, + }, }, body: body, }); diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..ba765b6 --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,97 @@ +type AnyObject = Record; + +export class TBPlusLogger { + private maskSensitiveData: boolean; + private maskBodyFields: string[] = [ + "client_id", + "client_secret", + "access_token", + ]; + private maskHeaderFields: string[] = ["authorization"]; + + constructor(maskSensitiveData = true) { + this.maskSensitiveData = maskSensitiveData; + } + + private maskValue(value: string, keep = 5): string { + if (value.length <= keep * 2) { + return "*".repeat(value.length); + } + return ( + value.slice(0, keep) + + "*".repeat(value.length - 2 * keep) + + value.slice(-keep) + ); + } + private isRecord(obj: unknown): obj is Record { + return typeof obj === "object" && obj !== null && !Array.isArray(obj); + } + + private maskBody(body: unknown): unknown { + if (!this.maskSensitiveData || !this.isRecord(body)) return body; + + const masked: Record = { ...body }; + + for (const key of this.maskBodyFields) { + if (key in masked && typeof masked[key] === "string") { + masked[key] = this.maskValue(masked[key]); + } + } + + return masked; + } + + private maskHeaders(headers: Headers | AnyObject): AnyObject { + const rawHeaders: AnyObject = + headers instanceof Headers + ? Object.fromEntries(headers.entries()) + : headers; + const masked: AnyObject = {}; + + for (const key in rawHeaders) { + masked[key] = this.maskHeaderFields.includes(key) + ? this.maskValue(String(rawHeaders[key])) + : rawHeaders[key]; + } + + return masked; + } + + public async log(request: Request, response: Response): Promise { + const requestClone = request.clone(); + const bodyText = await requestClone.text(); + const parsedBody = (() => { + try { + return JSON.parse(bodyText); + } catch { + return bodyText; + } + })(); + + const maskedBody = this.maskBody(parsedBody); + const maskedHeaders = this.maskHeaders(request.headers); + + this.writeLine(`Request: ${request.method} ${request.url}`); + this.writeLine(`Headers: ${JSON.stringify(maskedHeaders, null, 2)}`); + if (bodyText) { + this.writeLine(`Body: ${JSON.stringify(maskedBody, null, 2)}`); + } + + const resText = await response.clone().text(); + let resParsed; + try { + resParsed = JSON.parse(resText); + } catch { + resParsed = resText; + } + + this.writeLine(`Response Status: ${response.status}`); + this.writeLine( + `Response: ${JSON.stringify(this.maskBody(resParsed), null, 2)}\n`, + ); + } + + protected writeLine(line: string): void { + console.log(line); + } +} diff --git a/src/types.ts b/src/types.ts index f5da0e6..7b28bb8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -12,6 +12,7 @@ export type PaymentMethodsParams = { }; export type PaymentStatuses = { - accepted: string[]; + authorized: string[]; rejected: string[]; + capture: string[]; }; diff --git a/tests/live.test.ts b/tests/live.test.ts index 7129da1..da25bc1 100644 --- a/tests/live.test.ts +++ b/tests/live.test.ts @@ -3,11 +3,16 @@ import { TBPlusSDK } from "../src"; import dotenv from "dotenv"; import { PaymentMethod, SimpleStatus } from "../src/enums"; import { getAvailable } from "../src/helpers"; +import { TBPlusLogger } from "../src/logger"; dotenv.config(); const REDIRECT_URI = "https://tatrabanka.sk/"; - +export class TestLogger extends TBPlusLogger { + protected writeLine(line: string): void { + console.log("[TestLogger]", line); + } +} describe("TBPlusSDK tests on live", () => { it("retrieve all payment methods", async () => { const sdk = new TBPlusSDK( @@ -29,6 +34,8 @@ describe("TBPlusSDK tests on live", () => { process.env.API_KEY as string, process.env.API_SECRET as string, "192.0.2.123", + {}, + new TestLogger(), ); const { data, error } = await sdk.createPayment( { @@ -42,6 +49,7 @@ describe("TBPlusSDK tests on live", () => { }, }, REDIRECT_URI, + "127.0.0.1", ); expect(error).toBeUndefined(); if (!data) { @@ -106,6 +114,7 @@ describe("TBPlusSDK tests on live", () => { }, }, REDIRECT_URI, + "127.0.0.1", ); expect(error).toBeUndefined(); if (data) { @@ -133,7 +142,6 @@ describe("TBPlusSDK tests on live", () => { const sdk = new TBPlusSDK( process.env.API_KEY as string, process.env.API_SECRET as string, - "192.0.2.123", { createClientParams: { fetch: mockFetch, @@ -174,7 +182,11 @@ describe("TBPlusSDK tests on live", () => { }, }, }; - const { data, error } = await sdk.createPayment(body, REDIRECT_URI); + const { data, error } = await sdk.createPayment( + body, + REDIRECT_URI, + "127.0.0.1", + ); const requestBody = await requestHistory[1]?.json(); expect(error).toBeUndefined(); diff --git a/tests/mocked.test.ts b/tests/mocked.test.ts index 7c22f51..5b84668 100644 --- a/tests/mocked.test.ts +++ b/tests/mocked.test.ts @@ -101,7 +101,6 @@ describe("TBPlusSDK Mocked suit", () => { const sdk = new TBPlusSDK( API_KEY as string, API_SECRET as string, - "192.0.2.123", ); server.use( http.get(`${sdk.baseUrl}/v1/payments/methods`, () => @@ -117,7 +116,6 @@ describe("TBPlusSDK Mocked suit", () => { const sdk = new TBPlusSDK( API_KEY as string, API_SECRET as string, - "192.0.2.123", ); const statusList: { status: number; key: string | null }[] = []; @@ -157,7 +155,6 @@ describe("TBPlusSDK Mocked suit", () => { const sdk = new TBPlusSDK( API_KEY as string, API_SECRET as string, - "192.0.2.123", ); server.use( http.get(`${sdk.baseUrl}/v1/payments/methods`, () => { @@ -247,7 +244,6 @@ describe("TBPlusSDK Mocked suit", () => { const sdk = new TBPlusSDK( API_KEY as string, API_SECRET as string, - "192.0.2.123", ); server.use( http.get( @@ -345,7 +341,6 @@ describe("TBPlusSDK Mocked suit", () => { const sdk = new TBPlusSDK( API_KEY as string, API_SECRET as string, - "192.0.2.123", ); const result = sdk.generateSignedCardIdFromCid("12345"); expect(result).toBeTruthy(); @@ -355,7 +350,6 @@ describe("TBPlusSDK Mocked suit", () => { const sdkSandbox = new TBPlusSDK( API_KEY as string, API_SECRET as string, - "192.0.2.123", ); const accessTokenSandbox = await sdkSandbox.fetchAccessToken(); expect(accessTokenSandbox).toBe("e8fc6511-4e80-4972-9f91-604fcd06a6d7"); @@ -363,14 +357,13 @@ describe("TBPlusSDK Mocked suit", () => { const sdkProduction = new TBPlusSDK( API_KEY as string, API_SECRET as string, - "192.0.2.123", { mode: GatewayMode.PRODUCTION }, ); const accessTokenProduction = await sdkProduction.fetchAccessToken(); expect(accessTokenProduction).toBe("d8fc6511-4e80-4972-9f91-604fcd06a6d7"); expect(() => { - new TBPlusSDK(API_KEY as string, API_SECRET as string, "192.0.2.123", { + new TBPlusSDK(API_KEY as string, API_SECRET as string,{ mode: "live" as never, }); }).toThrowError("Unknown gateway mode"); @@ -380,7 +373,6 @@ describe("TBPlusSDK Mocked suit", () => { const sdk = new TBPlusSDK( API_KEY as string, API_SECRET as string, - "192.0.2.123", ); server.use( http.post(`${sdk.baseUrl}/v1/payments`, ({ request }) => { @@ -437,6 +429,7 @@ describe("TBPlusSDK Mocked suit", () => { }, }, REDIRECT_URI, + "127.0.0.1", "en", "BANK_TRANSFER", ); @@ -450,7 +443,6 @@ describe("TBPlusSDK Mocked suit", () => { const sdk = new TBPlusSDK( API_KEY as string, API_SECRET as string, - "192.0.2.123", ); server.use( http.post(`${sdk.baseUrl}/v1/appearances`, () => { @@ -485,7 +477,6 @@ describe("TBPlusSDK Mocked suit", () => { const sdk = new TBPlusSDK( API_KEY as string, API_SECRET as string, - "192.0.2.123", ); server.use( http.post(`${sdk.baseUrl}/v1/appearances/logo`, () => { @@ -509,7 +500,6 @@ describe("TBPlusSDK Mocked suit", () => { const sdk = new TBPlusSDK( API_KEY as string, API_SECRET as string, - "192.0.2.123", ); server.use( http.post(`${sdk.baseUrl}/auth/oauth/v2/token`, () => { @@ -528,7 +518,6 @@ describe("TBPlusSDK Mocked suit", () => { const sdk = new TBPlusSDK( API_KEY as string, API_SECRET as string, - "192.0.2.123", ); const REDIRECT_URI = "http://google.com"; server.use( @@ -594,6 +583,7 @@ describe("TBPlusSDK Mocked suit", () => { }, }, REDIRECT_URI, + "127.0.0.1", ); expect(error).toBeFalsy(); expect(response.status).toBe(200);