wdc
This commit is contained in:
20
src/__tests__/apiTestsUtils.ts
Normal file
20
src/__tests__/apiTestsUtils.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { type HttpMethod } from "../lib/api-response";
|
||||
import { type JSONValue } from "../types";
|
||||
|
||||
export const host = "https://localhost:4213";
|
||||
|
||||
export const createRequestMock = (
|
||||
method: HttpMethod,
|
||||
body?: JSONValue,
|
||||
headers = new Headers(),
|
||||
) => {
|
||||
if (body) {
|
||||
headers.append("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
return new Request(`${host}/api/route`, {
|
||||
method,
|
||||
headers,
|
||||
...(body && { body: JSON.stringify(body) }),
|
||||
});
|
||||
};
|
||||
26
src/__tests__/pages/index.test.tsx
Normal file
26
src/__tests__/pages/index.test.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { expect, vi, describe, it } from "vitest";
|
||||
import IndexPage from "../../pages";
|
||||
|
||||
vi.mock("@saleor/app-sdk/app-bridge", () => {
|
||||
return {
|
||||
useAppBridge: () => ({
|
||||
appBridgeState: {},
|
||||
appBridge: {},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/router", () => ({
|
||||
useRouter: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("App", () => {
|
||||
it("renders text", () => {
|
||||
render(<IndexPage />);
|
||||
|
||||
expect(
|
||||
screen.getByText("Install this app in your Saleor Dashboard", { exact: false }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
269
src/__tests__/polly.ts
Normal file
269
src/__tests__/polly.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
import path from "path";
|
||||
import NodeHttpAdapter from "@pollyjs/adapter-node-http";
|
||||
import FetchAdapter from "@pollyjs/adapter-fetch";
|
||||
import { Polly, type Headers, type PollyConfig } from "@pollyjs/core";
|
||||
import FSPersister from "@pollyjs/persister-fs";
|
||||
import { afterEach, beforeEach, expect } from "vitest";
|
||||
import merge from "lodash-es/merge";
|
||||
import omit from "lodash-es/omit";
|
||||
import omitDeep from "omit-deep-lodash";
|
||||
import { tryJsonParse, tryIgnore } from "../lib/utils";
|
||||
import { testEnv } from "@/__tests__/test-env.mjs";
|
||||
import { env } from "@/lib/env.mjs";
|
||||
|
||||
declare module "vitest" {
|
||||
export interface TestContext {
|
||||
polly?: Polly;
|
||||
}
|
||||
}
|
||||
|
||||
export const omitPathsFromJson = (paths: string[]) => (input: string) => {
|
||||
return JSON.stringify(omit(JSON.parse(input) as object, paths));
|
||||
};
|
||||
export const omitPathsFromHeaders = (paths: string[]) => (headers: Headers) => {
|
||||
return omit(headers, paths);
|
||||
};
|
||||
|
||||
const HEADERS_BLACKLIST = new Set([
|
||||
"authorization-bearer",
|
||||
"authorization",
|
||||
"saleor-signature",
|
||||
"set-cookie",
|
||||
"x-api-key",
|
||||
"x-stripe-client-user-agent",
|
||||
]);
|
||||
|
||||
const VARIABLES_BLACKLIST = new Set([
|
||||
"csrfToken",
|
||||
"email",
|
||||
"newEmail",
|
||||
"newPassword",
|
||||
"oldPassword",
|
||||
"password",
|
||||
"redirectUrl",
|
||||
"refreshToken",
|
||||
"token",
|
||||
"authorisationToken",
|
||||
]);
|
||||
|
||||
const removeBlacklistedVariables = (
|
||||
obj: {} | undefined | string | null,
|
||||
): {} | undefined | string | null => {
|
||||
if (!obj || typeof obj === "string") {
|
||||
return obj;
|
||||
}
|
||||
|
||||
if ("client_secret" in obj) {
|
||||
obj.client_secret = "pi_FAKE_CLIENT_SECRET";
|
||||
}
|
||||
|
||||
return omitDeep(obj, ...VARIABLES_BLACKLIST);
|
||||
};
|
||||
|
||||
/**
|
||||
* This interface is incomplete
|
||||
*/
|
||||
interface PollyRecording {
|
||||
response: {
|
||||
content?: {
|
||||
mimeType: string;
|
||||
text: string;
|
||||
};
|
||||
cookies: string[];
|
||||
headers: Array<{ value: string; name: string }>;
|
||||
};
|
||||
request: {
|
||||
postData?: {
|
||||
text: string;
|
||||
};
|
||||
cookies: string[];
|
||||
headers: Array<{ value: string; name: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
const responseIsJson = (recording: PollyRecording) => {
|
||||
return recording.response.content?.mimeType.includes("application/json");
|
||||
};
|
||||
const requestIsJson = (recording: PollyRecording) => {
|
||||
return recording.request.headers.some(
|
||||
({ name, value }) =>
|
||||
name.toLowerCase() === "content-type" && value.includes("application/json"),
|
||||
);
|
||||
};
|
||||
|
||||
export const setupRecording = (config?: PollyConfig) => {
|
||||
Polly.on("create", (polly) => {
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-argument */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
polly.server
|
||||
.any()
|
||||
// Hide sensitive data in headers or in body
|
||||
.on("beforePersist", (_req, recording: PollyRecording) => {
|
||||
recording.response.cookies = [];
|
||||
|
||||
recording.response.headers = recording.response.headers.filter(
|
||||
(el: Record<string, string>) => !HEADERS_BLACKLIST.has(el.name),
|
||||
);
|
||||
recording.request.headers = recording.request.headers.filter(
|
||||
(el: Record<string, string>) => !HEADERS_BLACKLIST.has(el.name),
|
||||
);
|
||||
|
||||
if (recording.request.postData?.text) {
|
||||
const requestJson = tryJsonParse(recording.request.postData.text);
|
||||
const filteredRequestJson = removeBlacklistedVariables(requestJson);
|
||||
recording.request.postData.text =
|
||||
typeof filteredRequestJson === "string"
|
||||
? filteredRequestJson
|
||||
: JSON.stringify(filteredRequestJson);
|
||||
}
|
||||
if (recording.response.content?.text) {
|
||||
const responseJson = tryJsonParse(recording.response.content.text);
|
||||
const filteredResponseJson = removeBlacklistedVariables(responseJson);
|
||||
recording.response.content.text =
|
||||
typeof filteredResponseJson === "string"
|
||||
? filteredResponseJson
|
||||
: JSON.stringify(filteredResponseJson);
|
||||
}
|
||||
})
|
||||
// make JSON response and requests more readable
|
||||
// https://github.com/Netflix/pollyjs/issues/322
|
||||
.on("beforePersist", (_req, recording: PollyRecording) => {
|
||||
if (responseIsJson(recording)) {
|
||||
tryIgnore(
|
||||
() =>
|
||||
(recording.response.content!.text = JSON.parse(
|
||||
recording.response.content!.text,
|
||||
) as string),
|
||||
);
|
||||
}
|
||||
|
||||
if (requestIsJson(recording)) {
|
||||
tryIgnore(
|
||||
() =>
|
||||
(recording.request.postData!.text = JSON.parse(
|
||||
recording.request.postData!.text,
|
||||
) as string),
|
||||
);
|
||||
}
|
||||
})
|
||||
.on("beforeReplay", (_req, recording: PollyRecording) => {
|
||||
if (responseIsJson(recording)) {
|
||||
tryIgnore(
|
||||
() =>
|
||||
(recording.response.content!.text = JSON.stringify(recording.response.content!.text)),
|
||||
);
|
||||
}
|
||||
|
||||
if (requestIsJson(recording)) {
|
||||
tryIgnore(
|
||||
() =>
|
||||
(recording.request.postData!.text = JSON.stringify(recording.request.postData!.text)),
|
||||
);
|
||||
}
|
||||
});
|
||||
/* eslint-enable */
|
||||
});
|
||||
|
||||
const defaultConfig = {
|
||||
...getRecordingSettings(),
|
||||
adapters: [FetchAdapter, NodeHttpAdapter],
|
||||
persister: FSPersister,
|
||||
adapterOptions: {
|
||||
fetch: {
|
||||
context: globalThis,
|
||||
},
|
||||
},
|
||||
persisterOptions: {
|
||||
fs: {},
|
||||
keepUnusedRequests: false,
|
||||
},
|
||||
flushRequestsOnStop: true,
|
||||
matchRequestsBy: {
|
||||
url: {
|
||||
protocol: true,
|
||||
username: true,
|
||||
password: true,
|
||||
hostname: true,
|
||||
port: true,
|
||||
pathname: true,
|
||||
query: true,
|
||||
hash: false,
|
||||
},
|
||||
body: true,
|
||||
order: false,
|
||||
method: true,
|
||||
headers: {
|
||||
exclude: [
|
||||
"date",
|
||||
"idempotency-key",
|
||||
"original-request",
|
||||
"request-id",
|
||||
"content-length",
|
||||
"x-stripe-client-user-agent",
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async (ctx) => {
|
||||
const { currentTestName } = expect.getState();
|
||||
if (!currentTestName) {
|
||||
throw new Error("This function must be run inside a test case!");
|
||||
}
|
||||
|
||||
const recordingsRoot = path.dirname(expect.getState().testPath || "");
|
||||
const recordingsDirectory = path.join(recordingsRoot, "__recordings__");
|
||||
|
||||
const [, ...names] = currentTestName.split(" > ");
|
||||
const polly = new Polly(
|
||||
names.join("/"),
|
||||
merge(
|
||||
defaultConfig,
|
||||
{
|
||||
persisterOptions: {
|
||||
fs: {
|
||||
recordingsDir: recordingsDirectory,
|
||||
},
|
||||
},
|
||||
},
|
||||
config,
|
||||
),
|
||||
);
|
||||
ctx.polly = polly;
|
||||
});
|
||||
|
||||
afterEach((ctx) => ctx.polly?.flush());
|
||||
afterEach((ctx) => ctx.polly?.stop());
|
||||
};
|
||||
|
||||
const getRecordingSettings = (): Pick<
|
||||
PollyConfig,
|
||||
"mode" | "recordIfMissing" | "recordFailedRequests"
|
||||
> => {
|
||||
// use replay mode by default, override if POLLY_MODE env variable is passed
|
||||
const mode = env.CI ? "replay" : testEnv.POLLY_MODE;
|
||||
|
||||
if (mode === "record") {
|
||||
return {
|
||||
mode: "record",
|
||||
recordIfMissing: true,
|
||||
recordFailedRequests: true,
|
||||
};
|
||||
}
|
||||
if (mode === "record_missing") {
|
||||
return {
|
||||
mode: "replay",
|
||||
recordIfMissing: true,
|
||||
recordFailedRequests: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
mode: "replay",
|
||||
recordIfMissing: false,
|
||||
recordFailedRequests: false,
|
||||
};
|
||||
};
|
||||
51
src/__tests__/test-env.mjs
Normal file
51
src/__tests__/test-env.mjs
Normal file
@@ -0,0 +1,51 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const testEnvSchema = z.object({
|
||||
// Saleor
|
||||
TEST_SALEOR_API_URL: z.string().url(),
|
||||
TEST_SALEOR_APP_TOKEN: z.string(),
|
||||
TEST_SALEOR_APP_ID: z.string(),
|
||||
TEST_SALEOR_JWKS: z.string(),
|
||||
// Payment App
|
||||
TEST_PAYMENT_APP_SECRET_KEY: z.string(),
|
||||
TEST_PAYMENT_APP_PUBLISHABLE_KEY: z.string(),
|
||||
TEST_PAYMENT_APP_WEBHOOK_SECRET: z.string(),
|
||||
TEST_PAYMENT_APP_WEBHOOK_ID: z.string(),
|
||||
// Polly.js
|
||||
POLLY_MODE: z.enum(["record", "record_missing", "replay"]).optional().default("replay"),
|
||||
});
|
||||
|
||||
const processEnv = {
|
||||
// Saleor
|
||||
TEST_SALEOR_API_URL: process.env.TEST_SALEOR_API_URL,
|
||||
TEST_SALEOR_APP_TOKEN: process.env.TEST_SALEOR_APP_TOKEN,
|
||||
TEST_SALEOR_APP_ID: process.env.TEST_SALEOR_APP_ID,
|
||||
TEST_SALEOR_JWKS: process.env.TEST_SALEOR_JWKS,
|
||||
// Payment App
|
||||
TEST_PAYMENT_APP_SECRET_KEY: process.env.TEST_PAYMENT_APP_SECRET_KEY,
|
||||
TEST_PAYMENT_APP_PUBLISHABLE_KEY: process.env.TEST_PAYMENT_APP_PUBLISHABLE_KEY,
|
||||
TEST_PAYMENT_APP_WEBHOOK_SECRET: process.env.TEST_PAYMENT_APP_WEBHOOK_SECRET,
|
||||
TEST_PAYMENT_APP_WEBHOOK_ID: process.env.TEST_PAYMENT_APP_WEBHOOK_ID,
|
||||
// Polly.js
|
||||
POLLY_MODE: process.env.POLLY_MODE,
|
||||
};
|
||||
|
||||
/* c8 ignore start */
|
||||
/** @type z.infer<testEnvSchema>
|
||||
* @ts-ignore - can't type this properly in jsdoc */
|
||||
// eslint-disable-next-line import/no-mutable-exports
|
||||
let testEnv = process.env;
|
||||
|
||||
if (!!process.env.SKIP_ENV_VALIDATION == false) {
|
||||
const parsed = testEnvSchema.safeParse(processEnv);
|
||||
|
||||
if (parsed.success === false) {
|
||||
console.error("❌ Invalid environment variables:", parsed.error.flatten().fieldErrors);
|
||||
throw new Error("Invalid environment variables");
|
||||
}
|
||||
|
||||
testEnv = parsed.data;
|
||||
}
|
||||
/* c8 ignore stop */
|
||||
|
||||
export { testEnv };
|
||||
59
src/__tests__/testAPL.ts
Normal file
59
src/__tests__/testAPL.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
type AplConfiguredResult,
|
||||
type AplReadyResult,
|
||||
type APL,
|
||||
type AuthData,
|
||||
} from "@saleor/app-sdk/APL";
|
||||
import { testEnv } from "@/__tests__/test-env.mjs";
|
||||
|
||||
export class TestAPL implements APL {
|
||||
async get(saleorApiUrl: string): Promise<AuthData | undefined> {
|
||||
if (testEnv.TEST_SALEOR_API_URL !== saleorApiUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
saleorApiUrl: testEnv.TEST_SALEOR_API_URL,
|
||||
domain: new URL(testEnv.TEST_SALEOR_API_URL).hostname,
|
||||
jwks: "",
|
||||
appId: testEnv.TEST_SALEOR_APP_ID,
|
||||
token: testEnv.TEST_SALEOR_APP_TOKEN,
|
||||
};
|
||||
}
|
||||
|
||||
async set(authData: AuthData) {
|
||||
console.warn("Attempted to save APL authData in test", authData);
|
||||
}
|
||||
|
||||
async delete() {
|
||||
console.warn("Attempted to delete APL authData in test");
|
||||
}
|
||||
|
||||
async getDomain(): Promise<string | undefined> {
|
||||
return new URL(testEnv.TEST_SALEOR_API_URL).hostname;
|
||||
}
|
||||
|
||||
async isReady(): Promise<AplReadyResult> {
|
||||
return {
|
||||
ready: true,
|
||||
};
|
||||
}
|
||||
|
||||
async getAll() {
|
||||
return [
|
||||
{
|
||||
saleorApiUrl: testEnv.TEST_SALEOR_API_URL,
|
||||
domain: new URL(testEnv.TEST_SALEOR_API_URL).hostname,
|
||||
jwks: "",
|
||||
appId: testEnv.TEST_SALEOR_APP_ID,
|
||||
token: testEnv.TEST_SALEOR_APP_TOKEN,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async isConfigured(): Promise<AplConfiguredResult> {
|
||||
return {
|
||||
configured: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
7
src/app-bridge-instance.ts
Normal file
7
src/app-bridge-instance.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { AppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
|
||||
/**
|
||||
* Ensure instance is a singleton.
|
||||
* TODO: This is React 18 issue, consider hiding this workaround inside app-sdk
|
||||
*/
|
||||
export const appBridgeInstance = typeof window !== "undefined" ? new AppBridge() : undefined;
|
||||
294
src/backend-lib/api-route-utils.test.ts
Normal file
294
src/backend-lib/api-route-utils.test.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { type ValidateFunction } from "ajv";
|
||||
import { type NextApiResponse, type NextApiRequest } from "next/types";
|
||||
import { type NextWebhookApiHandler } from "@saleor/app-sdk/handlers/next";
|
||||
import { getAuthDataForRequest, getSyncWebhookHandler, validateData } from "./api-route-utils";
|
||||
import { testEnv } from "@/__tests__/test-env.mjs";
|
||||
import { BaseError, MissingSaleorApiUrlError } from "@/errors";
|
||||
|
||||
describe("api-route-utils", () => {
|
||||
describe("validateData", () => {
|
||||
it("should return data if it validates", async () => {
|
||||
const data = { a: 1, b: "c" };
|
||||
await expect(
|
||||
validateData(data, (() => true) as unknown as ValidateFunction),
|
||||
).resolves.toEqual(data);
|
||||
});
|
||||
|
||||
it("should throw error if it doesn't validate", async () => {
|
||||
const data = { a: 1, b: "c" };
|
||||
await expect(
|
||||
validateData(data, (() => false) as unknown as ValidateFunction),
|
||||
).rejects.toMatchInlineSnapshot("[UnknownError: JsonSchemaError]");
|
||||
});
|
||||
|
||||
it("should throw error if it throws", async () => {
|
||||
const data = { a: 1, b: "c" };
|
||||
await expect(
|
||||
validateData(data, (() => {
|
||||
throw new Error("some error");
|
||||
}) as unknown as ValidateFunction),
|
||||
).rejects.toMatchInlineSnapshot("[UnknownError: some error]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSyncWebhookHandler", () => {
|
||||
type WebhookContext = Parameters<NextWebhookApiHandler>[2];
|
||||
|
||||
it("should return a function", () => {
|
||||
expect(
|
||||
getSyncWebhookHandler(
|
||||
"TestWebhook",
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
(() => {}) as any,
|
||||
(() => true) as unknown as ValidateFunction,
|
||||
() => ({}),
|
||||
),
|
||||
).toEqual(expect.any(Function));
|
||||
});
|
||||
|
||||
it("calls handler with payload and saleorApiUrl from context", async () => {
|
||||
const handler = vi.fn();
|
||||
const webhookHandler = getSyncWebhookHandler(
|
||||
"TestWebhook",
|
||||
handler,
|
||||
(() => true) as unknown as ValidateFunction,
|
||||
() => ({}),
|
||||
);
|
||||
|
||||
type WebhookContext = Parameters<NextWebhookApiHandler>[2];
|
||||
|
||||
const payload = {};
|
||||
const authData = {
|
||||
saleorApiUrl: testEnv.TEST_SALEOR_API_URL,
|
||||
};
|
||||
|
||||
await webhookHandler(
|
||||
{} as NextApiRequest,
|
||||
{ json() {} } as unknown as NextApiResponse,
|
||||
{
|
||||
authData,
|
||||
payload,
|
||||
} as unknown as WebhookContext,
|
||||
);
|
||||
|
||||
expect(handler).toHaveBeenCalledWith(payload, authData.saleorApiUrl);
|
||||
});
|
||||
|
||||
it("returns json with result", async () => {
|
||||
const handler = vi.fn().mockReturnValue({
|
||||
some: "json",
|
||||
});
|
||||
const json = vi.fn();
|
||||
const webhookHandler = getSyncWebhookHandler(
|
||||
"TestWebhook",
|
||||
handler,
|
||||
(() => true) as unknown as ValidateFunction,
|
||||
() => ({}),
|
||||
);
|
||||
|
||||
const payload = {};
|
||||
const authData = {
|
||||
saleorApiUrl: testEnv.TEST_SALEOR_API_URL,
|
||||
};
|
||||
|
||||
await webhookHandler(
|
||||
{} as NextApiRequest,
|
||||
{ json } as unknown as NextApiResponse,
|
||||
{
|
||||
authData,
|
||||
payload,
|
||||
} as unknown as WebhookContext,
|
||||
);
|
||||
|
||||
expect(json).toHaveBeenCalledWith({
|
||||
some: "json",
|
||||
});
|
||||
});
|
||||
|
||||
it("catches known errors and returns 200 with details", async () => {
|
||||
const handler = vi.fn().mockImplementation(() => {
|
||||
throw new BaseError("This is a known error", {
|
||||
props: {
|
||||
errorCode: 123,
|
||||
statusCode: 422,
|
||||
},
|
||||
errors: [new Error("Initial problem")],
|
||||
});
|
||||
});
|
||||
const errorMapper = vi.fn();
|
||||
const json = vi.fn();
|
||||
const status = vi.fn().mockReturnValue({ json });
|
||||
const webhookHandler = getSyncWebhookHandler(
|
||||
"TestWebhook",
|
||||
handler,
|
||||
(() => true) as unknown as ValidateFunction,
|
||||
errorMapper,
|
||||
);
|
||||
|
||||
type WebhookContext = Parameters<NextWebhookApiHandler>[2];
|
||||
|
||||
const payload = {};
|
||||
const authData = {
|
||||
saleorApiUrl: testEnv.TEST_SALEOR_API_URL,
|
||||
};
|
||||
|
||||
await webhookHandler(
|
||||
{} as NextApiRequest,
|
||||
{ json, status } as unknown as NextApiResponse,
|
||||
{
|
||||
authData,
|
||||
payload,
|
||||
} as unknown as WebhookContext,
|
||||
);
|
||||
|
||||
expect(status).toHaveBeenCalledWith(200);
|
||||
expect(json).toHaveBeenCalled();
|
||||
expect(errorMapper.mock.lastCall[1]).toMatchInlineSnapshot(`
|
||||
{
|
||||
"errors": [
|
||||
{
|
||||
"code": "BaseError",
|
||||
"details": {},
|
||||
"message": "This is a known error",
|
||||
},
|
||||
{
|
||||
"code": "Error",
|
||||
"message": "Initial problem",
|
||||
},
|
||||
],
|
||||
"message": "This is a known error",
|
||||
"sentry": [
|
||||
[BaseError: This is a known error],
|
||||
{
|
||||
"extra": {
|
||||
"errors": [
|
||||
[Error: Initial problem],
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("catches known errors and responds with whatever the errorMapper returns", async () => {
|
||||
const handler = vi.fn().mockImplementation(() => {
|
||||
throw new MissingSaleorApiUrlError("Missing");
|
||||
});
|
||||
const errorMapper = vi.fn().mockImplementation((payload, error) => {
|
||||
return {
|
||||
errors: error.errors,
|
||||
message: error.message,
|
||||
};
|
||||
});
|
||||
const json = vi.fn();
|
||||
const status = vi.fn().mockReturnValue({ json });
|
||||
const webhookHandler = getSyncWebhookHandler(
|
||||
"TestWebhook",
|
||||
handler,
|
||||
(() => true) as unknown as ValidateFunction,
|
||||
errorMapper,
|
||||
);
|
||||
|
||||
type WebhookContext = Parameters<NextWebhookApiHandler>[2];
|
||||
|
||||
const payload = {};
|
||||
const authData = {
|
||||
saleorApiUrl: testEnv.TEST_SALEOR_API_URL,
|
||||
};
|
||||
|
||||
await webhookHandler(
|
||||
{} as NextApiRequest,
|
||||
{ json, status } as unknown as NextApiResponse,
|
||||
{
|
||||
authData,
|
||||
payload,
|
||||
} as unknown as WebhookContext,
|
||||
);
|
||||
|
||||
expect(status).toHaveBeenCalledWith(200);
|
||||
expect(json).toHaveBeenCalled();
|
||||
expect(json.mock.lastCall[0]).toMatchInlineSnapshot(`
|
||||
{
|
||||
"errors": [
|
||||
{
|
||||
"code": "MissingSaleorApiUrlError",
|
||||
"details": {},
|
||||
"message": "Missing",
|
||||
},
|
||||
],
|
||||
"message": "Missing",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("catches unknown errors and returns 500", async () => {
|
||||
const handler = vi.fn().mockImplementation(() => {
|
||||
throw new Error("Some error");
|
||||
});
|
||||
const json = vi.fn();
|
||||
const status = vi.fn().mockReturnValue({ json });
|
||||
const webhookHandler = getSyncWebhookHandler(
|
||||
"TestWebhook",
|
||||
handler,
|
||||
(() => true) as unknown as ValidateFunction,
|
||||
() => ({}),
|
||||
);
|
||||
|
||||
type WebhookContext = Parameters<NextWebhookApiHandler>[2];
|
||||
|
||||
const payload = {};
|
||||
const authData = {
|
||||
saleorApiUrl: testEnv.TEST_SALEOR_API_URL,
|
||||
};
|
||||
|
||||
await webhookHandler(
|
||||
{} as NextApiRequest,
|
||||
{ json, status } as unknown as NextApiResponse,
|
||||
{
|
||||
authData,
|
||||
payload,
|
||||
} as unknown as WebhookContext,
|
||||
);
|
||||
|
||||
expect(status).toHaveBeenCalledWith(500);
|
||||
expect(json).toHaveBeenCalled();
|
||||
expect(BaseError.normalize(json.mock.lastCall[0])).toMatchInlineSnapshot(
|
||||
"[BaseError: Some error]",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAuthDataForRequest", () => {
|
||||
it("should throw if there's no saleroApiUrl in the query", async () => {
|
||||
await expect(
|
||||
getAuthDataForRequest({ query: {} } as NextApiRequest),
|
||||
).rejects.toMatchInlineSnapshot(
|
||||
"[MissingSaleorApiUrlError: Missing saleorApiUrl query param]",
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw if data doesn't exist in APL", async () => {
|
||||
await expect(
|
||||
getAuthDataForRequest({ query: { saleorApiUrl: "someurl" } } as unknown as NextApiRequest),
|
||||
).rejects.toMatchInlineSnapshot("[MissingAuthDataError: APL for someurl not found]");
|
||||
});
|
||||
|
||||
it("should return data from apl if it exists", async () => {
|
||||
await expect(
|
||||
getAuthDataForRequest({
|
||||
query: { saleorApiUrl: testEnv.TEST_SALEOR_API_URL },
|
||||
} as unknown as NextApiRequest),
|
||||
).resolves.toMatchInlineSnapshot(`
|
||||
{
|
||||
"appId": "123456",
|
||||
"domain": "saleor.localhost",
|
||||
"jwks": "",
|
||||
"saleorApiUrl": "https://saleor.localhost:8080/graphql/",
|
||||
"token": "123456",
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
||||
128
src/backend-lib/api-route-utils.ts
Normal file
128
src/backend-lib/api-route-utils.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
import { type NextApiRequest, type NextApiResponse } from "next";
|
||||
import type { ValidateFunction } from "ajv";
|
||||
import { type NextWebhookApiHandler } from "@saleor/app-sdk/handlers/next";
|
||||
import { createLogger, redactError } from "../lib/logger";
|
||||
import {
|
||||
JsonSchemaError,
|
||||
UnknownError,
|
||||
BaseError,
|
||||
MissingAuthDataError,
|
||||
MissingSaleorApiUrlError,
|
||||
} from "@/errors";
|
||||
import { toStringOrEmpty } from "@/lib/utils";
|
||||
import { saleorApp } from "@/saleor-app";
|
||||
|
||||
export const validateData = async <S extends ValidateFunction>(data: unknown, validate: S) => {
|
||||
type Result = S extends ValidateFunction<infer T> ? T : never;
|
||||
try {
|
||||
const isValid = validate(data);
|
||||
if (!isValid) {
|
||||
throw JsonSchemaError.normalize(validate.errors);
|
||||
}
|
||||
return data as Result;
|
||||
} catch (err) {
|
||||
throw UnknownError.normalize(err);
|
||||
}
|
||||
};
|
||||
|
||||
export function getSyncWebhookHandler<TPayload, TResult, TSchema extends ValidateFunction<TResult>>(
|
||||
name: string,
|
||||
webhookHandler: (payload: TPayload, saleorApiUrl: string) => Promise<TResult>,
|
||||
ResponseSchema: TSchema,
|
||||
errorMapper: (payload: TPayload, errorResponse: ErrorResponse) => TResult & {},
|
||||
): NextWebhookApiHandler<TPayload> {
|
||||
return async (_req, res: NextApiResponse<Error | TResult>, ctx) => {
|
||||
const logger = createLogger(
|
||||
{
|
||||
event: ctx.event,
|
||||
},
|
||||
{ msgPrefix: `[${name}] ` },
|
||||
);
|
||||
const { authData, payload } = ctx;
|
||||
logger.info(`handler called: ${webhookHandler.name}`);
|
||||
logger.debug({ payload }, "ctx payload");
|
||||
|
||||
try {
|
||||
const result = await webhookHandler(payload, authData.saleorApiUrl);
|
||||
logger.info(`${webhookHandler.name} was successful`);
|
||||
logger.debug({ result }, "Sending successful response");
|
||||
return res.json(await validateData(result, ResponseSchema));
|
||||
} catch (err) {
|
||||
logger.error({ err: redactError(err) }, `${webhookHandler.name} error`);
|
||||
|
||||
const response = errorToResponse(err);
|
||||
|
||||
if (!response) {
|
||||
Sentry.captureException(err);
|
||||
const result = BaseError.serialize(err);
|
||||
logger.debug("Sending error response");
|
||||
return res.status(500).json(result);
|
||||
}
|
||||
|
||||
Sentry.captureException(...response.sentry);
|
||||
const finalErrorResponse = errorMapper(payload, response);
|
||||
logger.debug({ finalErrorResponse }, "Sending error response");
|
||||
return res.status(200).json(await validateData(finalErrorResponse, ResponseSchema));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
type ErrorResponse = Exclude<ReturnType<typeof errorToResponse>, null>;
|
||||
const errorToResponse = (err: unknown) => {
|
||||
const normalizedError = err instanceof BaseError ? err : null;
|
||||
|
||||
if (!normalizedError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sentry = [
|
||||
normalizedError,
|
||||
{
|
||||
extra: {
|
||||
errors: normalizedError.errors,
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
|
||||
const message = normalizedError.message;
|
||||
|
||||
const errors = [
|
||||
{
|
||||
code: normalizedError.name,
|
||||
message: normalizedError.message,
|
||||
details: {},
|
||||
},
|
||||
...(normalizedError.errors?.map((inner) => {
|
||||
return {
|
||||
code: inner.name,
|
||||
message: inner.message,
|
||||
};
|
||||
}) ?? []),
|
||||
];
|
||||
|
||||
return {
|
||||
sentry,
|
||||
errors,
|
||||
message,
|
||||
};
|
||||
};
|
||||
|
||||
export const getAuthDataForRequest = async (request: NextApiRequest) => {
|
||||
const logger = createLogger({}, { msgPrefix: "[getAuthDataForRequest] " });
|
||||
|
||||
const saleorApiUrl = toStringOrEmpty(request.query.saleorApiUrl);
|
||||
logger.info(`Got saleorApiUrl=${saleorApiUrl || "<undefined>"}`);
|
||||
if (!saleorApiUrl) {
|
||||
throw new MissingSaleorApiUrlError("Missing saleorApiUrl query param");
|
||||
}
|
||||
|
||||
const authData = await saleorApp.apl.get(saleorApiUrl);
|
||||
logger.debug({ authData });
|
||||
if (!authData) {
|
||||
throw new MissingAuthDataError(`APL for ${saleorApiUrl} not found`);
|
||||
}
|
||||
|
||||
return authData;
|
||||
};
|
||||
12
src/deploy.ts
Normal file
12
src/deploy.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/* eslint-disable node/no-process-env */
|
||||
import { execSync } from "child_process";
|
||||
|
||||
execSync("pnpm run build", { stdio: "inherit" });
|
||||
|
||||
if (
|
||||
process.env.DEPLOYMENT_ENVIRONMENT === "production" ||
|
||||
process.env.DEPLOYMENT_ENVIRONMENT === "staging"
|
||||
) {
|
||||
console.log("Production environment detected, running migrations");
|
||||
execSync("pnpm run migrate", { stdio: "inherit" });
|
||||
}
|
||||
79
src/errors.ts
Normal file
79
src/errors.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { type IncomingHttpHeaders } from "node:http2";
|
||||
import { type TRPC_ERROR_CODE_KEY } from "@trpc/server/rpc";
|
||||
import ModernError from "modern-errors";
|
||||
import ModernErrorsSerialize from "modern-errors-serialize";
|
||||
|
||||
// Http errors
|
||||
type CommonProps = {
|
||||
errorCode?: string;
|
||||
statusCode?: number;
|
||||
name?: number;
|
||||
};
|
||||
|
||||
export const BaseError = ModernError.subclass("BaseError", {
|
||||
plugins: [ModernErrorsSerialize],
|
||||
props: {} as CommonProps,
|
||||
});
|
||||
export const UnknownError = BaseError.subclass("UnknownError");
|
||||
export const JsonSchemaError = BaseError.subclass("JsonSchemaError");
|
||||
export const MissingSaleorApiUrlError = BaseError.subclass("MissingSaleorApiUrlError");
|
||||
export const MissingAuthDataError = BaseError.subclass("MissingAuthDataError");
|
||||
export const HttpRequestError = BaseError.subclass("HttpRequestError", {
|
||||
props: {} as { statusCode: number; body: string; headers: IncomingHttpHeaders },
|
||||
});
|
||||
|
||||
// TRPC Errors
|
||||
export interface TrpcErrorOptions {
|
||||
/** HTTP response code returned by TRPC */
|
||||
trpcCode?: TRPC_ERROR_CODE_KEY;
|
||||
}
|
||||
export const BaseTrpcError = BaseError.subclass("BaseTrpcError", {
|
||||
props: { trpcCode: "INTERNAL_SERVER_ERROR" } as TrpcErrorOptions,
|
||||
});
|
||||
export const JwtTokenExpiredError = BaseTrpcError.subclass("JwtTokenExpiredError", {
|
||||
props: { trpcCode: "UNAUTHORIZED" } as TrpcErrorOptions,
|
||||
});
|
||||
export const JwtInvalidError = BaseTrpcError.subclass("JwtInvalidError", {
|
||||
props: { trpcCode: "UNAUTHORIZED" } as TrpcErrorOptions,
|
||||
});
|
||||
export const ReqMissingSaleorApiUrlError = BaseTrpcError.subclass("ReqMissingSaleorApiUrlError", {
|
||||
props: { trpcCode: "BAD_REQUEST" } as TrpcErrorOptions,
|
||||
});
|
||||
export const ReqMissingAuthDataError = BaseTrpcError.subclass("ReqMissingSaleorApiUrlError", {
|
||||
props: { trpcCode: "UNAUTHORIZED" } as TrpcErrorOptions,
|
||||
});
|
||||
export const ReqMissingTokenError = BaseTrpcError.subclass("ReqMissingTokenError", {
|
||||
props: { trpcCode: "BAD_REQUEST" } as TrpcErrorOptions,
|
||||
});
|
||||
export const ReqMissingAppIdError = BaseTrpcError.subclass("ReqMissingAppIdError", {
|
||||
props: { trpcCode: "BAD_REQUEST" } as TrpcErrorOptions,
|
||||
});
|
||||
|
||||
// TRPC + react-hook-form errors
|
||||
export interface FieldErrorOptions extends TrpcErrorOptions {
|
||||
fieldName: string;
|
||||
}
|
||||
|
||||
export const FieldError = BaseTrpcError.subclass("FieldError", {
|
||||
props: {} as FieldErrorOptions,
|
||||
});
|
||||
export const RestrictedKeyNotSupportedError = FieldError.subclass(
|
||||
"RestrictedKeyNotSupportedError",
|
||||
{
|
||||
props: { fieldName: "secretKey" } as FieldErrorOptions,
|
||||
},
|
||||
);
|
||||
export const InvalidSecretKeyError = FieldError.subclass("InvalidSecretKeyError", {
|
||||
props: { fieldName: "secretKey" } as FieldErrorOptions,
|
||||
});
|
||||
export const UnexpectedSecretKeyError = FieldError.subclass("UnexpectedSecretKeyError", {
|
||||
props: { fieldName: "secretKey" } as FieldErrorOptions,
|
||||
});
|
||||
export const InvalidPublishableKeyError = FieldError.subclass("InvalidPublishableKeyError", {
|
||||
props: { fieldName: "publishableKey" } as FieldErrorOptions,
|
||||
});
|
||||
export const UnexpectedPublishableKeyError = FieldError.subclass("UnexpectedPublishableKeyError", {
|
||||
props: { fieldName: "publishableKey" } as FieldErrorOptions,
|
||||
});
|
||||
|
||||
export const FileReaderError = BaseError.subclass("FileReaderError");
|
||||
71
src/lib/api-response.ts
Normal file
71
src/lib/api-response.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { type JSONValue } from "../types";
|
||||
|
||||
export const getResponse =
|
||||
(status: number) =>
|
||||
(data: JSONValue, headers: Headers = new Headers()) => {
|
||||
if (data) {
|
||||
headers.append("Content-Type", "application/json");
|
||||
}
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers,
|
||||
});
|
||||
};
|
||||
|
||||
export const HttpStatus = {
|
||||
OK: 200,
|
||||
Created: 201,
|
||||
Accepted: 202,
|
||||
NonAuthoritativeInformation: 203,
|
||||
NoContent: 204,
|
||||
ResetContent: 205,
|
||||
PartialContent: 206,
|
||||
BadRequest: 400,
|
||||
Unauthorized: 401,
|
||||
PaymentRequired: 402,
|
||||
Forbidden: 403,
|
||||
NotFound: 404,
|
||||
MethodNotAllowed: 405,
|
||||
NotAcceptable: 406,
|
||||
ProxyAuthenticationRequired: 407,
|
||||
RequestTimeout: 408,
|
||||
Conflict: 409,
|
||||
Gone: 410,
|
||||
LengthRequired: 411,
|
||||
PreconditionFailed: 412,
|
||||
PayloadTooLarge: 413,
|
||||
URITooLong: 414,
|
||||
UnsupportedMediaType: 415,
|
||||
RangeNotSatisfiable: 416,
|
||||
ExpectationFailed: 417,
|
||||
ImaTeapot: 418,
|
||||
MisdirectedRequest: 421,
|
||||
UnprocessableEntity: 422,
|
||||
Locked: 423,
|
||||
FailedDependency: 424,
|
||||
TooEarly: 425,
|
||||
UpgradeRequired: 426,
|
||||
PreconditionRequired: 428,
|
||||
TooManyRequests: 429,
|
||||
RequestHeaderFieldsTooLarge: 431,
|
||||
UnavailableForLegalReasons: 451,
|
||||
InternalServerError: 500,
|
||||
} as const;
|
||||
export type HttpStatus = (typeof HttpStatus)[keyof typeof HttpStatus];
|
||||
|
||||
export type HttpMethod = "GET" | "POST" | "PATCH" | "PUT" | "DELETE" | "HEAD";
|
||||
|
||||
export const ok = getResponse(HttpStatus.OK);
|
||||
export const created = getResponse(HttpStatus.Created);
|
||||
export const noContent = getResponse(HttpStatus.NoContent);
|
||||
|
||||
export const badRequest = getResponse(HttpStatus.BadRequest);
|
||||
export const unauthorized = getResponse(HttpStatus.Unauthorized);
|
||||
export const forbidden = getResponse(HttpStatus.Forbidden);
|
||||
export const notFound = getResponse(HttpStatus.NotFound);
|
||||
export const methodNotAllowed = (methods: string[]) =>
|
||||
getResponse(HttpStatus.MethodNotAllowed)(
|
||||
"Method not allowed",
|
||||
new Headers({ Allow: methods.join(", ") }),
|
||||
);
|
||||
export const conflict = getResponse(HttpStatus.Conflict);
|
||||
52
src/lib/create-graphq-client.ts
Normal file
52
src/lib/create-graphq-client.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { type AuthConfig, authExchange } from "@urql/exchange-auth";
|
||||
import {
|
||||
cacheExchange,
|
||||
createClient as urqlCreateClient,
|
||||
dedupExchange,
|
||||
fetchExchange,
|
||||
} from "urql";
|
||||
|
||||
interface IAuthState {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export const createClient = (url: string, getAuth: AuthConfig<IAuthState>["getAuth"]) =>
|
||||
urqlCreateClient({
|
||||
url,
|
||||
exchanges: [
|
||||
dedupExchange,
|
||||
cacheExchange,
|
||||
authExchange<IAuthState>({
|
||||
addAuthToOperation: ({ authState, operation }) => {
|
||||
if (!authState || !authState?.token) {
|
||||
return operation;
|
||||
}
|
||||
|
||||
const fetchOptions =
|
||||
typeof operation.context.fetchOptions === "function"
|
||||
? operation.context.fetchOptions()
|
||||
: operation.context.fetchOptions || {};
|
||||
|
||||
return {
|
||||
...operation,
|
||||
context: {
|
||||
...operation.context,
|
||||
fetchOptions: {
|
||||
...fetchOptions,
|
||||
headers: {
|
||||
...fetchOptions.headers,
|
||||
"Authorization-Bearer": authState.token,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
getAuth,
|
||||
}),
|
||||
fetchExchange,
|
||||
],
|
||||
});
|
||||
|
||||
export function createServerClient(saleorApiUrl: string, token: string) {
|
||||
return createClient(saleorApiUrl, async () => Promise.resolve({ token }));
|
||||
}
|
||||
62
src/lib/env.mjs
Normal file
62
src/lib/env.mjs
Normal file
@@ -0,0 +1,62 @@
|
||||
// @ts-check
|
||||
import { createEnv } from "@t3-oss/env-nextjs";
|
||||
import { z } from "zod";
|
||||
|
||||
export const env = createEnv({
|
||||
isServer: typeof window === "undefined" || process.env.NODE_ENV === "test",
|
||||
/*
|
||||
* Serverside Environment variables, not available on the client.
|
||||
* Will throw if you access these variables on the client.
|
||||
*/
|
||||
server: {
|
||||
ENV: z.enum(["development", "test", "staging", "production"]).default("development"),
|
||||
SECRET_KEY: z.string().min(8, { message: "Cannot be too short" }),
|
||||
SENTRY_DSN: z.string().min(1).optional(),
|
||||
APL: z.enum(["saleor-cloud", "upstash", "file"]).optional().default("file"),
|
||||
CI: z.coerce.boolean().optional().default(false),
|
||||
APP_DEBUG: z
|
||||
.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"])
|
||||
.optional()
|
||||
.default("error"),
|
||||
VERCEL_URL: z.string().optional(),
|
||||
PORT: z.coerce.number().optional(),
|
||||
UPSTASH_URL: z.string().optional(),
|
||||
UPSTASH_TOKEN: z.string().optional(),
|
||||
REST_APL_ENDPOINT: z.string().optional(),
|
||||
REST_APL_TOKEN: z.string().optional(),
|
||||
ALLOWED_DOMAIN_PATTERN: z.string().optional(),
|
||||
},
|
||||
|
||||
/*
|
||||
* Environment variables available on the client (and server).
|
||||
*
|
||||
* 💡 You'll get type errors if these are not prefixed with NEXT_PUBLIC_.
|
||||
*/
|
||||
client: {
|
||||
NEXT_PUBLIC_SENTRY_DSN: z.optional(z.string().min(1)),
|
||||
},
|
||||
|
||||
/*
|
||||
* Due to how Next.js bundles environment variables on Edge and Client,
|
||||
* we need to manually destructure them to make sure all are included in bundle.
|
||||
*
|
||||
* 💡 You'll get type errors if not all variables from `server` & `client` are included here.
|
||||
*/
|
||||
runtimeEnv: {
|
||||
ENV: process.env.ENV,
|
||||
NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
|
||||
SECRET_KEY: process.env.SECRET_KEY,
|
||||
SENTRY_DSN: process.env.SENTRY_DSN,
|
||||
APL: process.env.APL,
|
||||
CI: process.env.CI,
|
||||
APP_DEBUG: process.env.APP_DEBUG,
|
||||
VERCEL_URL: process.env.VERCEL_URL,
|
||||
PORT: process.env.PORT,
|
||||
UPSTASH_URL: process.env.UPSTASH_URL,
|
||||
UPSTASH_TOKEN: process.env.UPSTASH_TOKEN,
|
||||
REST_APL_ENDPOINT: process.env.REST_APL_ENDPOINT,
|
||||
REST_APL_TOKEN: process.env.REST_APL_TOKEN,
|
||||
ALLOWED_DOMAIN_PATTERN: process.env.ALLOWED_DOMAIN_PATTERN,
|
||||
},
|
||||
});
|
||||
8
src/lib/gql-ast-to-string.ts
Normal file
8
src/lib/gql-ast-to-string.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// Copied from @saleor/app-sdk/gql-ast-to-string.ts
|
||||
import { type ASTNode, print } from "graphql";
|
||||
|
||||
export const gqlAstToString = (ast: ASTNode) =>
|
||||
print(ast) // convert AST to string
|
||||
.replaceAll(/\n+/g, " ") // remove new lines
|
||||
.replaceAll(/\s{2,}/g, " ") // remove unnecessary multiple spaces
|
||||
.trim(); // remove whitespace from beginning and end
|
||||
13
src/lib/invariant.test.ts
Normal file
13
src/lib/invariant.test.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { invariant } from "./invariant";
|
||||
|
||||
describe("invariant", () => {
|
||||
it.each([0, "", null, undefined, 0n])("should throw for %p", (value) => {
|
||||
expect(() => invariant(value)).toThrowError("Invariant failed: ");
|
||||
expect(() => invariant(value, "some message")).toThrowError("Invariant failed: some message");
|
||||
});
|
||||
|
||||
it.each([true, 1, "some str", {}, [], 123n])("should not throw for %p", (value) => {
|
||||
expect(() => invariant(value)).not.toThrowError();
|
||||
});
|
||||
});
|
||||
20
src/lib/invariant.ts
Normal file
20
src/lib/invariant.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import ModernError from "modern-errors";
|
||||
|
||||
export const InvariantError = ModernError.subclass("InvariantError");
|
||||
|
||||
export function invariant(condition: unknown, message?: string): asserts condition {
|
||||
if (!condition) {
|
||||
const err = new InvariantError(`Invariant failed: ${message || ""}`);
|
||||
// remove utils.js from stack trace for better error messages
|
||||
const stack = (err.stack ?? "").split("\n");
|
||||
stack.splice(1, 1);
|
||||
err.stack = stack.join("\n");
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/* c8 ignore start */
|
||||
export function assertUnreachableButNotThrow(_: never) {
|
||||
return null as never;
|
||||
}
|
||||
/* c8 ignore stop */
|
||||
4
src/lib/isEnv.ts
Normal file
4
src/lib/isEnv.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
/* eslint-disable node/no-process-env */
|
||||
export const isTest = () => process.env.NODE_ENV === "test";
|
||||
export const isDevelopment = () => process.env.NODE_ENV === "development";
|
||||
export const isProduction = () => process.env.NODE_ENV === "production";
|
||||
103
src/lib/logger.ts
Normal file
103
src/lib/logger.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
// We have to use process.env, otherwise pino doesn't work
|
||||
/* eslint-disable node/no-process-env */
|
||||
import pino from "pino";
|
||||
// import pinoPretty from "pino-pretty";
|
||||
// import { isDevelopment, isTest } from "./isEnv";
|
||||
import { isObject } from "./utils";
|
||||
import { obfuscateValue } from "@/modules/app-configuration/utils";
|
||||
import { BaseError, BaseTrpcError } from "@/errors";
|
||||
|
||||
/* c8 ignore start */
|
||||
export const logger = pino({
|
||||
level: process.env.APP_DEBUG ?? "info",
|
||||
redact: {
|
||||
paths: ["secretKey", "*[*].secretKey"],
|
||||
censor: (value) => redactLogValue(value),
|
||||
},
|
||||
// transport:
|
||||
// process.env.CI || isDevelopment() || isTest()
|
||||
// ? {
|
||||
// target: "pino-pretty",
|
||||
// options: {
|
||||
// colorize: true,
|
||||
// },
|
||||
// }
|
||||
// : undefined,
|
||||
});
|
||||
/* c8 ignore stop */
|
||||
|
||||
export const createLogger = logger.child.bind(logger);
|
||||
|
||||
export const redactLogValue = (value: unknown) => {
|
||||
if (typeof value !== "string") {
|
||||
// non-string values are fully redacted to prevent leaks
|
||||
return "[REDACTED]";
|
||||
}
|
||||
|
||||
return obfuscateValue(value);
|
||||
};
|
||||
|
||||
export const redactError = (error: unknown) => {
|
||||
if (error instanceof BaseTrpcError) {
|
||||
const { message, name, errorCode, statusCode, trpcCode } = error;
|
||||
return {
|
||||
message,
|
||||
name,
|
||||
errorCode,
|
||||
statusCode,
|
||||
trpcCode,
|
||||
};
|
||||
}
|
||||
if (error instanceof BaseError) {
|
||||
const { message, name, errorCode, statusCode } = error;
|
||||
return {
|
||||
message,
|
||||
name,
|
||||
errorCode,
|
||||
statusCode,
|
||||
};
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
const { message, name } = error;
|
||||
return {
|
||||
message,
|
||||
name,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const redactLogObject = <T extends {}>(obj: T, callCount = 1): T => {
|
||||
if (callCount > 10) {
|
||||
logger.warn("Exceeded max call count for redactLogObject");
|
||||
return { _message: "[REDACTED - MAX CALL COUNT EXCEEDED]" } as unknown as T;
|
||||
}
|
||||
|
||||
const entries = Object.entries(obj).map(([key, value]) => {
|
||||
if (isObject(value)) {
|
||||
return [key, redactLogObject(value, callCount + 1)];
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return [key, redactLogArray(value)];
|
||||
}
|
||||
return [key, redactLogValue(value)];
|
||||
});
|
||||
return Object.fromEntries(entries) as T;
|
||||
};
|
||||
|
||||
export const redactLogArray = <T extends unknown[]>(array: T | undefined, callCount = 1): T => {
|
||||
if (!array) return [] as unknown as T;
|
||||
if (callCount > 10) {
|
||||
logger.warn("Exceeded max call count for redactLogArray");
|
||||
return [] as unknown as T;
|
||||
}
|
||||
|
||||
return array.map((item) => {
|
||||
if (isObject(item)) {
|
||||
return redactLogObject(item, callCount + 1);
|
||||
}
|
||||
if (Array.isArray(item)) {
|
||||
return redactLogArray(item, callCount + 1);
|
||||
}
|
||||
return redactLogValue(item);
|
||||
}) as T;
|
||||
};
|
||||
119
src/lib/use-fetch.ts
Normal file
119
src/lib/use-fetch.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
import ModernError from "modern-errors";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { type z } from "zod";
|
||||
import { type JSONValue } from "../types";
|
||||
import { tryJsonParse } from "./utils";
|
||||
|
||||
export const FetchError = ModernError.subclass("FetchError", {
|
||||
props: {
|
||||
body: "",
|
||||
code: 500,
|
||||
},
|
||||
});
|
||||
export const FetchParseError = ModernError.subclass("FetchParseError");
|
||||
|
||||
type FetchConfig<T> = {
|
||||
schema: z.ZodType<T>;
|
||||
onSuccess?: (data: z.infer<z.ZodType<T>>) => void | Promise<void>;
|
||||
onError?: (
|
||||
err: InstanceType<typeof FetchError> | InstanceType<typeof FetchParseError>,
|
||||
) => void | Promise<void>;
|
||||
onFinished?: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
export const useFetchFn = () => {
|
||||
const { appBridgeState } = useAppBridge();
|
||||
const { saleorApiUrl, token } = appBridgeState ?? {};
|
||||
|
||||
const customFetch = useCallback(
|
||||
(info: RequestInfo | URL, init?: RequestInit | undefined) => {
|
||||
return fetch(info, {
|
||||
...init,
|
||||
body: JSON.stringify(init?.body),
|
||||
headers: {
|
||||
...init?.headers,
|
||||
"content-type": "application/json",
|
||||
"saleor-api-url": saleorApiUrl ?? "",
|
||||
"authorization-bearer": token ?? "",
|
||||
},
|
||||
});
|
||||
},
|
||||
[saleorApiUrl, token],
|
||||
);
|
||||
|
||||
return {
|
||||
fetch: customFetch,
|
||||
isReady: saleorApiUrl && token,
|
||||
};
|
||||
};
|
||||
|
||||
async function handleResponse<T>(res: Response, config: FetchConfig<T> | undefined): Promise<void> {
|
||||
if (!res.ok) {
|
||||
void config?.onError?.(
|
||||
new FetchError(res.statusText, {
|
||||
props: {
|
||||
body: await res.text(),
|
||||
code: res.status,
|
||||
},
|
||||
}),
|
||||
);
|
||||
void config?.onFinished?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (config) {
|
||||
try {
|
||||
const json = tryJsonParse(await res.text());
|
||||
const data = config.schema.parse(json);
|
||||
void config?.onSuccess?.(data);
|
||||
} catch (err) {
|
||||
void config?.onError?.(FetchParseError.normalize(err));
|
||||
}
|
||||
}
|
||||
void config?.onFinished?.();
|
||||
}
|
||||
|
||||
/** Fetch function, can be replaced to any fetching library, e.g. React Query, useSWR */
|
||||
export const useFetch = <T>(endpoint: string, config?: FetchConfig<T>) => {
|
||||
const { fetch, isReady } = useFetchFn();
|
||||
const configRef = useRef(config);
|
||||
|
||||
// We don't want changes in config to trigger re-fetch
|
||||
useEffect(() => {
|
||||
configRef.current = config;
|
||||
}, [config]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
const res = await fetch(endpoint);
|
||||
await handleResponse(res, configRef.current);
|
||||
})();
|
||||
}, [endpoint, fetch, isReady]);
|
||||
};
|
||||
|
||||
export const usePost = <T>(endpoint: string, config?: FetchConfig<T>) => {
|
||||
const { fetch, isReady } = useFetchFn();
|
||||
|
||||
const submit = useCallback(
|
||||
async (data: JSONValue, options?: RequestInit | undefined) => {
|
||||
if (!isReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
...options,
|
||||
});
|
||||
await handleResponse(res, config);
|
||||
},
|
||||
[config, endpoint, fetch, isReady],
|
||||
);
|
||||
|
||||
return submit;
|
||||
};
|
||||
98
src/lib/utils.test.ts
Normal file
98
src/lib/utils.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { tryIgnore, tryJsonParse, toStringOrEmpty, unpackPromise, unpackThrowable } from "./utils";
|
||||
import { BaseError } from "@/errors";
|
||||
|
||||
describe("api-route-utils", () => {
|
||||
describe("tryIgnore", () => {
|
||||
it("should run the function", () => {
|
||||
const fn = vi.fn();
|
||||
expect(() => tryIgnore(fn)).not.toThrow();
|
||||
expect(fn).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should ignore errors", () => {
|
||||
expect(() =>
|
||||
tryIgnore(() => {
|
||||
throw new Error("Error!");
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("tryJsonParse", () => {
|
||||
it("should ignore empty input", () => {
|
||||
expect(tryJsonParse("")).toBeUndefined();
|
||||
expect(tryJsonParse(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should try parsing to JSON", () => {
|
||||
expect(tryJsonParse('{"a": 123, "b": {"c": "aaa"}}')).toEqual({ a: 123, b: { c: "aaa" } });
|
||||
});
|
||||
|
||||
it("should return original input in case of error", () => {
|
||||
expect(tryJsonParse('{"a": 123, "b" {"c": "aaa"}}')).toBe('{"a": 123, "b" {"c": "aaa"}}');
|
||||
});
|
||||
});
|
||||
|
||||
describe("toStringOrEmpty", () => {
|
||||
it("should return value if it's a string", () => {
|
||||
expect(toStringOrEmpty("")).toBe("");
|
||||
expect(toStringOrEmpty("some string")).toBe("some string");
|
||||
});
|
||||
|
||||
it.each([0, 1, 1n, {}, [], undefined, null, false, true])(
|
||||
"should return empty string if value is not a string: %p",
|
||||
(value) => {
|
||||
expect(toStringOrEmpty(value)).toBe("");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("unpackPromise", () => {
|
||||
it("returns value if promise resolves", async () => {
|
||||
const [error, value] = await unpackPromise(Promise.resolve("some value"));
|
||||
expect(error).toBeNull();
|
||||
expect(value).toBe("some value");
|
||||
});
|
||||
|
||||
it("returns error if promise rejects", async () => {
|
||||
const [error, value] = await unpackPromise(Promise.reject("some error"));
|
||||
expect(error).toMatchInlineSnapshot("[UnknownError: some error]");
|
||||
expect(value).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves error if it's BaseError or descendants", async () => {
|
||||
const SomeError = BaseError.subclass("SomeError");
|
||||
const [error, value] = await unpackPromise(Promise.reject(new SomeError("some error")));
|
||||
expect(error).toMatchInlineSnapshot("[SomeError: some error]");
|
||||
expect(value).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("unpackThrowable", () => {
|
||||
it("returns value if promise resolves", async () => {
|
||||
const [error, value] = unpackThrowable(() => {
|
||||
return "some value";
|
||||
});
|
||||
expect(error).toBeNull();
|
||||
expect(value).toBe("some value");
|
||||
});
|
||||
|
||||
it("returns error if promise rejects", async () => {
|
||||
const [error, value] = unpackThrowable(() => {
|
||||
throw new Error("some error");
|
||||
});
|
||||
expect(error).toMatchInlineSnapshot("[Error: some error]");
|
||||
expect(value).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves error if it's BaseError or descendants", async () => {
|
||||
const SomeError = BaseError.subclass("SomeError");
|
||||
const [error, value] = unpackThrowable(() => {
|
||||
throw new SomeError("some error");
|
||||
});
|
||||
expect(error).toMatchInlineSnapshot("[SomeError: some error]");
|
||||
expect(value).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
62
src/lib/utils.ts
Normal file
62
src/lib/utils.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { JSONValue } from "../types";
|
||||
import { BaseError, UnknownError } from "@/errors";
|
||||
|
||||
export const tryJsonParse = (text: string | undefined) => {
|
||||
if (!text) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text) as JSONValue;
|
||||
} catch (e) {
|
||||
return text;
|
||||
}
|
||||
};
|
||||
|
||||
export const tryIgnore = (fn: () => void) => {
|
||||
try {
|
||||
fn();
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
};
|
||||
|
||||
export const toStringOrEmpty = (value: unknown) => {
|
||||
if (typeof value === "string") return value;
|
||||
return "";
|
||||
};
|
||||
|
||||
type PromiseToTupleResult<T> = [Error, null] | [null, Awaited<T>];
|
||||
export const unpackPromise = async <T extends Promise<unknown>>(
|
||||
promise: T,
|
||||
): Promise<PromiseToTupleResult<T>> => {
|
||||
try {
|
||||
const result = await promise;
|
||||
return [null, result];
|
||||
} catch (maybeError) {
|
||||
if (maybeError instanceof Error) {
|
||||
return [maybeError, null];
|
||||
}
|
||||
return [BaseError.normalize(maybeError, UnknownError), null];
|
||||
}
|
||||
};
|
||||
|
||||
type ThrowableToTupleResult<T> = [Error, null] | [null, T];
|
||||
export const unpackThrowable = <T>(throwable: () => T): ThrowableToTupleResult<T> => {
|
||||
try {
|
||||
const result = throwable();
|
||||
return [null, result];
|
||||
} catch (maybeError) {
|
||||
if (maybeError instanceof Error) {
|
||||
return [maybeError, null];
|
||||
}
|
||||
return [BaseError.normalize(maybeError, UnknownError), null];
|
||||
}
|
||||
};
|
||||
|
||||
export const isNotNullish = <T>(val: T | null | undefined): val is T =>
|
||||
val !== undefined && val !== null;
|
||||
|
||||
export const isObject = (val: unknown): val is Record<string, unknown> =>
|
||||
typeof val === "object" && val !== null && !Array.isArray(val);
|
||||
|
||||
export const __do = <T>(fn: () => T): T => fn();
|
||||
2
src/load-env.ts
Normal file
2
src/load-env.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import nextEnv from "@next/env";
|
||||
nextEnv.loadEnvConfig(".");
|
||||
@@ -0,0 +1,10 @@
|
||||
query Migration_01_FetchWebhookIds {
|
||||
app {
|
||||
webhooks {
|
||||
id
|
||||
syncEvents {
|
||||
eventType
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
12
src/migrations/1-add-issuing-pricinpal/UpdateWebhook.graphql
Normal file
12
src/migrations/1-add-issuing-pricinpal/UpdateWebhook.graphql
Normal file
@@ -0,0 +1,12 @@
|
||||
mutation Migration_01_UpdateWebhook($webhookId: ID!, $newQuery: String!) {
|
||||
webhookUpdate(id: $webhookId, input: { query: $newQuery }) {
|
||||
webhook {
|
||||
id
|
||||
}
|
||||
errors {
|
||||
code
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
42
src/migrations/1-add-issuing-pricinpal/index.ts
Normal file
42
src/migrations/1-add-issuing-pricinpal/index.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { type AuthData } from "@saleor/app-sdk/APL";
|
||||
import { createServerClient } from "@/lib/create-graphq-client";
|
||||
import {
|
||||
Migration_01_FetchWebhookIdsDocument,
|
||||
Migration_01_UpdateWebhookDocument,
|
||||
UntypedTransactionInitializeSessionDocument,
|
||||
WebhookEventTypeSyncEnum,
|
||||
} from "generated/graphql";
|
||||
import { gqlAstToString } from "@/lib/gql-ast-to-string";
|
||||
import { type PaymentAppConfigurator } from "@/modules/payment-app-configuration/payment-app-configuration";
|
||||
|
||||
export const requiredSaleorVersion = "3.13";
|
||||
|
||||
export async function migrate(authData: AuthData, _configurator: PaymentAppConfigurator) {
|
||||
const client = createServerClient(authData.saleorApiUrl, authData.token);
|
||||
const { data: fetchWebhookData } = await client
|
||||
.query(Migration_01_FetchWebhookIdsDocument, {})
|
||||
.toPromise();
|
||||
|
||||
const webhook = fetchWebhookData?.app?.webhooks?.find((webhook) =>
|
||||
webhook.syncEvents.find(
|
||||
(syncEvent) => syncEvent.eventType === WebhookEventTypeSyncEnum.TransactionInitializeSession,
|
||||
),
|
||||
);
|
||||
|
||||
if (!webhook) {
|
||||
throw new Error("No webhook to update");
|
||||
}
|
||||
|
||||
const webhookId = webhook.id;
|
||||
|
||||
const { data: updateWebhookData, error } = await client
|
||||
.mutation(Migration_01_UpdateWebhookDocument, {
|
||||
newQuery: gqlAstToString(UntypedTransactionInitializeSessionDocument),
|
||||
webhookId,
|
||||
})
|
||||
.toPromise();
|
||||
|
||||
if (error || !updateWebhookData?.webhookUpdate?.webhook?.id) {
|
||||
throw new Error("Error while updating webhook");
|
||||
}
|
||||
}
|
||||
159
src/modules/app-configuration/app-configuration.test.ts
Normal file
159
src/modules/app-configuration/app-configuration.test.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { type MetadataEntry } from "@saleor/app-sdk/settings-manager";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import {
|
||||
PrivateMetadataAppConfigurator,
|
||||
PublicMetadataAppConfiguration,
|
||||
serializeSettingsToMetadata,
|
||||
} from "./app-configuration";
|
||||
import {
|
||||
createWebhookPrivateSettingsManager,
|
||||
createWebhookPublicSettingsManager,
|
||||
} from "./metadata-manager";
|
||||
import { obfuscateValue, filterConfigValues, OBFUSCATION_DOTS } from "./utils";
|
||||
import { testEnv } from "@/__tests__/test-env.mjs";
|
||||
|
||||
describe("obfuscateValue", () => {
|
||||
it("obfuscates fully short values", () => {
|
||||
expect(obfuscateValue("123")).toEqual(OBFUSCATION_DOTS);
|
||||
expect(obfuscateValue("")).toEqual(OBFUSCATION_DOTS);
|
||||
expect(obfuscateValue("1234")).toEqual(OBFUSCATION_DOTS);
|
||||
});
|
||||
|
||||
it("leaves 4 charts of obfuscated value visible", () => {
|
||||
expect(obfuscateValue("12345")).toBe(`${OBFUSCATION_DOTS}5`);
|
||||
expect(obfuscateValue("123456")).toBe(`${OBFUSCATION_DOTS}56`);
|
||||
expect(obfuscateValue("1234567")).toBe(`${OBFUSCATION_DOTS}567`);
|
||||
expect(obfuscateValue("12345678")).toBe(`${OBFUSCATION_DOTS}5678`);
|
||||
expect(obfuscateValue("123456789")).toBe(`${OBFUSCATION_DOTS}6789`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterConfigValues", () => {
|
||||
it("filters out null and undefined values", () => {
|
||||
expect(filterConfigValues({ a: 1, b: null, c: undefined })).toEqual({ a: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("PublicMetadataAppConfigurator", () => {
|
||||
const onUpdate = vi.fn((update: MetadataEntry[]) => Promise.resolve(update));
|
||||
|
||||
beforeEach(() => {
|
||||
onUpdate.mockClear();
|
||||
});
|
||||
|
||||
const KEY = "some-metadata";
|
||||
|
||||
const getMetadata = (value?: unknown) => {
|
||||
return serializeSettingsToMetadata({
|
||||
key: KEY,
|
||||
domain: testEnv.TEST_SALEOR_API_URL,
|
||||
value: value ? JSON.stringify(value) : "",
|
||||
});
|
||||
};
|
||||
const defaultMetadata = { a: "a" };
|
||||
|
||||
const managerEmpty = new PublicMetadataAppConfiguration(
|
||||
createWebhookPublicSettingsManager([], onUpdate),
|
||||
testEnv.TEST_SALEOR_API_URL,
|
||||
KEY,
|
||||
);
|
||||
|
||||
// make tests easier
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let manager: PublicMetadataAppConfiguration<any>;
|
||||
beforeEach(() => {
|
||||
manager = new PublicMetadataAppConfiguration(
|
||||
createWebhookPublicSettingsManager([getMetadata(defaultMetadata)], onUpdate),
|
||||
testEnv.TEST_SALEOR_API_URL,
|
||||
"some-metadata",
|
||||
);
|
||||
});
|
||||
|
||||
it("gets metadata from metadataManager and parses it", async () => {
|
||||
await expect(manager.getConfig()).resolves.toEqual(defaultMetadata);
|
||||
await expect(managerEmpty.getConfig()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("gets metadata in raw config form", async () => {
|
||||
await expect(manager.getRawConfig()).resolves.toEqual([getMetadata(defaultMetadata)]);
|
||||
await expect(managerEmpty.getRawConfig()).resolves.toEqual([getMetadata()]);
|
||||
});
|
||||
|
||||
describe("setConfig", () => {
|
||||
it("skips saving metadata if there is nothing new", async () => {
|
||||
await manager.setConfig({});
|
||||
expect(onUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("replaces metadata if param is passed", async () => {
|
||||
await manager.setConfig({}, true);
|
||||
expect(onUpdate).toHaveBeenCalledWith([
|
||||
serializeSettingsToMetadata({
|
||||
key: "some-metadata",
|
||||
value: JSON.stringify({}),
|
||||
domain: testEnv.TEST_SALEOR_API_URL,
|
||||
}),
|
||||
]);
|
||||
|
||||
await manager.setConfig({ b: "b" }, true);
|
||||
expect(onUpdate).toHaveBeenCalledWith([getMetadata({ b: "b" })]);
|
||||
});
|
||||
|
||||
it("saves only settings that have values", async () => {
|
||||
await managerEmpty.setConfig({ a: null, b: "b", c: undefined });
|
||||
expect(onUpdate).toHaveBeenCalledWith([getMetadata({ b: "b" })]);
|
||||
});
|
||||
|
||||
it("merges new settings with existing ones", async () => {
|
||||
await manager.setConfig({ b: "b" });
|
||||
expect(onUpdate).toHaveBeenCalledWith([getMetadata({ a: "a", b: "b" })]);
|
||||
});
|
||||
});
|
||||
|
||||
it("clears metadata", async () => {
|
||||
await manager.clearConfig();
|
||||
expect(onUpdate).toHaveBeenCalledWith([
|
||||
serializeSettingsToMetadata({
|
||||
key: "some-metadata",
|
||||
value: "",
|
||||
domain: testEnv.TEST_SALEOR_API_URL,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PrivateMetadataAppConfigurator", () => {
|
||||
const metadataConfigurator = new PrivateMetadataAppConfigurator(
|
||||
createWebhookPublicSettingsManager([
|
||||
serializeSettingsToMetadata({
|
||||
key: "some-metadata",
|
||||
value: JSON.stringify({ a: "123456", b: "1234" }),
|
||||
domain: testEnv.TEST_SALEOR_API_URL,
|
||||
}),
|
||||
]),
|
||||
testEnv.TEST_SALEOR_API_URL,
|
||||
"some-metadata",
|
||||
);
|
||||
|
||||
it("can obfuscate provided config object", () => {
|
||||
expect(metadataConfigurator.obfuscateConfig({ a: "12345" })).toEqual({
|
||||
a: `${OBFUSCATION_DOTS}5`,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns empty config when none is set", async () => {
|
||||
const emptyMetadataConfigurator = new PrivateMetadataAppConfigurator(
|
||||
createWebhookPrivateSettingsManager([]),
|
||||
testEnv.TEST_SALEOR_API_URL,
|
||||
"some-metadata",
|
||||
);
|
||||
await expect(emptyMetadataConfigurator.getConfigObfuscated()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("can obfuscate its own config", async () => {
|
||||
await expect(metadataConfigurator.getConfigObfuscated()).resolves.toEqual({
|
||||
a: `${OBFUSCATION_DOTS}56`,
|
||||
b: OBFUSCATION_DOTS,
|
||||
});
|
||||
});
|
||||
});
|
||||
126
src/modules/app-configuration/app-configuration.ts
Normal file
126
src/modules/app-configuration/app-configuration.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
type SettingsValue,
|
||||
type MetadataEntry,
|
||||
type SettingsManager,
|
||||
} from "@saleor/app-sdk/settings-manager";
|
||||
import merge from "lodash-es/merge";
|
||||
import { toStringOrEmpty } from "../../lib/utils";
|
||||
import { filterConfigValues, obfuscateValue } from "./utils";
|
||||
import { logger as pinoLogger } from "@/lib/logger";
|
||||
|
||||
export interface GenericAppConfigurator<TConfig extends Record<string, unknown>> {
|
||||
setConfig(config: TConfig): Promise<void>;
|
||||
getConfig(): Promise<TConfig | undefined>;
|
||||
}
|
||||
|
||||
// Taken from @saleor/app-sdk/src/settings-manager
|
||||
export const serializeSettingsToMetadata = ({
|
||||
key,
|
||||
value,
|
||||
domain,
|
||||
}: SettingsValue): MetadataEntry => {
|
||||
// domain specific metadata use convention key__domain, e.g. `secret_key__example.com`
|
||||
if (!domain) {
|
||||
return { key, value };
|
||||
}
|
||||
|
||||
return {
|
||||
key: [key, domain].join("__"),
|
||||
value,
|
||||
};
|
||||
};
|
||||
|
||||
export abstract class MetadataConfigurator<TConfig extends Record<string, unknown>>
|
||||
implements GenericAppConfigurator<TConfig>
|
||||
{
|
||||
constructor(
|
||||
protected metadataManager: SettingsManager,
|
||||
protected saleorApiUrl: string,
|
||||
protected metadataKey: string,
|
||||
) {}
|
||||
|
||||
async getConfig(): Promise<TConfig | undefined> {
|
||||
const data = await this.metadataManager.get(this.metadataKey, this.saleorApiUrl);
|
||||
if (!data) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(data) as TConfig;
|
||||
} catch (e) {
|
||||
throw new Error("Invalid metadata value, cant be parsed");
|
||||
}
|
||||
}
|
||||
|
||||
async getRawConfig(
|
||||
prepareValue: (val: string) => string = (data) => data,
|
||||
): Promise<MetadataEntry[]> {
|
||||
const data = await this.metadataManager.get(this.metadataKey, this.saleorApiUrl);
|
||||
|
||||
return [
|
||||
// metadataManager strips out domain from key, we need to add it back
|
||||
serializeSettingsToMetadata({
|
||||
key: this.metadataKey,
|
||||
value: prepareValue(data ?? ""),
|
||||
domain: this.saleorApiUrl,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
async setConfig(newConfig: Partial<TConfig>, replace = false) {
|
||||
const logger = pinoLogger.child({
|
||||
saleorApiUrl: this.saleorApiUrl,
|
||||
metadataKey: this.metadataKey,
|
||||
});
|
||||
const filteredNewConfig = filterConfigValues(newConfig);
|
||||
if (Object.keys(filteredNewConfig).length === 0 && !replace) {
|
||||
logger.debug("No config to safe in metadata");
|
||||
return;
|
||||
}
|
||||
|
||||
const existingConfig = replace ? {} : await this.getConfig();
|
||||
|
||||
return this.metadataManager.set({
|
||||
key: this.metadataKey,
|
||||
value: JSON.stringify(merge(existingConfig, filteredNewConfig)),
|
||||
domain: this.saleorApiUrl,
|
||||
});
|
||||
}
|
||||
|
||||
async clearConfig() {
|
||||
return this.metadataManager.set({
|
||||
key: this.metadataKey,
|
||||
value: "",
|
||||
domain: this.saleorApiUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class PublicMetadataAppConfiguration<
|
||||
TConfig extends Record<string, unknown>,
|
||||
> extends MetadataConfigurator<TConfig> {}
|
||||
|
||||
export class PrivateMetadataAppConfigurator<
|
||||
TConfig extends Record<string, unknown>,
|
||||
> extends MetadataConfigurator<TConfig> {
|
||||
constructor(metadataManager: SettingsManager, saleorApiUrl: string, metadataKey: string) {
|
||||
super(metadataManager, saleorApiUrl, metadataKey);
|
||||
}
|
||||
|
||||
obfuscateConfig(config: TConfig): TConfig {
|
||||
const entries = Object.entries(config).map(([key, value]) => [
|
||||
key,
|
||||
obfuscateValue(toStringOrEmpty(value)),
|
||||
]);
|
||||
|
||||
return Object.fromEntries(entries) as TConfig;
|
||||
}
|
||||
|
||||
async getConfigObfuscated(): Promise<TConfig | undefined> {
|
||||
const config = await this.getConfig();
|
||||
if (!config) {
|
||||
return undefined;
|
||||
}
|
||||
return this.obfuscateConfig(config);
|
||||
}
|
||||
}
|
||||
142
src/modules/app-configuration/metadata-manager.ts
Normal file
142
src/modules/app-configuration/metadata-manager.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import {
|
||||
EncryptedMetadataManager,
|
||||
type MetadataEntry,
|
||||
MetadataManager,
|
||||
type MutateMetadataCallback,
|
||||
} from "@saleor/app-sdk/settings-manager";
|
||||
import { type Client } from "urql";
|
||||
import {
|
||||
FetchAppDetailsDocument,
|
||||
type FetchAppDetailsQuery,
|
||||
UpdateAppMetadataDocument,
|
||||
UpdatePublicMetadataDocument,
|
||||
} from "../../../generated/graphql";
|
||||
import { env } from "@/lib/env.mjs";
|
||||
|
||||
export async function fetchAllMetadata(client: Client): Promise<MetadataEntry[]> {
|
||||
const { error, data } = await client
|
||||
.query<FetchAppDetailsQuery>(FetchAppDetailsDocument, {})
|
||||
.toPromise();
|
||||
|
||||
if (error) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const combinedMetadata = [...(data?.app?.metadata || []), ...(data?.app?.privateMetadata || [])];
|
||||
|
||||
return combinedMetadata.map((md) => ({ key: md.key, value: md.value }));
|
||||
}
|
||||
|
||||
async function getAppId(client: Client) {
|
||||
const { error: idQueryError, data: idQueryData } = await client
|
||||
.query(FetchAppDetailsDocument, {})
|
||||
.toPromise();
|
||||
|
||||
if (idQueryError) {
|
||||
throw new Error(
|
||||
"Could not fetch the app id. Please check if auth data for the client are valid.",
|
||||
);
|
||||
}
|
||||
|
||||
const appId = idQueryData?.app?.id;
|
||||
|
||||
if (!appId) {
|
||||
throw new Error("Could not fetch the app ID");
|
||||
}
|
||||
|
||||
return appId;
|
||||
}
|
||||
|
||||
export async function mutatePrivateMetadata(client: Client, metadata: MetadataEntry[]) {
|
||||
// to update the metadata, ID is required
|
||||
const appId = await getAppId(client);
|
||||
|
||||
const { error: mutationError, data: mutationData } = await client
|
||||
.mutation(UpdateAppMetadataDocument, {
|
||||
id: appId,
|
||||
input: metadata,
|
||||
})
|
||||
.toPromise();
|
||||
|
||||
if (mutationError) {
|
||||
throw new Error(`Mutation error: ${mutationError.message}`);
|
||||
}
|
||||
|
||||
return (
|
||||
mutationData?.updatePrivateMetadata?.item?.privateMetadata.map((md) => ({
|
||||
key: md.key,
|
||||
value: md.value,
|
||||
})) || []
|
||||
);
|
||||
}
|
||||
|
||||
export async function mutatePublicMetadata(client: Client, metadata: MetadataEntry[]) {
|
||||
// to update the metadata, ID is required
|
||||
const appId = await getAppId(client);
|
||||
|
||||
const { error: mutationError, data: mutationData } = await client
|
||||
.mutation(UpdatePublicMetadataDocument, {
|
||||
id: appId,
|
||||
input: metadata,
|
||||
})
|
||||
.toPromise();
|
||||
|
||||
if (mutationError) {
|
||||
throw new Error(`Mutation error: ${mutationError.message}`);
|
||||
}
|
||||
|
||||
return (
|
||||
mutationData?.updateMetadata?.item?.metadata.map((md) => ({
|
||||
key: md.key,
|
||||
value: md.value,
|
||||
})) || []
|
||||
);
|
||||
}
|
||||
|
||||
// branded types are used to prevent using wrong manager for wrong metadata
|
||||
type Brand<K, T> = K & { __brand: T };
|
||||
export type BrandedEncryptedMetadataManager = Brand<
|
||||
EncryptedMetadataManager,
|
||||
"EncryptedMetadataManager"
|
||||
>;
|
||||
export type BrandedMetadataManager = Brand<MetadataManager, "MetadataManager">;
|
||||
|
||||
export const createPrivateSettingsManager = (client: Client) => {
|
||||
// EncryptedMetadataManager gives you interface to manipulate metadata and cache values in memory.
|
||||
// We recommend it for production, because all values are encrypted.
|
||||
// If your use case require plain text values, you can use MetadataManager.
|
||||
return new EncryptedMetadataManager({
|
||||
// Secret key should be randomly created for production and set as environment variable
|
||||
encryptionKey: env.SECRET_KEY,
|
||||
fetchMetadata: () => fetchAllMetadata(client),
|
||||
mutateMetadata: (metadata) => mutatePrivateMetadata(client, metadata),
|
||||
}) as BrandedEncryptedMetadataManager;
|
||||
};
|
||||
|
||||
export const createPublicSettingsManager = (client: Client) => {
|
||||
return new MetadataManager({
|
||||
fetchMetadata: () => fetchAllMetadata(client),
|
||||
mutateMetadata: (metadata) => mutatePublicMetadata(client, metadata),
|
||||
}) as BrandedMetadataManager;
|
||||
};
|
||||
|
||||
export const createWebhookPrivateSettingsManager = (
|
||||
data: MetadataEntry[],
|
||||
onUpdate?: MutateMetadataCallback,
|
||||
) => {
|
||||
return new EncryptedMetadataManager({
|
||||
encryptionKey: env.SECRET_KEY,
|
||||
fetchMetadata: () => Promise.resolve(data),
|
||||
mutateMetadata: onUpdate ?? (() => Promise.resolve([])),
|
||||
}) as BrandedEncryptedMetadataManager;
|
||||
};
|
||||
|
||||
export const createWebhookPublicSettingsManager = (
|
||||
data: MetadataEntry[],
|
||||
onUpdate?: MutateMetadataCallback,
|
||||
) => {
|
||||
return new MetadataManager({
|
||||
fetchMetadata: () => Promise.resolve(data),
|
||||
mutateMetadata: onUpdate ?? (() => Promise.resolve([])),
|
||||
}) as BrandedMetadataManager;
|
||||
};
|
||||
38
src/modules/app-configuration/utils.ts
Normal file
38
src/modules/app-configuration/utils.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { isNotNullish, toStringOrEmpty } from "../../lib/utils";
|
||||
|
||||
export const OBFUSCATION_DOTS = "••••";
|
||||
|
||||
export const obfuscateValue = (value: string) => {
|
||||
const unbofuscatedLength = Math.min(4, value.length - 4);
|
||||
|
||||
if (unbofuscatedLength <= 0) {
|
||||
return OBFUSCATION_DOTS;
|
||||
}
|
||||
|
||||
const visibleValue = value.slice(-unbofuscatedLength);
|
||||
return `${OBFUSCATION_DOTS}${visibleValue}`;
|
||||
};
|
||||
|
||||
export const deobfuscateValues = (values: Record<string, unknown>) => {
|
||||
const entries = Object.entries(values).map(
|
||||
([key, value]) =>
|
||||
[key, toStringOrEmpty(value).includes(OBFUSCATION_DOTS) ? null : value] as [string, unknown],
|
||||
);
|
||||
return Object.fromEntries(entries);
|
||||
};
|
||||
|
||||
export const filterConfigValues = <T extends Record<string, unknown>>(values: T) => {
|
||||
const entries = Object.entries(values).filter(
|
||||
([_, value]) => value !== null && value !== undefined,
|
||||
);
|
||||
return Object.fromEntries(entries);
|
||||
};
|
||||
|
||||
export const obfuscateConfig = <T extends {}>(config: T): T => {
|
||||
const entries = Object.entries(config).map(([key, value]) => [
|
||||
key,
|
||||
isNotNullish(value) ? obfuscateValue(toStringOrEmpty(value)) : value,
|
||||
]);
|
||||
|
||||
return Object.fromEntries(entries) as T;
|
||||
};
|
||||
43
src/modules/jwt/check-token-expiration.test.ts
Normal file
43
src/modules/jwt/check-token-expiration.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { SignJWT } from "jose";
|
||||
import { checkTokenExpiration } from "./check-token-expiration";
|
||||
|
||||
describe("checkTokenExpiration", () => {
|
||||
const secretKey = new TextEncoder().encode("test");
|
||||
|
||||
it("returns false if token is undefined", () => {
|
||||
expect(checkTokenExpiration(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false if token doesn't have expire date", async () => {
|
||||
// create JWT token without exp claim
|
||||
const jwt = await new SignJWT({ id: "12345" })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.sign(new Uint8Array(secretKey));
|
||||
expect(checkTokenExpiration(jwt)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false if token is not expired", async () => {
|
||||
const jwt = await new SignJWT({ id: "12345" })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setExpirationTime("5s")
|
||||
.sign(secretKey);
|
||||
expect(checkTokenExpiration(jwt)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true if token is expired", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(2000, 1, 1, 13, 1, 1));
|
||||
|
||||
const jwt = await new SignJWT({ id: "12345" })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setExpirationTime("1s")
|
||||
.sign(secretKey);
|
||||
|
||||
vi.setSystemTime(new Date(2000, 1, 1, 13, 1, 11)); // 10 seconds later
|
||||
|
||||
expect(checkTokenExpiration(jwt)).toBe(true);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
22
src/modules/jwt/check-token-expiration.ts
Normal file
22
src/modules/jwt/check-token-expiration.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { decodeJwt } from "jose";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
/** Checks if JWT token expired.
|
||||
* Returns false if token is still valid (note: there could be other issues)
|
||||
* or true if it expired */
|
||||
export const checkTokenExpiration = (token: string | undefined): boolean => {
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const claims = decodeJwt(token);
|
||||
if (claims.exp) {
|
||||
const now = new Date().getTime();
|
||||
const expireDate = claims.exp * 1000;
|
||||
|
||||
logger.trace({ claimsExp: claims.exp, now, expireDate }, "JWT token expiration time");
|
||||
return now >= expireDate;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
23
src/modules/jwt/check-token-offline.ts
Normal file
23
src/modules/jwt/check-token-offline.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { type Permission } from "@saleor/app-sdk/types";
|
||||
import { decodeJwt } from "jose";
|
||||
|
||||
const isPermissionsArray = (permissions: unknown): permissions is Permission[] => {
|
||||
return Array.isArray(permissions);
|
||||
};
|
||||
|
||||
export const checkTokenPermissions = (
|
||||
token: string | undefined,
|
||||
permissions: Permission[],
|
||||
): boolean => {
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const claims = decodeJwt(token);
|
||||
if (isPermissionsArray(claims.user_permissions)) {
|
||||
const userPermissions = new Set(claims.user_permissions);
|
||||
return permissions.every((requiredPermission) => userPermissions.has(requiredPermission));
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
3
src/modules/jwt/consts.ts
Normal file
3
src/modules/jwt/consts.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { type Permission } from "@saleor/app-sdk/types";
|
||||
|
||||
export const REQUIRED_SALEOR_PERMISSIONS: Permission[] = ["MANAGE_APPS", "MANAGE_SETTINGS"];
|
||||
10
src/modules/payment-app-configuration/__tests__/mocks.ts
Normal file
10
src/modules/payment-app-configuration/__tests__/mocks.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { type PaymentAppConfigEntryFullyConfigured } from "../config-entry";
|
||||
|
||||
export const configEntryAll: PaymentAppConfigEntryFullyConfigured = {
|
||||
configurationName: "test",
|
||||
secretKey: "sk_secret-key",
|
||||
publishableKey: "pk_that-secret-key",
|
||||
configurationId: "mock-id",
|
||||
webhookSecret: "whsec_test",
|
||||
webhookId: "webhook-id",
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import { encrypt, type MetadataEntry } from "@saleor/app-sdk/settings-manager";
|
||||
import {
|
||||
type BrandedEncryptedMetadataManager,
|
||||
type BrandedMetadataManager,
|
||||
createWebhookPrivateSettingsManager,
|
||||
} from "../../app-configuration/metadata-manager";
|
||||
import { serializeSettingsToMetadata } from "../../app-configuration/app-configuration";
|
||||
import { PaymentAppConfigurator, privateMetadataKey } from "../payment-app-configuration";
|
||||
import { type PaymentAppConfig } from "../app-config";
|
||||
import { env } from "@/lib/env.mjs";
|
||||
|
||||
export type MetadataManagerOverride = {
|
||||
private?: (metadata: MetadataEntry[]) => BrandedEncryptedMetadataManager;
|
||||
public?: (metadata: MetadataEntry[]) => BrandedMetadataManager;
|
||||
};
|
||||
|
||||
export const getFakePaymentAppConfigurator = (
|
||||
config: PaymentAppConfig,
|
||||
saleorApiUrl: string,
|
||||
metadataManager?: MetadataManagerOverride,
|
||||
) => {
|
||||
const privateConfigEntries: MetadataEntry[] = [
|
||||
serializeSettingsToMetadata({
|
||||
key: privateMetadataKey,
|
||||
value: encrypt(JSON.stringify(config), env.SECRET_KEY),
|
||||
domain: saleorApiUrl,
|
||||
}),
|
||||
];
|
||||
|
||||
const getPrivateSettingsManager = () => {
|
||||
if (metadataManager?.private) {
|
||||
return metadataManager.private(privateConfigEntries);
|
||||
}
|
||||
return createWebhookPrivateSettingsManager(privateConfigEntries);
|
||||
};
|
||||
|
||||
return new PaymentAppConfigurator(getPrivateSettingsManager(), saleorApiUrl);
|
||||
};
|
||||
@@ -0,0 +1,225 @@
|
||||
import { vi, describe, it, expect } from "vitest";
|
||||
import { PaymentAppConfigurator } from "../payment-app-configuration";
|
||||
import {
|
||||
type ChannelMapping,
|
||||
paymentAppConfigSchema,
|
||||
type PaymentAppConfig,
|
||||
type PaymentAppConfigUserVisible,
|
||||
} from "../app-config";
|
||||
import { type PaymentAppConfigEntry } from "../config-entry";
|
||||
import { configEntryAll } from "./mocks";
|
||||
import { testEnv } from "@/__tests__/test-env.mjs";
|
||||
import { type BrandedEncryptedMetadataManager } from "@/modules/app-configuration/metadata-manager";
|
||||
import { type PrivateMetadataAppConfigurator } from "@/modules/app-configuration/app-configuration";
|
||||
import { OBFUSCATION_DOTS } from "@/modules/app-configuration/utils";
|
||||
|
||||
describe("PaymentAppConfigurator", () => {
|
||||
const metadataManagerMock = {} as BrandedEncryptedMetadataManager;
|
||||
const saleorApiUrlMock = testEnv.TEST_SALEOR_API_URL;
|
||||
const configuratorMock = {
|
||||
setConfig: vi.fn(async () => {}),
|
||||
getConfig: vi.fn(async () => undefined),
|
||||
} as unknown as PrivateMetadataAppConfigurator<any>;
|
||||
const appConfigurator = new PaymentAppConfigurator(metadataManagerMock, saleorApiUrlMock);
|
||||
appConfigurator["configurator"] = configuratorMock;
|
||||
|
||||
describe("getConfig", () => {
|
||||
const defaultConfig = { configurations: [], channelToConfigurationId: {}, lastMigration: null };
|
||||
it("should call the configurator and return value that matches schema", async () => {
|
||||
const getConfig = vi.spyOn(configuratorMock, "getConfig").mockResolvedValue(defaultConfig);
|
||||
|
||||
const config = await appConfigurator.getConfig();
|
||||
expect(getConfig).toHaveBeenCalled();
|
||||
expect(config).toEqual(defaultConfig);
|
||||
});
|
||||
|
||||
it("if configurator returns undefined it should provide a default config", async () => {
|
||||
const getConfig = vi.spyOn(configuratorMock, "getConfig").mockResolvedValue(undefined);
|
||||
|
||||
const config = await appConfigurator.getConfig();
|
||||
expect(getConfig).toHaveBeenCalled();
|
||||
expect(config).toEqual(defaultConfig);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getConfigObfuscated", () => {
|
||||
it("should obfuscate configurations and keep channelToConfigurationId as is", async () => {
|
||||
const mockConfig = {
|
||||
configurations: [configEntryAll],
|
||||
channelToConfigurationId: { "channel-1": "mock-id" },
|
||||
} satisfies PaymentAppConfig;
|
||||
const getConfig = vi.spyOn(appConfigurator, "getConfig").mockResolvedValue(mockConfig);
|
||||
|
||||
const obfuscatedConfig = await appConfigurator.getConfigObfuscated();
|
||||
|
||||
expect(getConfig).toHaveBeenCalled();
|
||||
expect(obfuscatedConfig).toEqual({
|
||||
configurations: [
|
||||
{
|
||||
configurationId: "mock-id",
|
||||
secretKey: `${OBFUSCATION_DOTS}-key`, // from super-secret-key
|
||||
publishableKey: "pk_that-secret-key",
|
||||
configurationName: "test",
|
||||
webhookId: "webhook-id",
|
||||
},
|
||||
],
|
||||
channelToConfigurationId: mockConfig.channelToConfigurationId,
|
||||
} satisfies PaymentAppConfigUserVisible);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setConfigEntry", () => {
|
||||
it("should update app config with new config entry added to list of config entries", async () => {
|
||||
const newConfigEntry: PaymentAppConfigEntry = {
|
||||
...configEntryAll,
|
||||
configurationId: "new-mock-id",
|
||||
};
|
||||
const existingConfig: PaymentAppConfig = {
|
||||
configurations: [configEntryAll],
|
||||
channelToConfigurationId: {},
|
||||
};
|
||||
|
||||
const getConfig = vi.spyOn(appConfigurator, "getConfig").mockResolvedValue(existingConfig);
|
||||
const setConfig = vi.spyOn(appConfigurator, "setConfig");
|
||||
|
||||
await appConfigurator.setConfigEntry(newConfigEntry);
|
||||
|
||||
expect(getConfig).toHaveBeenCalled();
|
||||
expect(setConfig).toHaveBeenCalledWith({
|
||||
configurations: [configEntryAll, newConfigEntry],
|
||||
});
|
||||
});
|
||||
|
||||
it("should update app config with update config entry changed in list of config entries", async () => {
|
||||
const updateConfigEntry: PaymentAppConfigEntry = {
|
||||
...configEntryAll,
|
||||
configurationId: "new-mock-id",
|
||||
};
|
||||
const existingConfig: PaymentAppConfig = {
|
||||
configurations: [configEntryAll, { ...configEntryAll, configurationId: "new-mock-id" }],
|
||||
channelToConfigurationId: {},
|
||||
};
|
||||
|
||||
const getConfig = vi.spyOn(appConfigurator, "getConfig").mockResolvedValue(existingConfig);
|
||||
const setConfig = vi.spyOn(appConfigurator, "setConfig");
|
||||
|
||||
await appConfigurator.setConfigEntry(updateConfigEntry);
|
||||
|
||||
expect(getConfig).toHaveBeenCalled();
|
||||
expect(setConfig).toHaveBeenCalledWith({
|
||||
configurations: [configEntryAll, updateConfigEntry],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteConfigEntry", () => {
|
||||
it("should call setConfig without the deleted configurationId", async () => {
|
||||
const existingConfig: PaymentAppConfig = {
|
||||
configurations: [configEntryAll],
|
||||
channelToConfigurationId: {},
|
||||
};
|
||||
|
||||
const getConfig = vi.spyOn(appConfigurator, "getConfig").mockResolvedValue(existingConfig);
|
||||
const setConfig = vi.spyOn(appConfigurator, "setConfig");
|
||||
|
||||
await appConfigurator.deleteConfigEntry(configEntryAll.configurationId);
|
||||
|
||||
expect(getConfig).toHaveBeenCalled();
|
||||
expect(setConfig).toHaveBeenCalledWith(
|
||||
{ channelToConfigurationId: {}, configurations: [] },
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setMapping", () => {
|
||||
it("should call setConfig with new mapping", async () => {
|
||||
const newMapping: ChannelMapping = { "channel-2": "new-mock-id" };
|
||||
const existingConfig: PaymentAppConfig = { configurations: [], channelToConfigurationId: {} };
|
||||
|
||||
const getConfig = vi.spyOn(appConfigurator, "getConfig").mockResolvedValue(existingConfig);
|
||||
const setConfig = vi.spyOn(appConfigurator, "setConfig");
|
||||
|
||||
await appConfigurator.setMapping(newMapping);
|
||||
|
||||
expect(getConfig).toHaveBeenCalled();
|
||||
expect(setConfig).toHaveBeenCalledWith({
|
||||
channelToConfigurationId: newMapping,
|
||||
});
|
||||
});
|
||||
|
||||
it("should call setConfig with merged mappings", async () => {
|
||||
const existingMapping: ChannelMapping = { "channel-1": "old-mock-id" };
|
||||
const newMapping: ChannelMapping = { "channel-2": "new-mock-id" };
|
||||
const existingConfig: PaymentAppConfig = {
|
||||
configurations: [],
|
||||
channelToConfigurationId: { ...existingMapping },
|
||||
};
|
||||
|
||||
const getConfig = vi.spyOn(appConfigurator, "getConfig").mockResolvedValue(existingConfig);
|
||||
const setConfig = vi.spyOn(appConfigurator, "setConfig");
|
||||
|
||||
await appConfigurator.setMapping(newMapping);
|
||||
|
||||
expect(getConfig).toHaveBeenCalled();
|
||||
expect(setConfig).toHaveBeenCalledWith({
|
||||
channelToConfigurationId: { ...existingMapping, ...newMapping },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteMapping", () => {
|
||||
it("should call setConfig without the deleted channelId", async () => {
|
||||
const existingMapping: ChannelMapping = { "channel-1": "existing-mock-id" };
|
||||
const existingConfig: PaymentAppConfig = {
|
||||
configurations: [],
|
||||
channelToConfigurationId: existingMapping,
|
||||
};
|
||||
|
||||
const getConfig = vi.spyOn(appConfigurator, "getConfig").mockResolvedValue(existingConfig);
|
||||
const setConfig = vi.spyOn(appConfigurator, "setConfig");
|
||||
|
||||
await appConfigurator.deleteMapping("channel-1");
|
||||
|
||||
expect(getConfig).toHaveBeenCalled();
|
||||
expect(setConfig).toHaveBeenCalledWith({ channelToConfigurationId: {} });
|
||||
});
|
||||
});
|
||||
|
||||
describe("setConfig", () => {
|
||||
it("should call setConfig on configurator", async () => {
|
||||
const newConfig: Partial<PaymentAppConfig> = {
|
||||
configurations: [],
|
||||
channelToConfigurationId: {},
|
||||
};
|
||||
const setConfig = vi.spyOn(configuratorMock, "setConfig");
|
||||
|
||||
await appConfigurator.setConfig(newConfig);
|
||||
|
||||
expect(setConfig).toHaveBeenCalledWith(newConfig, false);
|
||||
});
|
||||
|
||||
it("should call setConfig with replace = true", async () => {
|
||||
const newConfig: Partial<PaymentAppConfig> = {
|
||||
configurations: [],
|
||||
channelToConfigurationId: {},
|
||||
};
|
||||
const setConfig = vi.spyOn(configuratorMock, "setConfig");
|
||||
|
||||
await appConfigurator.setConfig(newConfig, true);
|
||||
|
||||
expect(setConfig).toHaveBeenCalledWith(newConfig, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearConfig", () => {
|
||||
it("should call setConfig on configurator with replace parameter set to true", async () => {
|
||||
const defaultConfig = paymentAppConfigSchema.parse(undefined);
|
||||
const setConfig = vi.spyOn(configuratorMock, "setConfig");
|
||||
|
||||
await appConfigurator.clearConfig();
|
||||
|
||||
expect(setConfig).toHaveBeenCalledWith(defaultConfig, true);
|
||||
});
|
||||
});
|
||||
});
|
||||
35
src/modules/payment-app-configuration/__tests__/utils.ts
Normal file
35
src/modules/payment-app-configuration/__tests__/utils.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { type PaymentAppConfig } from "../app-config";
|
||||
import {
|
||||
getFakePaymentAppConfigurator,
|
||||
type MetadataManagerOverride,
|
||||
} from "./payment-app-configuration-factory";
|
||||
import { testEnv } from "@/__tests__/test-env.mjs";
|
||||
|
||||
export const filledFakeMatadataConfig = {
|
||||
configurations: [
|
||||
{
|
||||
secretKey: testEnv.TEST_PAYMENT_APP_SECRET_KEY,
|
||||
publishableKey: testEnv.TEST_PAYMENT_APP_PUBLISHABLE_KEY,
|
||||
configurationId: "mock-id",
|
||||
configurationName: "test",
|
||||
webhookId: testEnv.TEST_PAYMENT_APP_WEBHOOK_ID,
|
||||
webhookSecret: testEnv.TEST_PAYMENT_APP_WEBHOOK_SECRET,
|
||||
},
|
||||
],
|
||||
channelToConfigurationId: {
|
||||
"1": "mock-id",
|
||||
},
|
||||
} satisfies PaymentAppConfig;
|
||||
|
||||
export const getFilledFakeMetadataConfigurator = (override?: MetadataManagerOverride) => {
|
||||
return getFakePaymentAppConfigurator(
|
||||
filledFakeMatadataConfig,
|
||||
testEnv.TEST_SALEOR_API_URL,
|
||||
override,
|
||||
);
|
||||
};
|
||||
|
||||
export const getFilledMetadata = () => {
|
||||
const configurator = getFilledFakeMetadataConfigurator();
|
||||
return configurator.getRawConfig();
|
||||
};
|
||||
48
src/modules/payment-app-configuration/app-config.ts
Normal file
48
src/modules/payment-app-configuration/app-config.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
paymentAppConfigEntrySchema,
|
||||
paymentAppUserVisibleConfigEntrySchema,
|
||||
} from "./config-entry";
|
||||
|
||||
export const paymentAppConfigEntriesSchema = paymentAppConfigEntrySchema.array();
|
||||
export const paymentAppUserVisibleConfigEntriesSchema =
|
||||
paymentAppUserVisibleConfigEntrySchema.array();
|
||||
|
||||
// Record<ChannelID, AppConfigEntryId>
|
||||
export const channelMappingSchema = z
|
||||
.record(z.string().min(1), z.string().min(1).nullable())
|
||||
.default({});
|
||||
|
||||
export type ChannelMapping = z.infer<typeof channelMappingSchema>;
|
||||
|
||||
export const paymentAppConfigSchema = z
|
||||
.object({
|
||||
configurations: paymentAppConfigEntriesSchema,
|
||||
channelToConfigurationId: channelMappingSchema,
|
||||
lastMigration: z.number().nullish(),
|
||||
})
|
||||
.default({
|
||||
configurations: [],
|
||||
channelToConfigurationId: {},
|
||||
lastMigration: null,
|
||||
});
|
||||
|
||||
export const paymentAppUserVisibleConfigSchema = z
|
||||
.object({
|
||||
configurations: paymentAppUserVisibleConfigEntriesSchema,
|
||||
channelToConfigurationId: channelMappingSchema,
|
||||
})
|
||||
.default({
|
||||
configurations: [],
|
||||
channelToConfigurationId: {},
|
||||
});
|
||||
|
||||
export const defaultPaymentAppConfig: PaymentAppConfig = {
|
||||
configurations: [],
|
||||
channelToConfigurationId: {},
|
||||
};
|
||||
|
||||
export type PaymentAppConfigEntries = z.infer<typeof paymentAppConfigEntriesSchema>;
|
||||
export type PaymentAppUserVisibleEntries = z.infer<typeof paymentAppUserVisibleConfigEntriesSchema>;
|
||||
export type PaymentAppConfig = z.infer<typeof paymentAppConfigSchema>;
|
||||
export type PaymentAppConfigUserVisible = z.infer<typeof paymentAppUserVisibleConfigSchema>;
|
||||
99
src/modules/payment-app-configuration/config-entry.ts
Normal file
99
src/modules/payment-app-configuration/config-entry.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { z } from "zod";
|
||||
import { deobfuscateValues } from "../app-configuration/utils";
|
||||
|
||||
export const DANGEROUS_paymentAppConfigHiddenSchema = z.object({
|
||||
webhookSecret: z.string().min(1),
|
||||
});
|
||||
|
||||
export const paymentAppConfigEntryInternalSchema = z.object({
|
||||
configurationId: z.string().min(1),
|
||||
webhookId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const paymentAppConfigEntryEncryptedSchema = z.object({
|
||||
secretKey: z
|
||||
.string({ required_error: "Secret Key is required" })
|
||||
.min(1, { message: "Secret Key is required" }),
|
||||
});
|
||||
|
||||
export const paymentAppConfigEntryPublicSchema = z.object({
|
||||
publishableKey: z
|
||||
.string({ required_error: "Publishable Key is required" })
|
||||
.min(1, { message: "Publishable Key is required" }),
|
||||
configurationName: z
|
||||
.string({ required_error: "Configuration name is required" })
|
||||
.min(1, { message: "Configuration name is required" }),
|
||||
});
|
||||
|
||||
export const paymentAppConfigEntrySchema = DANGEROUS_paymentAppConfigHiddenSchema.merge(
|
||||
paymentAppConfigEntryEncryptedSchema,
|
||||
)
|
||||
.merge(paymentAppConfigEntryPublicSchema)
|
||||
.merge(paymentAppConfigEntryInternalSchema);
|
||||
|
||||
// Entire config available to user
|
||||
export const paymentAppUserVisibleConfigEntrySchema = paymentAppConfigEntryPublicSchema
|
||||
.merge(paymentAppConfigEntryEncryptedSchema)
|
||||
.merge(paymentAppConfigEntryInternalSchema)
|
||||
.strict();
|
||||
|
||||
// Fully configured app - all fields are required
|
||||
// Zod doesn't have a utility for marking fields as non-nullable, we need to use unwrap
|
||||
export const paymentAppFullyConfiguredEntrySchema = z
|
||||
.object({
|
||||
configurationName: paymentAppConfigEntryPublicSchema.shape.configurationName,
|
||||
configurationId: paymentAppConfigEntryInternalSchema.shape.configurationId,
|
||||
secretKey: paymentAppConfigEntryEncryptedSchema.shape.secretKey,
|
||||
publishableKey: paymentAppConfigEntryPublicSchema.shape.publishableKey,
|
||||
webhookSecret: DANGEROUS_paymentAppConfigHiddenSchema.shape.webhookSecret,
|
||||
webhookId: paymentAppConfigEntryInternalSchema.shape.webhookId,
|
||||
})
|
||||
.required();
|
||||
|
||||
// Schema used as input validation for saving config entires
|
||||
export const paymentAppFormConfigEntrySchema = z
|
||||
.object({
|
||||
configurationName: paymentAppConfigEntryPublicSchema.shape.configurationName,
|
||||
secretKey: paymentAppConfigEntryEncryptedSchema.shape.secretKey.startsWith(
|
||||
"sk_",
|
||||
"This isn't a Stripe secret key, it must start with sk_",
|
||||
),
|
||||
publishableKey: paymentAppConfigEntryPublicSchema.shape.publishableKey.startsWith(
|
||||
"pk_",
|
||||
"This isn't a Stripe publishable key, it must start with pk_",
|
||||
),
|
||||
})
|
||||
.strict()
|
||||
.default({
|
||||
secretKey: "",
|
||||
publishableKey: "",
|
||||
configurationName: "",
|
||||
});
|
||||
|
||||
/** Schema used in front-end forms
|
||||
* Replaces obfuscated values with null */
|
||||
export const paymentAppEncryptedFormSchema = paymentAppConfigEntryEncryptedSchema.transform(
|
||||
(values) => deobfuscateValues(values),
|
||||
);
|
||||
|
||||
// Schema used for front-end forms
|
||||
export const paymentAppCombinedFormSchema = z.intersection(
|
||||
paymentAppEncryptedFormSchema,
|
||||
paymentAppConfigEntryPublicSchema,
|
||||
);
|
||||
|
||||
export type PaymentAppInternalConfig = z.infer<typeof paymentAppConfigEntryInternalSchema>;
|
||||
export type PaymentAppEncryptedConfig = z.infer<typeof paymentAppConfigEntryEncryptedSchema>;
|
||||
export type PaymentAppPublicConfig = z.infer<typeof paymentAppConfigEntryPublicSchema>;
|
||||
|
||||
export type PaymentAppConfigEntry = z.infer<typeof paymentAppConfigEntrySchema>;
|
||||
export type PaymentAppConfigEntryFullyConfigured = z.infer<
|
||||
typeof paymentAppFullyConfiguredEntrySchema
|
||||
>;
|
||||
export type PaymentAppUserVisibleConfigEntry = z.infer<
|
||||
typeof paymentAppUserVisibleConfigEntrySchema
|
||||
>;
|
||||
export type PaymentAppFormConfigEntry = z.infer<typeof paymentAppFormConfigEntrySchema>;
|
||||
export type PaymentAppConfigEntryUpdate = Partial<PaymentAppConfigEntry> & {
|
||||
configurationId: PaymentAppConfigEntry["configurationId"];
|
||||
};
|
||||
167
src/modules/payment-app-configuration/config-manager.test.ts
Normal file
167
src/modules/payment-app-configuration/config-manager.test.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
/* eslint-disable @typescript-eslint/unbound-method */
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { OBFUSCATION_DOTS } from "../app-configuration/utils";
|
||||
import {
|
||||
addConfigEntry,
|
||||
updateConfigEntry,
|
||||
EntryNotFoundError,
|
||||
deleteConfigEntry,
|
||||
getConfigEntryObfuscated,
|
||||
getAllConfigEntriesObfuscated,
|
||||
} from "./config-manager";
|
||||
import { configEntryAll } from "./__tests__/mocks";
|
||||
import { obfuscateConfigEntry } from "./utils";
|
||||
import { type ConfigEntryUpdate } from "./input-schemas";
|
||||
import { type PaymentAppConfigurator } from "./payment-app-configuration";
|
||||
import {
|
||||
type PaymentAppConfigEntryFullyConfigured,
|
||||
type PaymentAppFormConfigEntry,
|
||||
} from "./config-entry";
|
||||
import { deleteStripeWebhook } from "./webhook-manager";
|
||||
import { testEnv } from "@/__tests__/test-env.mjs";
|
||||
|
||||
vi.mock("@/modules/stripe/stripe-api", async () => {
|
||||
return {
|
||||
validateStripeKeys: () => {},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/modules/payment-app-configuration/webhook-manager", () => {
|
||||
return {
|
||||
createStripeWebhook: async () => ({
|
||||
webhookSecret: "ws_secret",
|
||||
webhookId: "12345",
|
||||
}),
|
||||
deleteStripeWebhook: vi.fn(async () => {}),
|
||||
};
|
||||
});
|
||||
|
||||
const mockConfigurator = {
|
||||
getConfig: vi.fn(async () => ({ configurations: [configEntryAll] })),
|
||||
getConfigObfuscated: vi.fn(async () => ({
|
||||
configurations: [obfuscateConfigEntry(configEntryAll)],
|
||||
})),
|
||||
setConfigEntry: vi.fn(async () => {}),
|
||||
saleorApiUrl: testEnv.TEST_SALEOR_API_URL,
|
||||
deleteConfigEntry: vi.fn(async () => {}),
|
||||
} as unknown as PaymentAppConfigurator;
|
||||
|
||||
describe("getAllConfigEntriesObfuscated", () => {
|
||||
it("calls configurator and returns data", async () => {
|
||||
const entries = await getAllConfigEntriesObfuscated(mockConfigurator);
|
||||
|
||||
expect(entries).toEqual([obfuscateConfigEntry(configEntryAll)]);
|
||||
expect(mockConfigurator.getConfigObfuscated).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findConfigEntry", () => {
|
||||
it("calls getAllConfigEntriesObfuscated and finds entry with provided ID", async () => {
|
||||
const entry = await getConfigEntryObfuscated(configEntryAll.configurationId, mockConfigurator);
|
||||
|
||||
expect(entry).toEqual(obfuscateConfigEntry(configEntryAll));
|
||||
expect(mockConfigurator.getConfigObfuscated).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addConfigEntry", () => {
|
||||
it("generates random id for new config entry, creates webhook in Stripe, saves config entry in configurator, returns new config entry which has obfuscated fields", async () => {
|
||||
const input: PaymentAppFormConfigEntry = {
|
||||
configurationName: "new-config",
|
||||
secretKey: "new-key",
|
||||
publishableKey: "client-key",
|
||||
};
|
||||
const result = await addConfigEntry(input, mockConfigurator, "http://stripe.saleor.io");
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
configurationId: expect.any(String),
|
||||
configurationName: input.configurationName,
|
||||
secretKey: `${OBFUSCATION_DOTS}key`,
|
||||
publishableKey: "client-key",
|
||||
webhookId: "12345",
|
||||
});
|
||||
expect(mockConfigurator.setConfigEntry).toHaveBeenCalledTimes(1);
|
||||
expect(mockConfigurator.setConfigEntry).toHaveBeenCalledWith({
|
||||
configurationId: expect.any(String),
|
||||
configurationName: input.configurationName,
|
||||
secretKey: "new-key",
|
||||
publishableKey: "client-key",
|
||||
webhookId: "12345",
|
||||
webhookSecret: "ws_secret",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateConfigEntry", () => {
|
||||
it("checks if entry exists, updates entry in configurator", async () => {
|
||||
const input = {
|
||||
configurationId: configEntryAll.configurationId,
|
||||
entry: {
|
||||
configurationName: "new-name",
|
||||
secretKey: "updated-key",
|
||||
publishableKey: configEntryAll.publishableKey,
|
||||
},
|
||||
} satisfies ConfigEntryUpdate;
|
||||
|
||||
const result = await updateConfigEntry(input, mockConfigurator);
|
||||
|
||||
expect(result).toEqual(
|
||||
obfuscateConfigEntry({
|
||||
...configEntryAll,
|
||||
configurationName: "new-name",
|
||||
}),
|
||||
);
|
||||
expect(mockConfigurator.setConfigEntry).toHaveBeenCalledWith({
|
||||
...input.entry,
|
||||
configurationId: input.configurationId,
|
||||
});
|
||||
});
|
||||
|
||||
it("throws an error if config entry is not found", async () => {
|
||||
const input = {
|
||||
configurationId: "non-existing-id",
|
||||
entry: {
|
||||
configurationName: configEntryAll.configurationName,
|
||||
secretKey: "updated-key",
|
||||
publishableKey: configEntryAll.publishableKey,
|
||||
},
|
||||
} satisfies ConfigEntryUpdate;
|
||||
|
||||
await expect(updateConfigEntry(input, mockConfigurator)).rejects.toThrow(EntryNotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteConfigEntry", () => {
|
||||
it("checks if entry exists, deletes entry in configurator, deletes Stripe webhook", async () => {
|
||||
const result = await deleteConfigEntry(configEntryAll.configurationId, mockConfigurator);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockConfigurator.getConfig).toHaveBeenCalledOnce();
|
||||
expect(mockConfigurator.deleteConfigEntry).toHaveBeenCalledWith(configEntryAll.configurationId);
|
||||
expect(deleteStripeWebhook).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("checks if entry exists, skips deleting Stripe webhook if it exists in other config", async () => {
|
||||
const additionalEntry = {
|
||||
...configEntryAll,
|
||||
configurationId: "other-config-id",
|
||||
} satisfies PaymentAppConfigEntryFullyConfigured;
|
||||
|
||||
const testMockConfigurator = {
|
||||
...mockConfigurator,
|
||||
getConfig: vi.fn(async () => ({ configurations: [configEntryAll, additionalEntry] })),
|
||||
} as unknown as PaymentAppConfigurator;
|
||||
|
||||
const result = await deleteConfigEntry(configEntryAll.configurationId, testMockConfigurator);
|
||||
expect(result).toBeUndefined();
|
||||
expect(testMockConfigurator.getConfig).toHaveBeenCalledOnce();
|
||||
expect(mockConfigurator.deleteConfigEntry).toHaveBeenCalledWith(configEntryAll.configurationId);
|
||||
expect(deleteStripeWebhook).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws an error if config entry is not found", async () => {
|
||||
await expect(deleteConfigEntry("non-existing-id", mockConfigurator)).rejects.toThrow(
|
||||
EntryNotFoundError,
|
||||
);
|
||||
});
|
||||
});
|
||||
188
src/modules/payment-app-configuration/config-manager.ts
Normal file
188
src/modules/payment-app-configuration/config-manager.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { uuidv7 } from "uuidv7";
|
||||
import { validateStripeKeys } from "../stripe/stripe-api";
|
||||
import { type ConfigEntryUpdate } from "./input-schemas";
|
||||
import { obfuscateConfigEntry } from "./utils";
|
||||
import { type PaymentAppConfigurator } from "./payment-app-configuration";
|
||||
import {
|
||||
type PaymentAppConfigEntryFullyConfigured,
|
||||
type PaymentAppFormConfigEntry,
|
||||
} from "./config-entry";
|
||||
import { createStripeWebhook, deleteStripeWebhook } from "./webhook-manager";
|
||||
import { createLogger, redactError, redactLogObject } from "@/lib/logger";
|
||||
import { BaseError } from "@/errors";
|
||||
|
||||
export const EntryNotFoundError = BaseError.subclass("EntryNotFoundError");
|
||||
|
||||
export const getAllConfigEntriesObfuscated = async (configurator: PaymentAppConfigurator) => {
|
||||
const logger = createLogger(
|
||||
{ saleorApiUrl: configurator.saleorApiUrl },
|
||||
{ msgPrefix: "[getAllConfigEntriesObfuscated] " },
|
||||
);
|
||||
|
||||
const config = await configurator.getConfigObfuscated();
|
||||
logger.debug("Got obfuscated config");
|
||||
|
||||
return config.configurations;
|
||||
};
|
||||
|
||||
export const getAllConfigEntriesDecrypted = async (configurator: PaymentAppConfigurator) => {
|
||||
const logger = createLogger(
|
||||
{ saleorApiUrl: configurator.saleorApiUrl },
|
||||
{ msgPrefix: "[getAllConfigEntriesDecrypted] " },
|
||||
);
|
||||
|
||||
const config = await configurator.getConfig();
|
||||
logger.debug("Got config");
|
||||
|
||||
return config.configurations;
|
||||
};
|
||||
|
||||
export const getConfigEntryObfuscated = async (
|
||||
configurationId: string,
|
||||
configurator: PaymentAppConfigurator,
|
||||
) => {
|
||||
const logger = createLogger(
|
||||
{ configurationId, saleorApiUrl: configurator.saleorApiUrl },
|
||||
{ msgPrefix: "[getConfigEntryObfuscated] " },
|
||||
);
|
||||
logger.debug("Fetching all config entries");
|
||||
const entries = await getAllConfigEntriesObfuscated(configurator);
|
||||
const entry = entries.find((entry) => entry.configurationId === configurationId);
|
||||
if (!entry) {
|
||||
logger.warn("Entry was not found");
|
||||
throw new EntryNotFoundError(`Entry with id ${configurationId} was not found`);
|
||||
}
|
||||
logger.debug({ entryName: entry.configurationName }, "Found entry");
|
||||
return entry;
|
||||
};
|
||||
|
||||
export const getConfigEntryDecrypted = async (
|
||||
configurationId: string,
|
||||
configurator: PaymentAppConfigurator,
|
||||
) => {
|
||||
const logger = createLogger(
|
||||
{ configurationId, saleorApiUrl: configurator.saleorApiUrl },
|
||||
{ msgPrefix: "[getConfigEntryDecrypted] " },
|
||||
);
|
||||
|
||||
logger.debug("Fetching all config entries");
|
||||
const entries = await getAllConfigEntriesDecrypted(configurator);
|
||||
const entry = entries.find((entry) => entry.configurationId === configurationId);
|
||||
if (!entry) {
|
||||
logger.warn("Entry was not found");
|
||||
throw new EntryNotFoundError(`Entry with id ${configurationId} was not found`);
|
||||
}
|
||||
logger.debug({ entryName: entry.configurationName }, "Found entry");
|
||||
return entry;
|
||||
};
|
||||
|
||||
export const addConfigEntry = async (
|
||||
newConfigEntry: PaymentAppFormConfigEntry,
|
||||
configurator: PaymentAppConfigurator,
|
||||
appUrl: string,
|
||||
) => {
|
||||
const logger = createLogger(
|
||||
{ saleorApiUrl: configurator.saleorApiUrl },
|
||||
{ msgPrefix: "[addConfigEntry] " },
|
||||
);
|
||||
|
||||
await validateStripeKeys(newConfigEntry.secretKey, newConfigEntry.publishableKey);
|
||||
|
||||
logger.debug("Creating new webhook for config entry");
|
||||
const { webhookSecret, webhookId } = await createStripeWebhook({
|
||||
appUrl,
|
||||
secretKey: newConfigEntry.secretKey,
|
||||
saleorApiUrl: configurator.saleorApiUrl,
|
||||
configurator,
|
||||
});
|
||||
|
||||
const uuid = uuidv7();
|
||||
const config = {
|
||||
...newConfigEntry,
|
||||
webhookSecret,
|
||||
webhookId,
|
||||
configurationId: uuid,
|
||||
} satisfies PaymentAppConfigEntryFullyConfigured;
|
||||
|
||||
logger.debug({ config: redactLogObject(config) }, "Adding new config entry");
|
||||
await configurator.setConfigEntry(config);
|
||||
logger.info({ configurationId: config.configurationId }, "Config entry added");
|
||||
|
||||
return obfuscateConfigEntry(config);
|
||||
};
|
||||
|
||||
export const updateConfigEntry = async (
|
||||
input: ConfigEntryUpdate,
|
||||
configurator: PaymentAppConfigurator,
|
||||
) => {
|
||||
const logger = createLogger(
|
||||
{ saleorApiUrl: configurator.saleorApiUrl },
|
||||
{ msgPrefix: "[updateConfigEntry] " },
|
||||
);
|
||||
|
||||
const { entry, configurationId } = input;
|
||||
logger.debug("Checking if config entry with provided ID exists");
|
||||
const existingEntry = await getConfigEntryDecrypted(configurationId, configurator);
|
||||
logger.debug({ existingEntry: redactLogObject(existingEntry) }, "Found entry");
|
||||
|
||||
await configurator.setConfigEntry({
|
||||
...entry,
|
||||
configurationId,
|
||||
});
|
||||
logger.info({ configurationId }, "Config entry updated");
|
||||
|
||||
return obfuscateConfigEntry({
|
||||
...existingEntry,
|
||||
...entry,
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteConfigEntry = async (
|
||||
configurationId: string,
|
||||
configurator: PaymentAppConfigurator,
|
||||
) => {
|
||||
const logger = createLogger(
|
||||
{ configurationId, saleorApiUrl: configurator.saleorApiUrl },
|
||||
{ msgPrefix: "[deleteConfigEntry] " },
|
||||
);
|
||||
|
||||
logger.debug("Checking if config entry with provided ID exists");
|
||||
const entries = await getAllConfigEntriesDecrypted(configurator);
|
||||
const existingEntry = entries.find((entry) => entry.configurationId === configurationId);
|
||||
|
||||
if (!existingEntry) {
|
||||
logger.error({ configurationId }, "Entry was not found");
|
||||
throw new EntryNotFoundError(`Entry with id ${configurationId} was not found`);
|
||||
}
|
||||
|
||||
logger.debug({ existingEntry: redactLogObject(existingEntry) }, "Found entry");
|
||||
|
||||
logger.debug(
|
||||
{ webhookId: existingEntry.webhookId },
|
||||
"Checking if other config is using assosiated webhook",
|
||||
);
|
||||
|
||||
const otherEntries = entries.filter((entry) => entry.configurationId !== configurationId);
|
||||
const isWebhookUsed = otherEntries.some((entry) => entry.webhookId === existingEntry.webhookId);
|
||||
|
||||
if (!isWebhookUsed) {
|
||||
logger.debug("Deleting webhook linked with config entry");
|
||||
try {
|
||||
await deleteStripeWebhook({
|
||||
webhookId: existingEntry.webhookId,
|
||||
secretKey: existingEntry.secretKey,
|
||||
});
|
||||
} catch (e) {
|
||||
// Ignore error
|
||||
logger.warn(
|
||||
{ error: redactError(e), webhookId: existingEntry.webhookId },
|
||||
"Webhook couldn't be deleted with the config",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
logger.debug("Webhook linked with deleted config entry is used by other config entries");
|
||||
}
|
||||
|
||||
await configurator.deleteConfigEntry(configurationId);
|
||||
logger.info({ configurationId }, "Config entry deleted");
|
||||
};
|
||||
18
src/modules/payment-app-configuration/input-schemas.ts
Normal file
18
src/modules/payment-app-configuration/input-schemas.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { z } from "zod";
|
||||
import { paymentAppFormConfigEntrySchema } from "./config-entry";
|
||||
|
||||
export const mappingUpdate = z.object({
|
||||
channelId: z.string().min(1),
|
||||
configurationId: z.string().nullable(),
|
||||
});
|
||||
|
||||
export const paymentConfigEntryUpdate = z.object({
|
||||
configurationId: z.string().min(1),
|
||||
entry: paymentAppFormConfigEntrySchema,
|
||||
});
|
||||
|
||||
export const paymentConfigEntryDelete = z.object({ configurationId: z.string().min(1) });
|
||||
|
||||
export type MappingUpdate = z.infer<typeof mappingUpdate>;
|
||||
export type ConfigEntryUpdate = z.infer<typeof paymentConfigEntryUpdate>;
|
||||
export type ConfigEntryDelete = z.infer<typeof paymentConfigEntryDelete>;
|
||||
@@ -0,0 +1,71 @@
|
||||
/* eslint-disable @typescript-eslint/unbound-method */
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { type Client } from "urql";
|
||||
import {
|
||||
fetchChannels,
|
||||
getMappingFromAppConfig,
|
||||
setMappingInAppConfig,
|
||||
EntryDoesntExistError,
|
||||
} from "./mapping-manager";
|
||||
import { type PaymentAppConfigurator } from "./payment-app-configuration";
|
||||
import { FetchChannelsDocument } from "generated/graphql";
|
||||
|
||||
describe("fetchChannels", () => {
|
||||
it("should make a query to Saleor GraphQL endpoint and return channels", async () => {
|
||||
const mockClient = {
|
||||
query: vi.fn().mockReturnValue({
|
||||
toPromise: () => Promise.resolve({ error: null, data: { channels: [] } }),
|
||||
}),
|
||||
} as unknown as Client;
|
||||
|
||||
await fetchChannels(mockClient);
|
||||
|
||||
expect(mockClient.query).toBeCalledWith(FetchChannelsDocument, {});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMappingFromAppConfig", () => {
|
||||
it("should return correct mapping from app config", async () => {
|
||||
const mockClient = {
|
||||
query: vi.fn().mockReturnValue({
|
||||
toPromise: () => Promise.resolve({ error: null, data: { channels: [{ id: "123" }] } }),
|
||||
}),
|
||||
} as unknown as Client;
|
||||
|
||||
const mockConfigurator = {
|
||||
getConfigObfuscated: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ channelToConfigurationId: { "123": "config1" } }),
|
||||
} as unknown as PaymentAppConfigurator;
|
||||
|
||||
const result = await getMappingFromAppConfig(mockClient, mockConfigurator);
|
||||
|
||||
expect(result).toEqual({ "123": "config1" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("setMappingInAppConfig", () => {
|
||||
it("should throw error if configurationId does not exist", async () => {
|
||||
const mockConfigurator = {
|
||||
getConfig: vi.fn().mockResolvedValue({ configurations: [] }),
|
||||
} as unknown as PaymentAppConfigurator;
|
||||
|
||||
await expect(
|
||||
setMappingInAppConfig({ channelId: "123", configurationId: "nonexistent" }, mockConfigurator),
|
||||
).rejects.toThrow(EntryDoesntExistError);
|
||||
});
|
||||
|
||||
it("should call setMapping if configurationId exists", async () => {
|
||||
const mockConfigurator = {
|
||||
getConfig: vi.fn().mockResolvedValue({ configurations: [{ configurationId: "exist" }] }),
|
||||
setMapping: vi.fn(),
|
||||
getConfigObfuscated: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ channelToConfigurationId: { "123": "exist" } }),
|
||||
} as unknown as PaymentAppConfigurator;
|
||||
|
||||
await setMappingInAppConfig({ channelId: "123", configurationId: "exist" }, mockConfigurator);
|
||||
|
||||
expect(mockConfigurator.setMapping).toBeCalledWith({ "123": "exist" });
|
||||
});
|
||||
});
|
||||
90
src/modules/payment-app-configuration/mapping-manager.ts
Normal file
90
src/modules/payment-app-configuration/mapping-manager.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { type Client } from "urql";
|
||||
import { type MappingUpdate } from "./input-schemas";
|
||||
import { type PaymentAppConfigurator } from "./payment-app-configuration";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
import { BaseError, FieldError } from "@/errors";
|
||||
import { FetchChannelsDocument, type FetchChannelsQuery } from "generated/graphql";
|
||||
|
||||
export const EntryDoesntExistError = FieldError.subclass("EntryDoesntExistError", {
|
||||
props: { fieldName: "configurationId" },
|
||||
});
|
||||
|
||||
export const FetchChannelsError = BaseError.subclass("FetchChannelsError");
|
||||
|
||||
export const fetchChannels = async (client: Client) => {
|
||||
const { error, data } = await client
|
||||
.query<FetchChannelsQuery>(FetchChannelsDocument, {})
|
||||
.toPromise();
|
||||
|
||||
if (error) {
|
||||
throw new FetchChannelsError("Error while fetching channels", { cause: error });
|
||||
}
|
||||
|
||||
return data?.channels ?? [];
|
||||
};
|
||||
|
||||
export const getMappingFromAppConfig = async (
|
||||
client: Client,
|
||||
configurator: PaymentAppConfigurator,
|
||||
) => {
|
||||
const logger = createLogger(
|
||||
{ saleorApiUrl: configurator.saleorApiUrl },
|
||||
{ msgPrefix: "[getMappingFromAppConfig] " },
|
||||
);
|
||||
logger.debug("Fetching channels from Saleor and config");
|
||||
|
||||
const [channels, config] = await Promise.all([
|
||||
fetchChannels(client),
|
||||
configurator.getConfigObfuscated(),
|
||||
]);
|
||||
logger.debug({ channels: channels, config: config.channelToConfigurationId }, "Received data");
|
||||
|
||||
const emptyMapping = Object.fromEntries(channels.map((channel) => [channel.id, null]));
|
||||
logger.debug({ emptyMapping: emptyMapping }, "Prepared empty mapping");
|
||||
|
||||
return {
|
||||
...emptyMapping,
|
||||
...config.channelToConfigurationId,
|
||||
};
|
||||
};
|
||||
|
||||
export const setMappingInAppConfig = async (
|
||||
input: MappingUpdate,
|
||||
configurator: PaymentAppConfigurator,
|
||||
) => {
|
||||
const { configurationId, channelId } = input;
|
||||
const logger = createLogger(
|
||||
{ input: { configurationId, channelId }, saleorApiUrl: configurator.saleorApiUrl },
|
||||
{ msgPrefix: "[setMappingInAppConfig] " },
|
||||
);
|
||||
const config = await configurator.getConfig();
|
||||
logger.debug("Got app config");
|
||||
|
||||
const entry = config.configurations.find(
|
||||
(entry) => entry.configurationId === input.configurationId,
|
||||
);
|
||||
|
||||
if (!input.configurationId) {
|
||||
logger.info("Removing entry");
|
||||
|
||||
await configurator.setMapping({
|
||||
[input.channelId]: null,
|
||||
});
|
||||
} else if (entry) {
|
||||
logger.info("Entry with configurationId exists, updating app config");
|
||||
|
||||
await configurator.setMapping({
|
||||
[input.channelId]: input.configurationId,
|
||||
});
|
||||
} else {
|
||||
logger.error("Entry with configurationId doesn't exist");
|
||||
throw new EntryDoesntExistError(
|
||||
`Entry with configurationId ${input.configurationId} doesn't exist`,
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug("Updated app config");
|
||||
|
||||
const updatedConfig = await configurator.getConfigObfuscated();
|
||||
return updatedConfig.channelToConfigurationId;
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { type Client } from "urql";
|
||||
import { type MetadataEntry } from "@saleor/app-sdk/settings-manager";
|
||||
import {
|
||||
createPrivateSettingsManager,
|
||||
createWebhookPrivateSettingsManager,
|
||||
} from "../app-configuration/metadata-manager";
|
||||
import { PaymentAppConfigurator } from "./payment-app-configuration";
|
||||
|
||||
export const getPaymentAppConfigurator = (client: Client, saleorApiUrl: string) => {
|
||||
return new PaymentAppConfigurator(createPrivateSettingsManager(client), saleorApiUrl);
|
||||
};
|
||||
|
||||
export const getWebhookPaymentAppConfigurator = (
|
||||
data: { privateMetadata: readonly Readonly<MetadataEntry>[] },
|
||||
saleorApiUrl: string,
|
||||
) => {
|
||||
return new PaymentAppConfigurator(
|
||||
createWebhookPrivateSettingsManager(data.privateMetadata as MetadataEntry[]),
|
||||
saleorApiUrl,
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
import { z } from "zod";
|
||||
import { protectedClientProcedure } from "../trpc/protected-client-procedure";
|
||||
import { router } from "../trpc/trpc-server";
|
||||
import { channelMappingSchema, paymentAppUserVisibleConfigEntriesSchema } from "./app-config";
|
||||
import { mappingUpdate, paymentConfigEntryDelete, paymentConfigEntryUpdate } from "./input-schemas";
|
||||
import { getMappingFromAppConfig, setMappingInAppConfig } from "./mapping-manager";
|
||||
import { getPaymentAppConfigurator } from "./payment-app-configuration-factory";
|
||||
import {
|
||||
paymentAppFormConfigEntrySchema,
|
||||
paymentAppUserVisibleConfigEntrySchema,
|
||||
} from "./config-entry";
|
||||
import {
|
||||
addConfigEntry,
|
||||
deleteConfigEntry,
|
||||
getAllConfigEntriesObfuscated,
|
||||
getConfigEntryObfuscated,
|
||||
updateConfigEntry,
|
||||
} from "./config-manager";
|
||||
import { redactLogValue } from "@/lib/logger";
|
||||
import { invariant } from "@/lib/invariant";
|
||||
|
||||
export const paymentAppConfigurationRouter = router({
|
||||
mapping: router({
|
||||
getAll: protectedClientProcedure.output(channelMappingSchema).query(async ({ ctx }) => {
|
||||
ctx.logger.info("appConfigurationRouter.mapping.getAll called");
|
||||
const configurator = getPaymentAppConfigurator(ctx.apiClient, ctx.saleorApiUrl);
|
||||
return getMappingFromAppConfig(ctx.apiClient, configurator);
|
||||
}),
|
||||
update: protectedClientProcedure
|
||||
.input(mappingUpdate)
|
||||
.output(channelMappingSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { configurationId, channelId } = input;
|
||||
ctx.logger.info(
|
||||
{ configurationId, channelId },
|
||||
"appConfigurationRouter.mapping.update called",
|
||||
);
|
||||
|
||||
const configurator = getPaymentAppConfigurator(ctx.apiClient, ctx.saleorApiUrl);
|
||||
return setMappingInAppConfig(input, configurator);
|
||||
}),
|
||||
}),
|
||||
paymentConfig: router({
|
||||
get: protectedClientProcedure
|
||||
.input(z.object({ configurationId: z.string() }))
|
||||
.output(paymentAppUserVisibleConfigEntrySchema)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const { configurationId } = input;
|
||||
ctx.logger.info({ configurationId }, "appConfigurationRouter.paymentConfig.getAll called");
|
||||
|
||||
const configurator = getPaymentAppConfigurator(ctx.apiClient, ctx.saleorApiUrl);
|
||||
return getConfigEntryObfuscated(input.configurationId, configurator);
|
||||
}),
|
||||
getAll: protectedClientProcedure
|
||||
.output(paymentAppUserVisibleConfigEntriesSchema)
|
||||
.query(async ({ ctx }) => {
|
||||
ctx.logger.info("appConfigurationRouter.paymentConfig.getAll called");
|
||||
const configurator = getPaymentAppConfigurator(ctx.apiClient, ctx.saleorApiUrl);
|
||||
return getAllConfigEntriesObfuscated(configurator);
|
||||
}),
|
||||
add: protectedClientProcedure
|
||||
.input(paymentAppFormConfigEntrySchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { configurationName, secretKey } = input;
|
||||
ctx.logger.info("appConfigurationRouter.paymentConfig.add called");
|
||||
ctx.logger.debug(
|
||||
{ configurationName, secretKey: redactLogValue(secretKey) },
|
||||
"appConfigurationRouter.paymentConfig.add input",
|
||||
);
|
||||
invariant(ctx.appUrl, "Missing app url");
|
||||
|
||||
const configurator = getPaymentAppConfigurator(ctx.apiClient, ctx.saleorApiUrl);
|
||||
return addConfigEntry(input, configurator, ctx.appUrl);
|
||||
}),
|
||||
update: protectedClientProcedure
|
||||
.input(paymentConfigEntryUpdate)
|
||||
.output(paymentAppUserVisibleConfigEntrySchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { configurationId, entry } = input;
|
||||
const { configurationName, publishableKey } = entry;
|
||||
ctx.logger.info("appConfigurationRouter.paymentConfig.update called");
|
||||
ctx.logger.debug(
|
||||
{
|
||||
configurationId,
|
||||
entry: {
|
||||
publishableKey,
|
||||
configurationName,
|
||||
},
|
||||
},
|
||||
"appConfigurationRouter.paymentConfig.update input",
|
||||
);
|
||||
invariant(ctx.appUrl, "Missing app URL");
|
||||
|
||||
const configurator = getPaymentAppConfigurator(ctx.apiClient, ctx.saleorApiUrl);
|
||||
return updateConfigEntry(input, configurator);
|
||||
}),
|
||||
delete: protectedClientProcedure
|
||||
.input(paymentConfigEntryDelete)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { configurationId } = input;
|
||||
ctx.logger.info({ configurationId }, "appConfigurationRouter.paymentConfig.delete called");
|
||||
|
||||
const configurator = getPaymentAppConfigurator(ctx.apiClient, ctx.saleorApiUrl);
|
||||
return deleteConfigEntry(configurationId, configurator);
|
||||
}),
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import { encrypt, type MetadataEntry } from "@saleor/app-sdk/settings-manager";
|
||||
import {
|
||||
type GenericAppConfigurator,
|
||||
PrivateMetadataAppConfigurator,
|
||||
} from "../app-configuration/app-configuration";
|
||||
import { type BrandedEncryptedMetadataManager } from "../app-configuration/metadata-manager";
|
||||
import { type PaymentAppConfig, paymentAppConfigSchema, type ChannelMapping } from "./app-config";
|
||||
import {
|
||||
type PaymentAppConfigEntryUpdate,
|
||||
type PaymentAppConfigEntry,
|
||||
paymentAppConfigEntrySchema,
|
||||
} from "./config-entry";
|
||||
import { obfuscateConfigEntry } from "./utils";
|
||||
import { env } from "@/lib/env.mjs";
|
||||
import { BaseError } from "@/errors";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
|
||||
export const privateMetadataKey = "payment-app-config-private";
|
||||
export const hiddenMetadataKey = "payment-app-config-hidden";
|
||||
export const publicMetadataKey = "payment-app-config-public";
|
||||
|
||||
export const AppNotConfiguredError = BaseError.subclass("AppNotConfiguredError");
|
||||
|
||||
export class PaymentAppConfigurator implements GenericAppConfigurator<PaymentAppConfig> {
|
||||
private configurator: PrivateMetadataAppConfigurator<PaymentAppConfig>;
|
||||
public saleorApiUrl: string;
|
||||
|
||||
constructor(privateMetadataManager: BrandedEncryptedMetadataManager, saleorApiUrl: string) {
|
||||
this.configurator = new PrivateMetadataAppConfigurator(
|
||||
privateMetadataManager,
|
||||
saleorApiUrl,
|
||||
privateMetadataKey,
|
||||
);
|
||||
this.saleorApiUrl = saleorApiUrl;
|
||||
}
|
||||
|
||||
async getConfig(): Promise<PaymentAppConfig> {
|
||||
const config = await this.configurator.getConfig();
|
||||
return paymentAppConfigSchema.parse(config);
|
||||
}
|
||||
|
||||
async getConfigObfuscated() {
|
||||
const { configurations, channelToConfigurationId } = await this.getConfig();
|
||||
|
||||
return {
|
||||
configurations: configurations.map((entry) => obfuscateConfigEntry(entry)),
|
||||
channelToConfigurationId,
|
||||
};
|
||||
}
|
||||
|
||||
async getRawConfig(): Promise<MetadataEntry[]> {
|
||||
const encryptFn = (data: string) => encrypt(data, env.SECRET_KEY);
|
||||
|
||||
return this.configurator.getRawConfig(encryptFn);
|
||||
}
|
||||
|
||||
async getConfigEntry(configurationId: string): Promise<PaymentAppConfigEntry | null | undefined> {
|
||||
const config = await this.configurator.getConfig();
|
||||
return config?.configurations.find((entry) => entry.configurationId === configurationId);
|
||||
}
|
||||
|
||||
/** Adds new config entry or updates existing one */
|
||||
async setConfigEntry(newConfiguration: PaymentAppConfigEntryUpdate) {
|
||||
const { configurations } = await this.getConfig();
|
||||
|
||||
const existingEntryIndex = configurations.findIndex(
|
||||
(entry) => entry.configurationId === newConfiguration.configurationId,
|
||||
);
|
||||
|
||||
// Old entry = allow missing fields (they are already saved)
|
||||
if (existingEntryIndex !== -1) {
|
||||
const existingEntry = configurations[existingEntryIndex];
|
||||
const mergedEntry = {
|
||||
...existingEntry,
|
||||
...newConfiguration,
|
||||
};
|
||||
|
||||
const newConfigurations = configurations.slice(0);
|
||||
newConfigurations[existingEntryIndex] = mergedEntry;
|
||||
return this.setConfig({ configurations: newConfigurations });
|
||||
}
|
||||
|
||||
// New entry = check if valid
|
||||
const parsedConfig = paymentAppConfigEntrySchema.parse(newConfiguration);
|
||||
|
||||
return this.setConfig({
|
||||
configurations: [...configurations, parsedConfig],
|
||||
});
|
||||
}
|
||||
|
||||
async deleteConfigEntry(configurationId: string) {
|
||||
const oldConfig = await this.getConfig();
|
||||
const newConfigurations = oldConfig.configurations.filter(
|
||||
(entry) => entry.configurationId !== configurationId,
|
||||
);
|
||||
const newMappings = Object.fromEntries(
|
||||
Object.entries(oldConfig.channelToConfigurationId).filter(
|
||||
([, configId]) => configId !== configurationId,
|
||||
),
|
||||
);
|
||||
await this.setConfig(
|
||||
{ ...oldConfig, configurations: newConfigurations, channelToConfigurationId: newMappings },
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
/** Adds new mappings or updates exsting ones */
|
||||
async setMapping(newMapping: ChannelMapping) {
|
||||
const { channelToConfigurationId } = await this.getConfig();
|
||||
return this.setConfig({
|
||||
channelToConfigurationId: { ...channelToConfigurationId, ...newMapping },
|
||||
});
|
||||
}
|
||||
|
||||
async deleteMapping(channelId: string) {
|
||||
const { channelToConfigurationId } = await this.getConfig();
|
||||
const newMapping = { ...channelToConfigurationId };
|
||||
delete newMapping[channelId];
|
||||
return this.setConfig({ channelToConfigurationId: newMapping });
|
||||
}
|
||||
|
||||
/** Method that directly updates the config in MetadataConfigurator.
|
||||
* You should probably use setConfigEntry or setMapping instead */
|
||||
async setConfig(newConfig: Partial<PaymentAppConfig>, replace = false) {
|
||||
return this.configurator.setConfig(newConfig, replace);
|
||||
}
|
||||
|
||||
async clearConfig() {
|
||||
const defaultConfig = paymentAppConfigSchema.parse(undefined);
|
||||
return this.setConfig(defaultConfig, true);
|
||||
}
|
||||
}
|
||||
|
||||
export const getConfigurationForChannel = (
|
||||
appConfig: PaymentAppConfig,
|
||||
channelId?: string | undefined | null,
|
||||
) => {
|
||||
const logger = createLogger({ channelId }, { msgPrefix: "[getConfigurationForChannel] " });
|
||||
if (!channelId) {
|
||||
logger.warn("Missing channelId");
|
||||
return null;
|
||||
}
|
||||
|
||||
const configurationId = appConfig.channelToConfigurationId[channelId];
|
||||
if (!configurationId) {
|
||||
logger.warn(`Missing mapping for channelId ${channelId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const perChannelConfig = appConfig.configurations.find(
|
||||
(config) => config.configurationId === configurationId,
|
||||
);
|
||||
if (!perChannelConfig) {
|
||||
logger.warn({ configurationId }, "Missing configuration for configurationId");
|
||||
return null;
|
||||
}
|
||||
return perChannelConfig;
|
||||
};
|
||||
25
src/modules/payment-app-configuration/utils.ts
Normal file
25
src/modules/payment-app-configuration/utils.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { obfuscateConfig } from "../app-configuration/utils";
|
||||
import {
|
||||
type PaymentAppConfigEntry,
|
||||
type PaymentAppEncryptedConfig,
|
||||
type PaymentAppUserVisibleConfigEntry,
|
||||
paymentAppUserVisibleConfigEntrySchema,
|
||||
} from "./config-entry";
|
||||
|
||||
export const obfuscateConfigEntry = (
|
||||
entry: PaymentAppConfigEntry | PaymentAppUserVisibleConfigEntry,
|
||||
): PaymentAppUserVisibleConfigEntry => {
|
||||
const { secretKey, publishableKey, configurationName, configurationId, webhookId } = entry;
|
||||
|
||||
const configValuesToObfuscate = {
|
||||
secretKey,
|
||||
} satisfies PaymentAppEncryptedConfig;
|
||||
|
||||
return paymentAppUserVisibleConfigEntrySchema.parse({
|
||||
publishableKey,
|
||||
configurationId,
|
||||
configurationName,
|
||||
webhookId,
|
||||
...obfuscateConfig(configValuesToObfuscate),
|
||||
} satisfies PaymentAppUserVisibleConfigEntry);
|
||||
};
|
||||
154
src/modules/payment-app-configuration/webhook-manager.ts
Normal file
154
src/modules/payment-app-configuration/webhook-manager.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { type Stripe } from "stripe";
|
||||
import { getStripeApiClient } from "../stripe/stripe-api";
|
||||
import { type PaymentAppConfigurator } from "./payment-app-configuration";
|
||||
import { invariant } from "@/lib/invariant";
|
||||
import { createLogger, redactLogObject } from "@/lib/logger";
|
||||
|
||||
const stripeWebhookEndpointRoute = "/api/webhooks/stripe";
|
||||
|
||||
const enabledEvents: Array<Stripe.WebhookEndpointCreateParams.EnabledEvent> = [
|
||||
"payment_intent.created",
|
||||
"payment_intent.canceled",
|
||||
"payment_intent.succeeded",
|
||||
"payment_intent.processing",
|
||||
"payment_intent.payment_failed",
|
||||
"payment_intent.requires_action",
|
||||
"payment_intent.partially_funded",
|
||||
"payment_intent.amount_capturable_updated",
|
||||
"charge.refund.updated",
|
||||
"charge.refunded",
|
||||
];
|
||||
|
||||
const getWebhookUrl = (appUrl: string, saleorApiUrl: string): string => {
|
||||
const url = new URL(appUrl);
|
||||
url.pathname = stripeWebhookEndpointRoute;
|
||||
url.searchParams.set("saleorApiUrl", saleorApiUrl);
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
interface StripeWebhookResult {
|
||||
webhookSecret: string;
|
||||
webhookId: string;
|
||||
}
|
||||
|
||||
export const createStripeWebhook = async ({
|
||||
appUrl,
|
||||
saleorApiUrl,
|
||||
secretKey,
|
||||
configurator,
|
||||
}: {
|
||||
appUrl: string;
|
||||
saleorApiUrl: string;
|
||||
secretKey: string;
|
||||
configurator: PaymentAppConfigurator;
|
||||
}): Promise<StripeWebhookResult> => {
|
||||
const logger = createLogger({ saleorApiUrl, appUrl }, { msgPrefix: "[createStripeWebhook] " });
|
||||
const stripe = getStripeApiClient(secretKey);
|
||||
|
||||
const url = getWebhookUrl(appUrl, saleorApiUrl);
|
||||
|
||||
const existingStripeWebhook = await findExistingWebhook({ appUrl, saleorApiUrl, secretKey });
|
||||
if (existingStripeWebhook) {
|
||||
const existingAppWebhook = await checkWebhookUsage({
|
||||
webhookId: existingStripeWebhook.id,
|
||||
configurator,
|
||||
});
|
||||
|
||||
if (existingAppWebhook) {
|
||||
// There's already a webhook for this app, so we can just
|
||||
return existingAppWebhook;
|
||||
}
|
||||
|
||||
// We cannot retrieve webhook secret after it was created, so we need to delete it and create a new one
|
||||
await deleteStripeWebhook({ webhookId: existingStripeWebhook.id, secretKey });
|
||||
}
|
||||
|
||||
logger.debug({ url }, "Creating stripe webhook");
|
||||
const stripeWebhook = await stripe.webhookEndpoints.create({
|
||||
url,
|
||||
enabled_events: enabledEvents,
|
||||
description: "Saleor Stripe App",
|
||||
});
|
||||
|
||||
logger.debug({ webhook: redactLogObject(stripeWebhook) }, "Webhook created");
|
||||
const { secret, id } = stripeWebhook;
|
||||
invariant(secret, "Missing webhook secret");
|
||||
|
||||
return {
|
||||
webhookSecret: secret,
|
||||
webhookId: id,
|
||||
};
|
||||
};
|
||||
|
||||
export const findExistingWebhook = async ({
|
||||
appUrl,
|
||||
saleorApiUrl,
|
||||
secretKey,
|
||||
}: {
|
||||
appUrl: string;
|
||||
saleorApiUrl: string;
|
||||
secretKey: string;
|
||||
}) => {
|
||||
const logger = createLogger({ saleorApiUrl, appUrl }, { msgPrefix: "[findExistingWebhook] " });
|
||||
const stripe = getStripeApiClient(secretKey);
|
||||
|
||||
const url = getWebhookUrl(appUrl, saleorApiUrl);
|
||||
logger.debug({ url }, "Finding existing stripe webhook");
|
||||
|
||||
const webhooks = await stripe.webhookEndpoints.list();
|
||||
logger.debug({ webhooksLength: webhooks.data.length }, "Found webhooks");
|
||||
|
||||
const existingWebhook = webhooks.data.find((webhook) => webhook.url === url);
|
||||
if (existingWebhook) {
|
||||
logger.debug({ webhook: redactLogObject(existingWebhook) }, "Found existing webhook");
|
||||
}
|
||||
|
||||
return existingWebhook;
|
||||
};
|
||||
|
||||
export const deleteStripeWebhook = async ({
|
||||
webhookId,
|
||||
secretKey,
|
||||
}: {
|
||||
webhookId: string;
|
||||
secretKey: string;
|
||||
}) => {
|
||||
const logger = createLogger({ webhookId }, { msgPrefix: "[deleteStripeWebhook] " });
|
||||
const stripe = getStripeApiClient(secretKey);
|
||||
|
||||
logger.debug("Deleting stripe webhook");
|
||||
await stripe.webhookEndpoints.del(webhookId);
|
||||
logger.debug("Webhook was deleted");
|
||||
};
|
||||
|
||||
export const checkWebhookUsage = async ({
|
||||
webhookId,
|
||||
configurator,
|
||||
}: {
|
||||
webhookId: string;
|
||||
configurator: PaymentAppConfigurator;
|
||||
}) => {
|
||||
const logger = createLogger(
|
||||
{ webhookId, saleorApiUrl: configurator.saleorApiUrl },
|
||||
{ msgPrefix: "[checkWebhookUsage] " },
|
||||
);
|
||||
|
||||
logger.debug("Fetching all config entries");
|
||||
|
||||
const config = await configurator.getConfig();
|
||||
const entries = config.configurations;
|
||||
|
||||
logger.debug("Got all entries");
|
||||
const entry = entries.find((entry) => entry.webhookId === webhookId);
|
||||
|
||||
if (entry) {
|
||||
logger.debug("Found entry with matching webhook id");
|
||||
return {
|
||||
webhookId: entry.webhookId,
|
||||
webhookSecret: entry.webhookSecret,
|
||||
};
|
||||
}
|
||||
|
||||
logger.debug("Entry with matching webhook id was not found");
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,388 @@
|
||||
{
|
||||
"log": {
|
||||
"_recordingName": "stripe-api/validateStripeKeys/not throw error if both keys are correct",
|
||||
"creator": {
|
||||
"comment": "persister:fs",
|
||||
"name": "Polly.JS",
|
||||
"version": "6.0.5"
|
||||
},
|
||||
"entries": [
|
||||
{
|
||||
"_id": "740cb060792366c95c70efe3e6fefb52",
|
||||
"_order": 0,
|
||||
"cache": {},
|
||||
"request": {
|
||||
"bodySize": 0,
|
||||
"cookies": [],
|
||||
"headers": [
|
||||
{
|
||||
"name": "accept",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"name": "content-type",
|
||||
"value": "application/x-www-form-urlencoded"
|
||||
},
|
||||
{
|
||||
"name": "stripe-version",
|
||||
"value": "2023-10-16"
|
||||
},
|
||||
{
|
||||
"name": "user-agent",
|
||||
"value": "Stripe/v1 NodeBindings/14.8.0"
|
||||
}
|
||||
],
|
||||
"headersSize": 727,
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"method": "GET",
|
||||
"queryString": [
|
||||
{
|
||||
"name": "limit",
|
||||
"value": "1"
|
||||
}
|
||||
],
|
||||
"url": "https://api.stripe.com/v1/payment_intents?limit=1"
|
||||
},
|
||||
"response": {
|
||||
"bodySize": 2160,
|
||||
"content": {
|
||||
"mimeType": "application/json",
|
||||
"size": 2160,
|
||||
"text": {
|
||||
"data": [
|
||||
{
|
||||
"amount": 22299,
|
||||
"amount_capturable": 0,
|
||||
"amount_details": {
|
||||
"tip": {}
|
||||
},
|
||||
"amount_received": 0,
|
||||
"application": null,
|
||||
"application_fee_amount": null,
|
||||
"automatic_payment_methods": {
|
||||
"allow_redirects": "always",
|
||||
"enabled": true
|
||||
},
|
||||
"canceled_at": null,
|
||||
"cancellation_reason": null,
|
||||
"capture_method": "manual",
|
||||
"client_secret": "pi_3OMtDMEosEcNBN5m1qOegbpT_secret_qhCRYBSNepMmBNyqaPAg14y5R",
|
||||
"confirmation_method": "automatic",
|
||||
"created": 1702476908,
|
||||
"currency": "pln",
|
||||
"customer": null,
|
||||
"description": null,
|
||||
"id": "pi_3OMtDMEosEcNBN5m1qOegbpT",
|
||||
"invoice": null,
|
||||
"last_payment_error": null,
|
||||
"latest_charge": null,
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
"channelId": "1",
|
||||
"checkoutId": "c29tZS1jaGVja291dC1pZA==",
|
||||
"transactionId": "555555"
|
||||
},
|
||||
"next_action": null,
|
||||
"object": "payment_intent",
|
||||
"on_behalf_of": null,
|
||||
"payment_method": null,
|
||||
"payment_method_configuration_details": {
|
||||
"id": "pmc_1LVZxMEosEcNBN5manO2iTW7",
|
||||
"parent": null
|
||||
},
|
||||
"payment_method_options": {
|
||||
"card": {
|
||||
"installments": null,
|
||||
"mandate_options": null,
|
||||
"network": null,
|
||||
"request_three_d_secure": "automatic"
|
||||
},
|
||||
"klarna": {
|
||||
"preferred_locale": null
|
||||
},
|
||||
"link": {
|
||||
"persistent_token": null
|
||||
},
|
||||
"paypal": {
|
||||
"preferred_locale": null,
|
||||
"reference": null
|
||||
}
|
||||
},
|
||||
"payment_method_types": [
|
||||
"card",
|
||||
"link",
|
||||
"klarna",
|
||||
"paypal"
|
||||
],
|
||||
"processing": null,
|
||||
"receipt_email": null,
|
||||
"review": null,
|
||||
"setup_future_usage": null,
|
||||
"shipping": null,
|
||||
"source": null,
|
||||
"statement_descriptor": null,
|
||||
"statement_descriptor_suffix": null,
|
||||
"status": "requires_payment_method",
|
||||
"transfer_data": null,
|
||||
"transfer_group": null
|
||||
}
|
||||
],
|
||||
"has_more": true,
|
||||
"object": "list",
|
||||
"url": "/v1/payment_intents"
|
||||
}
|
||||
},
|
||||
"cookies": [],
|
||||
"headers": [
|
||||
{
|
||||
"name": "access-control-allow-credentials",
|
||||
"value": "true"
|
||||
},
|
||||
{
|
||||
"name": "access-control-allow-methods",
|
||||
"value": "GET,HEAD,PUT,PATCH,POST,DELETE"
|
||||
},
|
||||
{
|
||||
"name": "access-control-allow-origin",
|
||||
"value": "*"
|
||||
},
|
||||
{
|
||||
"name": "access-control-expose-headers",
|
||||
"value": "Request-Id, Stripe-Manage-Version, Stripe-Should-Retry, X-Stripe-External-Auth-Required, X-Stripe-Privileged-Session-Required"
|
||||
},
|
||||
{
|
||||
"name": "access-control-max-age",
|
||||
"value": "300"
|
||||
},
|
||||
{
|
||||
"name": "cache-control",
|
||||
"value": "no-cache, no-store"
|
||||
},
|
||||
{
|
||||
"name": "connection",
|
||||
"value": "keep-alive"
|
||||
},
|
||||
{
|
||||
"name": "content-length",
|
||||
"value": "2160"
|
||||
},
|
||||
{
|
||||
"name": "content-security-policy",
|
||||
"value": "report-uri https://q.stripe.com/csp-report?p=v1%2Fpayment_intents; block-all-mixed-content; default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; img-src 'self'; script-src 'self' 'report-sample'; style-src 'self'"
|
||||
},
|
||||
{
|
||||
"name": "content-type",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"name": "date",
|
||||
"value": "Wed, 13 Dec 2023 14:15:08 GMT"
|
||||
},
|
||||
{
|
||||
"name": "request-id",
|
||||
"value": "req_0R5WBWYzQ2M6Jx"
|
||||
},
|
||||
{
|
||||
"name": "server",
|
||||
"value": "nginx"
|
||||
},
|
||||
{
|
||||
"name": "strict-transport-security",
|
||||
"value": "max-age=63072000; includeSubDomains; preload"
|
||||
},
|
||||
{
|
||||
"name": "stripe-version",
|
||||
"value": "2023-10-16"
|
||||
},
|
||||
{
|
||||
"name": "vary",
|
||||
"value": "Origin"
|
||||
},
|
||||
{
|
||||
"name": "x-stripe-routing-context-priority-tier",
|
||||
"value": "api-testmode"
|
||||
}
|
||||
],
|
||||
"headersSize": 956,
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"redirectURL": "",
|
||||
"status": 200,
|
||||
"statusText": "OK"
|
||||
},
|
||||
"startedDateTime": "2023-12-13T14:15:07.789Z",
|
||||
"time": 354,
|
||||
"timings": {
|
||||
"blocked": -1,
|
||||
"connect": -1,
|
||||
"dns": -1,
|
||||
"receive": 0,
|
||||
"send": 0,
|
||||
"ssl": -1,
|
||||
"wait": 354
|
||||
}
|
||||
},
|
||||
{
|
||||
"_id": "b402ed8a49dac7e2becb92999f179d8b",
|
||||
"_order": 0,
|
||||
"cache": {},
|
||||
"request": {
|
||||
"bodySize": 19,
|
||||
"cookies": [],
|
||||
"headers": [
|
||||
{
|
||||
"name": "accept",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"name": "content-length",
|
||||
"value": "19"
|
||||
},
|
||||
{
|
||||
"name": "content-type",
|
||||
"value": "application/x-www-form-urlencoded"
|
||||
},
|
||||
{
|
||||
"name": "idempotency-key",
|
||||
"value": "stripe-node-retry-2444489d-914d-414c-844c-ff45f9c06213"
|
||||
},
|
||||
{
|
||||
"name": "stripe-version",
|
||||
"value": "2023-10-16"
|
||||
},
|
||||
{
|
||||
"name": "user-agent",
|
||||
"value": "Stripe/v1 NodeBindings/14.8.0"
|
||||
}
|
||||
],
|
||||
"headersSize": 804,
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"method": "POST",
|
||||
"postData": {
|
||||
"mimeType": "application/x-www-form-urlencoded",
|
||||
"params": [],
|
||||
"text": "pii[id_number]=test"
|
||||
},
|
||||
"queryString": [],
|
||||
"url": "https://api.stripe.com/v1/tokens"
|
||||
},
|
||||
"response": {
|
||||
"bodySize": 175,
|
||||
"content": {
|
||||
"mimeType": "application/json",
|
||||
"size": 175,
|
||||
"text": {
|
||||
"client_ip": "31.61.175.128",
|
||||
"created": 1702476908,
|
||||
"id": "pii_1OMtDMEosEcNBN5mbeBnLtHv",
|
||||
"livemode": false,
|
||||
"object": "token",
|
||||
"type": "pii",
|
||||
"used": false
|
||||
}
|
||||
},
|
||||
"cookies": [],
|
||||
"headers": [
|
||||
{
|
||||
"name": "access-control-allow-credentials",
|
||||
"value": "true"
|
||||
},
|
||||
{
|
||||
"name": "access-control-allow-methods",
|
||||
"value": "GET,HEAD,PUT,PATCH,POST,DELETE"
|
||||
},
|
||||
{
|
||||
"name": "access-control-allow-origin",
|
||||
"value": "*"
|
||||
},
|
||||
{
|
||||
"name": "access-control-expose-headers",
|
||||
"value": "Request-Id, Stripe-Manage-Version, Stripe-Should-Retry, X-Stripe-External-Auth-Required, X-Stripe-Privileged-Session-Required"
|
||||
},
|
||||
{
|
||||
"name": "access-control-max-age",
|
||||
"value": "300"
|
||||
},
|
||||
{
|
||||
"name": "cache-control",
|
||||
"value": "no-cache, no-store"
|
||||
},
|
||||
{
|
||||
"name": "connection",
|
||||
"value": "keep-alive"
|
||||
},
|
||||
{
|
||||
"name": "content-length",
|
||||
"value": "175"
|
||||
},
|
||||
{
|
||||
"name": "content-security-policy",
|
||||
"value": "report-uri https://q.stripe.com/csp-report?p=v1%2Ftokens; block-all-mixed-content; default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; img-src 'self'; script-src 'self' 'report-sample'; style-src 'self'"
|
||||
},
|
||||
{
|
||||
"name": "content-type",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"name": "date",
|
||||
"value": "Wed, 13 Dec 2023 14:15:08 GMT"
|
||||
},
|
||||
{
|
||||
"name": "idempotency-key",
|
||||
"value": "stripe-node-retry-2444489d-914d-414c-844c-ff45f9c06213"
|
||||
},
|
||||
{
|
||||
"name": "original-request",
|
||||
"value": "req_o4EjTS3oNbs3Vt"
|
||||
},
|
||||
{
|
||||
"name": "request-id",
|
||||
"value": "req_o4EjTS3oNbs3Vt"
|
||||
},
|
||||
{
|
||||
"name": "server",
|
||||
"value": "nginx"
|
||||
},
|
||||
{
|
||||
"name": "strict-transport-security",
|
||||
"value": "max-age=63072000; includeSubDomains; preload"
|
||||
},
|
||||
{
|
||||
"name": "stripe-should-retry",
|
||||
"value": "false"
|
||||
},
|
||||
{
|
||||
"name": "stripe-version",
|
||||
"value": "2023-10-16"
|
||||
},
|
||||
{
|
||||
"name": "vary",
|
||||
"value": "Origin"
|
||||
},
|
||||
{
|
||||
"name": "x-stripe-routing-context-priority-tier",
|
||||
"value": "api-testmode"
|
||||
}
|
||||
],
|
||||
"headersSize": 1085,
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"redirectURL": "",
|
||||
"status": 200,
|
||||
"statusText": "OK"
|
||||
},
|
||||
"startedDateTime": "2023-12-13T14:15:08.145Z",
|
||||
"time": 390,
|
||||
"timings": {
|
||||
"blocked": -1,
|
||||
"connect": -1,
|
||||
"dns": -1,
|
||||
"receive": 0,
|
||||
"send": 0,
|
||||
"ssl": -1,
|
||||
"wait": 390
|
||||
}
|
||||
}
|
||||
],
|
||||
"pages": [],
|
||||
"version": "1.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
{
|
||||
"log": {
|
||||
"_recordingName": "stripe-api/validateStripeKeys/should throw error if both keys are invalid",
|
||||
"creator": {
|
||||
"comment": "persister:fs",
|
||||
"name": "Polly.JS",
|
||||
"version": "6.0.5"
|
||||
},
|
||||
"entries": [
|
||||
{
|
||||
"_id": "90920035c265c2be49ed00c29eb902c0",
|
||||
"_order": 0,
|
||||
"cache": {},
|
||||
"request": {
|
||||
"bodySize": 0,
|
||||
"cookies": [],
|
||||
"headers": [
|
||||
{
|
||||
"name": "accept",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"name": "content-type",
|
||||
"value": "application/x-www-form-urlencoded"
|
||||
},
|
||||
{
|
||||
"name": "stripe-version",
|
||||
"value": "2023-10-16"
|
||||
},
|
||||
{
|
||||
"name": "user-agent",
|
||||
"value": "Stripe/v1 NodeBindings/14.8.0"
|
||||
}
|
||||
],
|
||||
"headersSize": 626,
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"method": "GET",
|
||||
"queryString": [
|
||||
{
|
||||
"name": "limit",
|
||||
"value": "1"
|
||||
}
|
||||
],
|
||||
"url": "https://api.stripe.com/v1/payment_intents?limit=1"
|
||||
},
|
||||
"response": {
|
||||
"bodySize": 108,
|
||||
"content": {
|
||||
"mimeType": "application/json",
|
||||
"size": 108,
|
||||
"text": {
|
||||
"error": {
|
||||
"message": "Invalid API Key provided: blabla",
|
||||
"type": "invalid_request_error"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cookies": [],
|
||||
"headers": [
|
||||
{
|
||||
"name": "access-control-allow-credentials",
|
||||
"value": "true"
|
||||
},
|
||||
{
|
||||
"name": "access-control-allow-methods",
|
||||
"value": "GET,HEAD,PUT,PATCH,POST,DELETE"
|
||||
},
|
||||
{
|
||||
"name": "access-control-allow-origin",
|
||||
"value": "*"
|
||||
},
|
||||
{
|
||||
"name": "access-control-expose-headers",
|
||||
"value": "Request-Id, Stripe-Manage-Version, Stripe-Should-Retry, X-Stripe-External-Auth-Required, X-Stripe-Privileged-Session-Required"
|
||||
},
|
||||
{
|
||||
"name": "access-control-max-age",
|
||||
"value": "300"
|
||||
},
|
||||
{
|
||||
"name": "cache-control",
|
||||
"value": "no-cache, no-store"
|
||||
},
|
||||
{
|
||||
"name": "connection",
|
||||
"value": "keep-alive"
|
||||
},
|
||||
{
|
||||
"name": "content-length",
|
||||
"value": "108"
|
||||
},
|
||||
{
|
||||
"name": "content-type",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"name": "date",
|
||||
"value": "Wed, 13 Dec 2023 14:15:07 GMT"
|
||||
},
|
||||
{
|
||||
"name": "server",
|
||||
"value": "nginx"
|
||||
},
|
||||
{
|
||||
"name": "strict-transport-security",
|
||||
"value": "max-age=63072000; includeSubDomains; preload"
|
||||
},
|
||||
{
|
||||
"name": "vary",
|
||||
"value": "Origin"
|
||||
},
|
||||
{
|
||||
"name": "www-authenticate",
|
||||
"value": "Bearer realm=\"Stripe\""
|
||||
}
|
||||
],
|
||||
"headersSize": 615,
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"redirectURL": "",
|
||||
"status": 401,
|
||||
"statusText": "Unauthorized"
|
||||
},
|
||||
"startedDateTime": "2023-12-13T14:15:07.688Z",
|
||||
"time": 97,
|
||||
"timings": {
|
||||
"blocked": -1,
|
||||
"connect": -1,
|
||||
"dns": -1,
|
||||
"receive": 0,
|
||||
"send": 0,
|
||||
"ssl": -1,
|
||||
"wait": 97
|
||||
}
|
||||
}
|
||||
],
|
||||
"pages": [],
|
||||
"version": "1.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
{
|
||||
"log": {
|
||||
"_recordingName": "stripe-api/validateStripeKeys/should throw error if publishable key is invalid",
|
||||
"creator": {
|
||||
"comment": "persister:fs",
|
||||
"name": "Polly.JS",
|
||||
"version": "6.0.5"
|
||||
},
|
||||
"entries": [
|
||||
{
|
||||
"_id": "740cb060792366c95c70efe3e6fefb52",
|
||||
"_order": 0,
|
||||
"cache": {},
|
||||
"request": {
|
||||
"bodySize": 0,
|
||||
"cookies": [],
|
||||
"headers": [
|
||||
{
|
||||
"name": "accept",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"name": "content-type",
|
||||
"value": "application/x-www-form-urlencoded"
|
||||
},
|
||||
{
|
||||
"name": "stripe-version",
|
||||
"value": "2023-10-16"
|
||||
},
|
||||
{
|
||||
"name": "user-agent",
|
||||
"value": "Stripe/v1 NodeBindings/14.8.0"
|
||||
}
|
||||
],
|
||||
"headersSize": 727,
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"method": "GET",
|
||||
"queryString": [
|
||||
{
|
||||
"name": "limit",
|
||||
"value": "1"
|
||||
}
|
||||
],
|
||||
"url": "https://api.stripe.com/v1/payment_intents?limit=1"
|
||||
},
|
||||
"response": {
|
||||
"bodySize": 2160,
|
||||
"content": {
|
||||
"mimeType": "application/json",
|
||||
"size": 2160,
|
||||
"text": {
|
||||
"data": [
|
||||
{
|
||||
"amount": 22299,
|
||||
"amount_capturable": 0,
|
||||
"amount_details": {
|
||||
"tip": {}
|
||||
},
|
||||
"amount_received": 0,
|
||||
"application": null,
|
||||
"application_fee_amount": null,
|
||||
"automatic_payment_methods": {
|
||||
"allow_redirects": "always",
|
||||
"enabled": true
|
||||
},
|
||||
"canceled_at": null,
|
||||
"cancellation_reason": null,
|
||||
"capture_method": "manual",
|
||||
"client_secret": "pi_3OMtDLEosEcNBN5m11wpQjbx_secret_cUiIxYoObEAywmhr0vL1Tz9Vj",
|
||||
"confirmation_method": "automatic",
|
||||
"created": 1702476907,
|
||||
"currency": "pln",
|
||||
"customer": null,
|
||||
"description": null,
|
||||
"id": "pi_3OMtDLEosEcNBN5m11wpQjbx",
|
||||
"invoice": null,
|
||||
"last_payment_error": null,
|
||||
"latest_charge": null,
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
"channelId": "1",
|
||||
"checkoutId": "c29tZS1jaGVja291dC1pZA==",
|
||||
"transactionId": "555555"
|
||||
},
|
||||
"next_action": null,
|
||||
"object": "payment_intent",
|
||||
"on_behalf_of": null,
|
||||
"payment_method": null,
|
||||
"payment_method_configuration_details": {
|
||||
"id": "pmc_1LVZxMEosEcNBN5manO2iTW7",
|
||||
"parent": null
|
||||
},
|
||||
"payment_method_options": {
|
||||
"card": {
|
||||
"installments": null,
|
||||
"mandate_options": null,
|
||||
"network": null,
|
||||
"request_three_d_secure": "automatic"
|
||||
},
|
||||
"klarna": {
|
||||
"preferred_locale": null
|
||||
},
|
||||
"link": {
|
||||
"persistent_token": null
|
||||
},
|
||||
"paypal": {
|
||||
"preferred_locale": null,
|
||||
"reference": null
|
||||
}
|
||||
},
|
||||
"payment_method_types": [
|
||||
"card",
|
||||
"link",
|
||||
"klarna",
|
||||
"paypal"
|
||||
],
|
||||
"processing": null,
|
||||
"receipt_email": null,
|
||||
"review": null,
|
||||
"setup_future_usage": null,
|
||||
"shipping": null,
|
||||
"source": null,
|
||||
"statement_descriptor": null,
|
||||
"statement_descriptor_suffix": null,
|
||||
"status": "requires_payment_method",
|
||||
"transfer_data": null,
|
||||
"transfer_group": null
|
||||
}
|
||||
],
|
||||
"has_more": true,
|
||||
"object": "list",
|
||||
"url": "/v1/payment_intents"
|
||||
}
|
||||
},
|
||||
"cookies": [],
|
||||
"headers": [
|
||||
{
|
||||
"name": "access-control-allow-credentials",
|
||||
"value": "true"
|
||||
},
|
||||
{
|
||||
"name": "access-control-allow-methods",
|
||||
"value": "GET,HEAD,PUT,PATCH,POST,DELETE"
|
||||
},
|
||||
{
|
||||
"name": "access-control-allow-origin",
|
||||
"value": "*"
|
||||
},
|
||||
{
|
||||
"name": "access-control-expose-headers",
|
||||
"value": "Request-Id, Stripe-Manage-Version, Stripe-Should-Retry, X-Stripe-External-Auth-Required, X-Stripe-Privileged-Session-Required"
|
||||
},
|
||||
{
|
||||
"name": "access-control-max-age",
|
||||
"value": "300"
|
||||
},
|
||||
{
|
||||
"name": "cache-control",
|
||||
"value": "no-cache, no-store"
|
||||
},
|
||||
{
|
||||
"name": "connection",
|
||||
"value": "keep-alive"
|
||||
},
|
||||
{
|
||||
"name": "content-length",
|
||||
"value": "2160"
|
||||
},
|
||||
{
|
||||
"name": "content-security-policy",
|
||||
"value": "report-uri https://q.stripe.com/csp-report?p=v1%2Fpayment_intents; block-all-mixed-content; default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; img-src 'self'; script-src 'self' 'report-sample'; style-src 'self'"
|
||||
},
|
||||
{
|
||||
"name": "content-type",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"name": "date",
|
||||
"value": "Wed, 13 Dec 2023 14:15:07 GMT"
|
||||
},
|
||||
{
|
||||
"name": "request-id",
|
||||
"value": "req_lQkucXGjdauYJA"
|
||||
},
|
||||
{
|
||||
"name": "server",
|
||||
"value": "nginx"
|
||||
},
|
||||
{
|
||||
"name": "strict-transport-security",
|
||||
"value": "max-age=63072000; includeSubDomains; preload"
|
||||
},
|
||||
{
|
||||
"name": "stripe-version",
|
||||
"value": "2023-10-16"
|
||||
},
|
||||
{
|
||||
"name": "vary",
|
||||
"value": "Origin"
|
||||
},
|
||||
{
|
||||
"name": "x-stripe-routing-context-priority-tier",
|
||||
"value": "api-testmode"
|
||||
}
|
||||
],
|
||||
"headersSize": 956,
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"redirectURL": "",
|
||||
"status": 200,
|
||||
"statusText": "OK"
|
||||
},
|
||||
"startedDateTime": "2023-12-13T14:15:07.056Z",
|
||||
"time": 530,
|
||||
"timings": {
|
||||
"blocked": -1,
|
||||
"connect": -1,
|
||||
"dns": -1,
|
||||
"receive": 0,
|
||||
"send": 0,
|
||||
"ssl": -1,
|
||||
"wait": 530
|
||||
}
|
||||
},
|
||||
{
|
||||
"_id": "990365e93b996a79997b70fe3ca6c55e",
|
||||
"_order": 0,
|
||||
"cache": {},
|
||||
"request": {
|
||||
"bodySize": 19,
|
||||
"cookies": [],
|
||||
"headers": [
|
||||
{
|
||||
"name": "accept",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"name": "content-length",
|
||||
"value": "19"
|
||||
},
|
||||
{
|
||||
"name": "content-type",
|
||||
"value": "application/x-www-form-urlencoded"
|
||||
},
|
||||
{
|
||||
"name": "idempotency-key",
|
||||
"value": "stripe-node-retry-de7e2360-cf92-42c3-b03f-65c74d1f65bd"
|
||||
},
|
||||
{
|
||||
"name": "stripe-version",
|
||||
"value": "2023-10-16"
|
||||
},
|
||||
{
|
||||
"name": "user-agent",
|
||||
"value": "Stripe/v1 NodeBindings/14.8.0"
|
||||
}
|
||||
],
|
||||
"headersSize": 703,
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"method": "POST",
|
||||
"postData": {
|
||||
"mimeType": "application/x-www-form-urlencoded",
|
||||
"params": [],
|
||||
"text": "pii[id_number]=test"
|
||||
},
|
||||
"queryString": [],
|
||||
"url": "https://api.stripe.com/v1/tokens"
|
||||
},
|
||||
"response": {
|
||||
"bodySize": 108,
|
||||
"content": {
|
||||
"mimeType": "application/json",
|
||||
"size": 108,
|
||||
"text": {
|
||||
"error": {
|
||||
"message": "Invalid API Key provided: blabla",
|
||||
"type": "invalid_request_error"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cookies": [],
|
||||
"headers": [
|
||||
{
|
||||
"name": "access-control-allow-credentials",
|
||||
"value": "true"
|
||||
},
|
||||
{
|
||||
"name": "access-control-allow-methods",
|
||||
"value": "GET,HEAD,PUT,PATCH,POST,DELETE"
|
||||
},
|
||||
{
|
||||
"name": "access-control-allow-origin",
|
||||
"value": "*"
|
||||
},
|
||||
{
|
||||
"name": "access-control-expose-headers",
|
||||
"value": "Request-Id, Stripe-Manage-Version, Stripe-Should-Retry, X-Stripe-External-Auth-Required, X-Stripe-Privileged-Session-Required"
|
||||
},
|
||||
{
|
||||
"name": "access-control-max-age",
|
||||
"value": "300"
|
||||
},
|
||||
{
|
||||
"name": "cache-control",
|
||||
"value": "no-cache, no-store"
|
||||
},
|
||||
{
|
||||
"name": "connection",
|
||||
"value": "keep-alive"
|
||||
},
|
||||
{
|
||||
"name": "content-length",
|
||||
"value": "108"
|
||||
},
|
||||
{
|
||||
"name": "content-type",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"name": "date",
|
||||
"value": "Wed, 13 Dec 2023 14:15:07 GMT"
|
||||
},
|
||||
{
|
||||
"name": "server",
|
||||
"value": "nginx"
|
||||
},
|
||||
{
|
||||
"name": "strict-transport-security",
|
||||
"value": "max-age=63072000; includeSubDomains; preload"
|
||||
},
|
||||
{
|
||||
"name": "vary",
|
||||
"value": "Origin"
|
||||
},
|
||||
{
|
||||
"name": "www-authenticate",
|
||||
"value": "Bearer realm=\"Stripe\""
|
||||
}
|
||||
],
|
||||
"headersSize": 615,
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"redirectURL": "",
|
||||
"status": 401,
|
||||
"statusText": "Unauthorized"
|
||||
},
|
||||
"startedDateTime": "2023-12-13T14:15:07.589Z",
|
||||
"time": 91,
|
||||
"timings": {
|
||||
"blocked": -1,
|
||||
"connect": -1,
|
||||
"dns": -1,
|
||||
"receive": 0,
|
||||
"send": 0,
|
||||
"ssl": -1,
|
||||
"wait": 91
|
||||
}
|
||||
}
|
||||
],
|
||||
"pages": [],
|
||||
"version": "1.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
{
|
||||
"log": {
|
||||
"_recordingName": "stripe-api/validateStripeKeys/should throw error if secret key is invalid",
|
||||
"creator": {
|
||||
"comment": "persister:fs",
|
||||
"name": "Polly.JS",
|
||||
"version": "6.0.5"
|
||||
},
|
||||
"entries": [
|
||||
{
|
||||
"_id": "90920035c265c2be49ed00c29eb902c0",
|
||||
"_order": 0,
|
||||
"cache": {},
|
||||
"request": {
|
||||
"bodySize": 0,
|
||||
"cookies": [],
|
||||
"headers": [
|
||||
{
|
||||
"name": "accept",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"name": "content-type",
|
||||
"value": "application/x-www-form-urlencoded"
|
||||
},
|
||||
{
|
||||
"name": "stripe-version",
|
||||
"value": "2023-10-16"
|
||||
},
|
||||
{
|
||||
"name": "user-agent",
|
||||
"value": "Stripe/v1 NodeBindings/14.8.0"
|
||||
}
|
||||
],
|
||||
"headersSize": 626,
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"method": "GET",
|
||||
"queryString": [
|
||||
{
|
||||
"name": "limit",
|
||||
"value": "1"
|
||||
}
|
||||
],
|
||||
"url": "https://api.stripe.com/v1/payment_intents?limit=1"
|
||||
},
|
||||
"response": {
|
||||
"bodySize": 108,
|
||||
"content": {
|
||||
"mimeType": "application/json",
|
||||
"size": 108,
|
||||
"text": {
|
||||
"error": {
|
||||
"message": "Invalid API Key provided: blabla",
|
||||
"type": "invalid_request_error"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cookies": [],
|
||||
"headers": [
|
||||
{
|
||||
"name": "access-control-allow-credentials",
|
||||
"value": "true"
|
||||
},
|
||||
{
|
||||
"name": "access-control-allow-methods",
|
||||
"value": "GET,HEAD,PUT,PATCH,POST,DELETE"
|
||||
},
|
||||
{
|
||||
"name": "access-control-allow-origin",
|
||||
"value": "*"
|
||||
},
|
||||
{
|
||||
"name": "access-control-expose-headers",
|
||||
"value": "Request-Id, Stripe-Manage-Version, Stripe-Should-Retry, X-Stripe-External-Auth-Required, X-Stripe-Privileged-Session-Required"
|
||||
},
|
||||
{
|
||||
"name": "access-control-max-age",
|
||||
"value": "300"
|
||||
},
|
||||
{
|
||||
"name": "cache-control",
|
||||
"value": "no-cache, no-store"
|
||||
},
|
||||
{
|
||||
"name": "connection",
|
||||
"value": "keep-alive"
|
||||
},
|
||||
{
|
||||
"name": "content-length",
|
||||
"value": "108"
|
||||
},
|
||||
{
|
||||
"name": "content-type",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"name": "date",
|
||||
"value": "Wed, 13 Dec 2023 14:15:07 GMT"
|
||||
},
|
||||
{
|
||||
"name": "server",
|
||||
"value": "nginx"
|
||||
},
|
||||
{
|
||||
"name": "strict-transport-security",
|
||||
"value": "max-age=63072000; includeSubDomains; preload"
|
||||
},
|
||||
{
|
||||
"name": "vary",
|
||||
"value": "Origin"
|
||||
},
|
||||
{
|
||||
"name": "www-authenticate",
|
||||
"value": "Bearer realm=\"Stripe\""
|
||||
}
|
||||
],
|
||||
"headersSize": 615,
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"redirectURL": "",
|
||||
"status": 401,
|
||||
"statusText": "Unauthorized"
|
||||
},
|
||||
"startedDateTime": "2023-12-13T14:15:06.754Z",
|
||||
"time": 292,
|
||||
"timings": {
|
||||
"blocked": -1,
|
||||
"connect": -1,
|
||||
"dns": -1,
|
||||
"receive": 0,
|
||||
"send": 0,
|
||||
"ssl": -1,
|
||||
"wait": 292
|
||||
}
|
||||
}
|
||||
],
|
||||
"pages": [],
|
||||
"version": "1.2"
|
||||
}
|
||||
}
|
||||
68
src/modules/stripe/currencies.test.ts
Normal file
68
src/modules/stripe/currencies.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { getStripeAmountFromSaleorMoney, getSaleorAmountFromStripeAmount } from "./currencies";
|
||||
|
||||
describe("currencies", () => {
|
||||
const testCases = [
|
||||
{ major: 10, currency: "PLN", minor: 1000 },
|
||||
{ major: 21.37, currency: "PLN", minor: 2137 },
|
||||
{ major: 21.37, currency: "EUR", minor: 2137 },
|
||||
{ major: 21.37, currency: "USD", minor: 2137 },
|
||||
{ major: 1231231231.23, currency: "PLN", minor: 123123123123 },
|
||||
|
||||
// https://stripe.com/docs/currencies?presentment-currency=US#zero-decimal
|
||||
{ major: 500, currency: "BIF", minor: 500 },
|
||||
{ major: 500, currency: "CLP", minor: 500 },
|
||||
{ major: 500, currency: "DJF", minor: 500 },
|
||||
{ major: 500, currency: "GNF", minor: 500 },
|
||||
{ major: 500, currency: "JPY", minor: 500 },
|
||||
{ major: 500, currency: "KMF", minor: 500 },
|
||||
{ major: 500, currency: "KRW", minor: 500 },
|
||||
{ major: 500, currency: "MGA", minor: 500 },
|
||||
{ major: 500, currency: "PYG", minor: 500 },
|
||||
{ major: 500, currency: "RWF", minor: 500 },
|
||||
{ major: 500, currency: "UGX", minor: 500 },
|
||||
{ major: 500, currency: "VND", minor: 500 },
|
||||
{ major: 500, currency: "VUV", minor: 500 },
|
||||
{ major: 500, currency: "XAF", minor: 500 },
|
||||
{ major: 500, currency: "XOF", minor: 500 },
|
||||
{ major: 500, currency: "XPF", minor: 500 },
|
||||
|
||||
// https://stripe.com/docs/currencies?presentment-currency=US#three-decimal
|
||||
{ major: 5.12, currency: "BHD", minor: 5120 },
|
||||
{ major: 5.12, currency: "JOD", minor: 5120 },
|
||||
{ major: 5.12, currency: "KWD", minor: 5120 },
|
||||
{ major: 5.12, currency: "OMR", minor: 5120 },
|
||||
{ major: 5.12, currency: "TND", minor: 5120 },
|
||||
|
||||
// @todo
|
||||
// {major: 5.124, currency: 'BHD', minor: 5120 OR 5130},
|
||||
// {major: 5.124, currency: 'JOD', minor: 5120 OR 5130},
|
||||
// {major: 5.124, currency: 'KWD', minor: 5120 OR 5130},
|
||||
// {major: 5.124, currency: 'OMR', minor: 5120 OR 5130},
|
||||
// {major: 5.124, currency: 'TND', minor: 5120 OR 5130},
|
||||
|
||||
{ major: 21.37, currency: "XBT", minor: 2137 },
|
||||
];
|
||||
|
||||
it.each(testCases)("getStripeAmountFromSaleorMoney %p", ({ major, minor, currency }) => {
|
||||
expect(getStripeAmountFromSaleorMoney({ amount: major, currency })).toEqual(minor);
|
||||
});
|
||||
it.each(testCases)("getSaleorAmountFromStripeAmount %p", ({ major, minor, currency }) => {
|
||||
expect(getSaleorAmountFromStripeAmount({ amount: minor, currency })).toEqual(major);
|
||||
});
|
||||
|
||||
it.each(testCases)("identity %p", ({ major, minor, currency }) => {
|
||||
expect(
|
||||
getStripeAmountFromSaleorMoney({
|
||||
amount: getSaleorAmountFromStripeAmount({ amount: minor, currency }),
|
||||
currency,
|
||||
}),
|
||||
).toEqual(minor);
|
||||
expect(
|
||||
getSaleorAmountFromStripeAmount({
|
||||
amount: getStripeAmountFromSaleorMoney({ amount: major, currency }),
|
||||
currency,
|
||||
}),
|
||||
).toEqual(major);
|
||||
});
|
||||
});
|
||||
52
src/modules/stripe/currencies.ts
Normal file
52
src/modules/stripe/currencies.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { type Money } from "generated/graphql";
|
||||
import { invariant } from "@/lib/invariant";
|
||||
|
||||
const getDecimalsForStripe = (currency: string) => {
|
||||
invariant(currency.length === 3, "currency needs to be a 3-letter code");
|
||||
|
||||
const stripeDecimals = stripeCurrencies[currency.toUpperCase()];
|
||||
const decimals = stripeDecimals ?? 2;
|
||||
return decimals;
|
||||
};
|
||||
|
||||
// Some payment methods expect the amount to be in cents (integers)
|
||||
// Saleor provides and expects the amount to be in dollars (decimal format / floats)
|
||||
export const getStripeAmountFromSaleorMoney = ({ amount: major, currency }: Money) => {
|
||||
const decimals = getDecimalsForStripe(currency);
|
||||
const multiplier = 10 ** decimals;
|
||||
return Number.parseInt((major * multiplier).toFixed(0), 10);
|
||||
};
|
||||
|
||||
// Some payment methods expect the amount to be in cents (integers)
|
||||
// Saleor provides and expects the amount to be in dollars (decimal format / floats)
|
||||
export const getSaleorAmountFromStripeAmount = ({ amount: minor, currency }: Money) => {
|
||||
const decimals = getDecimalsForStripe(currency);
|
||||
const multiplier = 10 ** decimals;
|
||||
return Number.parseFloat((minor / multiplier).toFixed(decimals));
|
||||
};
|
||||
|
||||
// https://docs.stripe.com/development-resources/currency-codes
|
||||
const stripeCurrencies: Record<string, number> = {
|
||||
BIF: 0,
|
||||
CLP: 0,
|
||||
DJF: 0,
|
||||
GNF: 0,
|
||||
JPY: 0,
|
||||
KMF: 0,
|
||||
KRW: 0,
|
||||
MGA: 0,
|
||||
PYG: 0,
|
||||
RWF: 0,
|
||||
UGX: 0,
|
||||
VND: 0,
|
||||
VUV: 0,
|
||||
XAF: 0,
|
||||
XOF: 0,
|
||||
XPF: 0,
|
||||
|
||||
BHD: 3,
|
||||
JOD: 3,
|
||||
KWD: 3,
|
||||
OMR: 3,
|
||||
TND: 3,
|
||||
};
|
||||
94
src/modules/stripe/stripe-api.test.ts
Normal file
94
src/modules/stripe/stripe-api.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type Stripe from "stripe";
|
||||
import {
|
||||
getStripeExternalUrlForIntentId,
|
||||
stripePaymentIntentToTransactionResult,
|
||||
validateStripeKeys,
|
||||
} from "./stripe-api";
|
||||
import { TransactionFlowStrategyEnum } from "generated/graphql";
|
||||
import { type TransactionInitializeSessionResponse } from "@/schemas/TransactionInitializeSession/TransactionInitializeSessionResponse.mjs";
|
||||
import { setupRecording } from "@/__tests__/polly";
|
||||
import { testEnv } from "@/__tests__/test-env.mjs";
|
||||
|
||||
describe("stripe-api", () => {
|
||||
describe("stripeResultCodeToTransactionResult", () => {
|
||||
type ResultWithoutPrefix =
|
||||
TransactionInitializeSessionResponse["result"] extends `${infer _Prefix}_${infer Result}`
|
||||
? Result
|
||||
: never;
|
||||
|
||||
// exhaustiveCheck is used to ensure that all Stripe.PaymentIntent.Status are covered
|
||||
const exhaustiveCheck = {
|
||||
canceled: "FAILURE",
|
||||
processing: "REQUEST",
|
||||
requires_action: "ACTION_REQUIRED",
|
||||
requires_capture: "SUCCESS",
|
||||
requires_confirmation: "ACTION_REQUIRED",
|
||||
requires_payment_method: "ACTION_REQUIRED",
|
||||
succeeded: "SUCCESS",
|
||||
} satisfies Record<Stripe.PaymentIntent.Status, ResultWithoutPrefix>;
|
||||
|
||||
describe.each(Object.entries(exhaustiveCheck))("%p", (stripeResult, expectedResult) => {
|
||||
it.each([
|
||||
{
|
||||
strategy: TransactionFlowStrategyEnum.Authorization,
|
||||
expectedAction: "AUTHORIZATION",
|
||||
},
|
||||
{
|
||||
strategy: TransactionFlowStrategyEnum.Charge,
|
||||
expectedAction: "CHARGE",
|
||||
},
|
||||
])("%p", async ({ strategy, expectedAction }) => {
|
||||
const returned = stripePaymentIntentToTransactionResult(strategy, {
|
||||
status: stripeResult,
|
||||
} as Stripe.PaymentIntent);
|
||||
|
||||
if (stripeResult === "requires_capture") {
|
||||
// special case
|
||||
expect(returned).toBe("AUTHORIZATION_SUCCESS");
|
||||
} else {
|
||||
expect(returned).toBe(`${expectedAction}_${expectedResult}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getStripeExternalUrlForIntentId", () => {
|
||||
it("should get external url for intentId", () => {
|
||||
expect(getStripeExternalUrlForIntentId("pi_3MmHAnLE6YuwiJ1e0lqUR2OC")).toMatchInlineSnapshot(
|
||||
'"https://dashboard.stripe.com/payments/pi_3MmHAnLE6YuwiJ1e0lqUR2OC"',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateStripeKeys", () => {
|
||||
setupRecording();
|
||||
|
||||
it("should throw error if secret key is invalid", async () => {
|
||||
return expect(
|
||||
validateStripeKeys("blabla", testEnv.TEST_PAYMENT_APP_PUBLISHABLE_KEY),
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot('"Provided secret key is invalid"');
|
||||
});
|
||||
|
||||
it("should throw error if publishable key is invalid", async () => {
|
||||
return expect(
|
||||
validateStripeKeys(testEnv.TEST_PAYMENT_APP_SECRET_KEY, "blabla"),
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot('"Provided publishable key is invalid"');
|
||||
});
|
||||
|
||||
it("should throw error if both keys are invalid", async () => {
|
||||
return expect(
|
||||
validateStripeKeys("blabla", "blabla"),
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot('"Provided secret key is invalid"');
|
||||
});
|
||||
|
||||
it("not throw error if both keys are correct", async () => {
|
||||
return expect(
|
||||
validateStripeKeys(
|
||||
testEnv.TEST_PAYMENT_APP_SECRET_KEY,
|
||||
testEnv.TEST_PAYMENT_APP_PUBLISHABLE_KEY,
|
||||
),
|
||||
).resolves.toMatchInlineSnapshot("undefined");
|
||||
});
|
||||
});
|
||||
});
|
||||
230
src/modules/stripe/stripe-api.ts
Normal file
230
src/modules/stripe/stripe-api.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import { Stripe } from "stripe";
|
||||
import { getStripeAmountFromSaleorMoney } from "./currencies";
|
||||
import {
|
||||
TransactionFlowStrategyEnum,
|
||||
type TransactionProcessSessionEventFragment,
|
||||
type TransactionInitializeSessionEventFragment,
|
||||
} from "generated/graphql";
|
||||
import { invariant } from "@/lib/invariant";
|
||||
import type { TransactionInitializeSessionResponse } from "@/schemas/TransactionInitializeSession/TransactionInitializeSessionResponse.mjs";
|
||||
import { InvalidSecretKeyError, RestrictedKeyNotSupportedError } from "@/errors";
|
||||
import { unpackPromise } from "@/lib/utils";
|
||||
import { createLogger, redactError } from "@/lib/logger";
|
||||
|
||||
export const getStripeApiClient = (secretKey: string) => {
|
||||
const stripe = new Stripe(secretKey, {
|
||||
apiVersion: "2023-10-16",
|
||||
typescript: true,
|
||||
httpClient: Stripe.createFetchHttpClient(fetch),
|
||||
});
|
||||
return stripe;
|
||||
};
|
||||
|
||||
export const validateStripeKeys = async (secretKey: string, publishableKey: string) => {
|
||||
const logger = createLogger({}, { msgPrefix: "[validateStripeKeys] " });
|
||||
|
||||
if (secretKey.startsWith("rk_")) {
|
||||
// @todo remove this once restricted keys are supported
|
||||
// validate that restricted keys have required permissions
|
||||
throw new RestrictedKeyNotSupportedError("Restricted keys are not supported");
|
||||
}
|
||||
|
||||
{
|
||||
const stripe = getStripeApiClient(secretKey);
|
||||
const [intentsError] = await unpackPromise(stripe.paymentIntents.list({ limit: 1 }));
|
||||
|
||||
if (intentsError) {
|
||||
logger.error({ error: redactError(intentsError) }, "Invalid secret key");
|
||||
if (intentsError instanceof Stripe.errors.StripeError) {
|
||||
throw new InvalidSecretKeyError("Provided secret key is invalid");
|
||||
}
|
||||
throw new InvalidSecretKeyError("There was an error while checking secret key");
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
// https://stackoverflow.com/a/61001462/704894
|
||||
const stripe = getStripeApiClient(publishableKey);
|
||||
const [tokenError] = await unpackPromise(
|
||||
stripe.tokens.create({
|
||||
pii: { id_number: "test" },
|
||||
}),
|
||||
);
|
||||
if (tokenError) {
|
||||
logger.error({ error: redactError(tokenError) }, "Invalid publishable key");
|
||||
if (tokenError instanceof Stripe.errors.StripeError) {
|
||||
throw new InvalidSecretKeyError("Provided publishable key is invalid");
|
||||
}
|
||||
throw new InvalidSecretKeyError("There was an error while checking publishable key");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const getEnvironmentFromKey = (secretKeyOrPublishableKey: string) => {
|
||||
return secretKeyOrPublishableKey.startsWith("sk_live_") ||
|
||||
secretKeyOrPublishableKey.startsWith("pk_live_") ||
|
||||
secretKeyOrPublishableKey.startsWith("rk_live_")
|
||||
? "live"
|
||||
: "test";
|
||||
};
|
||||
|
||||
export const getStripeWebhookDashboardLink = (
|
||||
webhookId: string,
|
||||
environment: "live" | "test",
|
||||
): string => {
|
||||
if (environment === "live") {
|
||||
return `https://dashboard.stripe.com/webhooks/${webhookId}`;
|
||||
} else {
|
||||
return `https://dashboard.stripe.com/test/webhooks/${webhookId}`;
|
||||
}
|
||||
};
|
||||
|
||||
export const transactionSessionInitializeEventToStripeCreate = (
|
||||
event: TransactionInitializeSessionEventFragment,
|
||||
): Stripe.PaymentIntentCreateParams => {
|
||||
const data = event.data as Partial<Stripe.PaymentIntentCreateParams>;
|
||||
|
||||
return {
|
||||
...data,
|
||||
amount: getStripeAmountFromSaleorMoney({
|
||||
amount: event.sourceObject.total.gross.amount,
|
||||
currency: event.sourceObject.total.gross.currency,
|
||||
}),
|
||||
currency: event.sourceObject.total.gross.currency,
|
||||
capture_method:
|
||||
event.action.actionType === TransactionFlowStrategyEnum.Charge ? "automatic" : "manual",
|
||||
metadata: {
|
||||
...data.metadata,
|
||||
transactionId: event.transaction.id,
|
||||
channelId: event.sourceObject.channel.id,
|
||||
...(event.sourceObject.__typename === "Checkout" && { checkoutId: event.sourceObject.id }),
|
||||
...(event.sourceObject.__typename === "Order" && { orderId: event.sourceObject.id }),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const transactionSessionProcessEventToStripeUpdate = (
|
||||
event: TransactionInitializeSessionEventFragment | TransactionProcessSessionEventFragment,
|
||||
): Stripe.PaymentIntentUpdateParams => {
|
||||
const data = event.data as Partial<Stripe.PaymentIntentUpdateParams>;
|
||||
|
||||
return {
|
||||
...data,
|
||||
amount: getStripeAmountFromSaleorMoney({
|
||||
amount: event.sourceObject.total.gross.amount,
|
||||
currency: event.sourceObject.total.gross.currency,
|
||||
}),
|
||||
currency: event.sourceObject.total.gross.currency,
|
||||
capture_method:
|
||||
event.action.actionType === TransactionFlowStrategyEnum.Charge ? "automatic" : "manual",
|
||||
metadata: {
|
||||
...data.metadata,
|
||||
transactionId: event.transaction.id,
|
||||
channelId: event.sourceObject.channel.id,
|
||||
...(event.sourceObject.__typename === "Checkout" && { checkoutId: event.sourceObject.id }),
|
||||
...(event.sourceObject.__typename === "Order" && { orderId: event.sourceObject.id }),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const stripePaymentIntentToTransactionResult = (
|
||||
transactionFlowStrategy: TransactionFlowStrategyEnum,
|
||||
stripePaymentIntent: Stripe.PaymentIntent,
|
||||
): TransactionInitializeSessionResponse["result"] => {
|
||||
const stripeResult = stripePaymentIntent.status;
|
||||
const prefix =
|
||||
transactionFlowStrategy === TransactionFlowStrategyEnum.Authorization
|
||||
? "AUTHORIZATION"
|
||||
: transactionFlowStrategy === TransactionFlowStrategyEnum.Charge
|
||||
? "CHARGE"
|
||||
: /* c8 ignore next */
|
||||
null;
|
||||
invariant(prefix, `Unsupported transactionFlowStrategy: ${transactionFlowStrategy}`);
|
||||
|
||||
switch (stripeResult) {
|
||||
case "processing":
|
||||
return `${prefix}_REQUEST`;
|
||||
case "requires_payment_method":
|
||||
case "requires_action":
|
||||
case "requires_confirmation":
|
||||
return `${prefix}_ACTION_REQUIRED`;
|
||||
case "canceled":
|
||||
return `${prefix}_FAILURE`;
|
||||
case "succeeded":
|
||||
return `${prefix}_SUCCESS`;
|
||||
case "requires_capture":
|
||||
return "AUTHORIZATION_SUCCESS";
|
||||
}
|
||||
};
|
||||
|
||||
export const initializeStripePaymentIntent = ({
|
||||
paymentIntentCreateParams,
|
||||
secretKey,
|
||||
}: {
|
||||
paymentIntentCreateParams: Stripe.PaymentIntentCreateParams;
|
||||
secretKey: string;
|
||||
}) => {
|
||||
const stripe = getStripeApiClient(secretKey);
|
||||
return stripe.paymentIntents.create(paymentIntentCreateParams);
|
||||
};
|
||||
|
||||
export const updateStripePaymentIntent = ({
|
||||
intentId,
|
||||
paymentIntentUpdateParams,
|
||||
secretKey,
|
||||
}: {
|
||||
intentId: string;
|
||||
paymentIntentUpdateParams: Stripe.PaymentIntentUpdateParams;
|
||||
secretKey: string;
|
||||
}) => {
|
||||
const stripe = getStripeApiClient(secretKey);
|
||||
return stripe.paymentIntents.update(intentId, paymentIntentUpdateParams);
|
||||
};
|
||||
|
||||
export const getStripeExternalUrlForIntentId = (intentId: string) => {
|
||||
const externalUrl = `https://dashboard.stripe.com/payments/${encodeURIComponent(intentId)}`;
|
||||
return externalUrl;
|
||||
};
|
||||
|
||||
export async function processStripePaymentIntentRefundRequest({
|
||||
paymentIntentId,
|
||||
stripeAmount,
|
||||
secretKey,
|
||||
}: {
|
||||
paymentIntentId: string;
|
||||
stripeAmount: number | null | undefined;
|
||||
secretKey: string;
|
||||
}) {
|
||||
const stripeClient = getStripeApiClient(secretKey);
|
||||
return stripeClient.refunds.create({
|
||||
payment_intent: paymentIntentId,
|
||||
amount: stripeAmount ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export async function processStripePaymentIntentCancelRequest({
|
||||
paymentIntentId,
|
||||
secretKey,
|
||||
}: {
|
||||
paymentIntentId: string;
|
||||
secretKey: string;
|
||||
}) {
|
||||
const stripeClient = getStripeApiClient(secretKey);
|
||||
|
||||
return stripeClient.paymentIntents.cancel(paymentIntentId);
|
||||
}
|
||||
|
||||
export async function processStripePaymentIntentCaptureRequest({
|
||||
paymentIntentId,
|
||||
stripeAmount,
|
||||
secretKey,
|
||||
}: {
|
||||
paymentIntentId: string;
|
||||
stripeAmount: number | null | undefined;
|
||||
secretKey: string;
|
||||
}) {
|
||||
const stripeClient = getStripeApiClient(secretKey);
|
||||
return stripeClient.paymentIntents.capture(paymentIntentId, {
|
||||
amount_to_capture: stripeAmount ?? undefined,
|
||||
});
|
||||
}
|
||||
122
src/modules/trpc/protected-client-procedure.ts
Normal file
122
src/modules/trpc/protected-client-procedure.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { verifyJWT } from "@saleor/app-sdk/verify-jwt";
|
||||
import { logger, redactLogValue } from "../../lib/logger";
|
||||
import { REQUIRED_SALEOR_PERMISSIONS } from "../jwt/consts";
|
||||
import { middleware, procedure } from "./trpc-server";
|
||||
import { checkTokenExpiration } from "@/modules/jwt/check-token-expiration";
|
||||
import { saleorApp } from "@/saleor-app";
|
||||
import { createClient } from "@/lib/create-graphq-client";
|
||||
import {
|
||||
JwtInvalidError,
|
||||
JwtTokenExpiredError,
|
||||
ReqMissingAppIdError,
|
||||
ReqMissingAuthDataError,
|
||||
ReqMissingSaleorApiUrlError,
|
||||
ReqMissingTokenError,
|
||||
} from "@/errors";
|
||||
|
||||
const attachAppToken = middleware(async ({ ctx, next }) => {
|
||||
logger.debug("attachAppToken middleware");
|
||||
|
||||
if (!ctx.saleorApiUrl) {
|
||||
logger.debug("ctx.saleorApiUrl not found, throwing");
|
||||
|
||||
throw new ReqMissingSaleorApiUrlError("Missing saleorApiUrl in request");
|
||||
}
|
||||
|
||||
const authData = await saleorApp.apl.get(ctx.saleorApiUrl);
|
||||
|
||||
if (!authData) {
|
||||
logger.debug("authData not found, throwing 401");
|
||||
|
||||
throw new ReqMissingAuthDataError("Missing authData in request");
|
||||
}
|
||||
|
||||
return next({
|
||||
ctx: {
|
||||
appToken: authData.token,
|
||||
saleorApiUrl: authData.saleorApiUrl,
|
||||
appId: authData.appId,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const validateClientToken = middleware(async ({ ctx, next }) => {
|
||||
logger.debug("validateClientToken middleware");
|
||||
|
||||
if (!ctx.token) {
|
||||
throw new ReqMissingTokenError(
|
||||
"Missing token in request. This middleware can be used only in frontend",
|
||||
);
|
||||
}
|
||||
|
||||
if (!ctx.appId) {
|
||||
throw new ReqMissingAppIdError(
|
||||
"Missing appId in request. This middleware can be used after auth is attached",
|
||||
);
|
||||
}
|
||||
|
||||
if (!ctx.saleorApiUrl) {
|
||||
throw new ReqMissingSaleorApiUrlError(
|
||||
"Missing saleorApiUrl in request. This middleware can be used after auth is attached",
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug({ token: redactLogValue(ctx.token) }, "check if JWT token didn't expire");
|
||||
|
||||
const expired = checkTokenExpiration(ctx.token);
|
||||
logger.debug({ expired }, "JWT token expiration check result");
|
||||
if (expired) {
|
||||
throw new JwtTokenExpiredError("Token expired");
|
||||
}
|
||||
|
||||
try {
|
||||
logger.debug({ token: redactLogValue(ctx.token) }, "trying to verify JWT token from frontend");
|
||||
|
||||
await verifyJWT({
|
||||
appId: ctx.appId,
|
||||
token: ctx.token,
|
||||
saleorApiUrl: ctx.saleorApiUrl,
|
||||
requiredPermissions: REQUIRED_SALEOR_PERMISSIONS,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.debug("JWT verification failed, throwing");
|
||||
throw new JwtInvalidError("Invalid token", { cause: e });
|
||||
}
|
||||
|
||||
return next({
|
||||
ctx: {
|
||||
...ctx,
|
||||
saleorApiUrl: ctx.saleorApiUrl,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Construct common graphQL client and attach it to the context
|
||||
*
|
||||
* Can be used only if called from the frontend (react-query),
|
||||
* otherwise jwks validation will fail (if createCaller used)
|
||||
*
|
||||
* TODO Rethink middleware composition to enable safe server-side router calls
|
||||
*/
|
||||
export const protectedClientProcedure = procedure
|
||||
.use(attachAppToken)
|
||||
.use(validateClientToken)
|
||||
.use(async ({ ctx, next }) => {
|
||||
const client = createClient(ctx.saleorApiUrl, async () =>
|
||||
Promise.resolve({ token: ctx.appToken }),
|
||||
);
|
||||
|
||||
const pinoLoggerInstance = logger.child({
|
||||
saleorApiUrl: ctx.saleorApiUrl,
|
||||
});
|
||||
|
||||
return next({
|
||||
ctx: {
|
||||
apiClient: client,
|
||||
appToken: ctx.appToken,
|
||||
saleorApiUrl: ctx.saleorApiUrl,
|
||||
logger: pinoLoggerInstance,
|
||||
},
|
||||
});
|
||||
});
|
||||
9
src/modules/trpc/trpc-app-router.ts
Normal file
9
src/modules/trpc/trpc-app-router.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { paymentAppConfigurationRouter } from "../payment-app-configuration/payment-app-configuration.router";
|
||||
import { router } from "./trpc-server";
|
||||
|
||||
export const appRouter = router({
|
||||
paymentAppConfigurationRouter,
|
||||
// CHANGEME: Add additioal routers here
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
87
src/modules/trpc/trpc-client.ts
Normal file
87
src/modules/trpc/trpc-client.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { TRPCClientError, httpBatchLink, loggerLink, type TRPCClientErrorLike } from "@trpc/client";
|
||||
import { createTRPCNext } from "@trpc/next";
|
||||
|
||||
import { SALEOR_API_URL_HEADER, SALEOR_AUTHORIZATION_BEARER_HEADER } from "@saleor/app-sdk/const";
|
||||
import { useErrorModalStore } from "../ui/organisms/GlobalErrorModal/state";
|
||||
import { type AppRouter } from "./trpc-app-router";
|
||||
import { getErrorHandler } from "./utils";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { appBridgeInstance } from "@/app-bridge-instance";
|
||||
import { BaseTrpcError, JwtInvalidError, JwtTokenExpiredError } from "@/errors";
|
||||
import { isDevelopment } from "@/lib/isEnv";
|
||||
|
||||
const genericErrorHandler = (err: unknown) => {
|
||||
getErrorHandler({
|
||||
actionId: "generic-error",
|
||||
appBridge: appBridgeInstance,
|
||||
})(err as TRPCClientErrorLike<AppRouter>);
|
||||
};
|
||||
|
||||
export const trpcClient = createTRPCNext<AppRouter>({
|
||||
config() {
|
||||
return {
|
||||
abortOnUnmount: true,
|
||||
links: [
|
||||
loggerLink({
|
||||
// enable client and server logs for development
|
||||
// enable client logs for production
|
||||
enabled: (opts) =>
|
||||
(isDevelopment() && typeof window !== "undefined") ||
|
||||
(opts.direction === "down" && opts.result instanceof Error),
|
||||
// use logger for production, console.log for development
|
||||
logger: !isDevelopment() ? (data) => logger.error(data, "TRPC client error") : undefined,
|
||||
}),
|
||||
loggerLink({
|
||||
logger: (data) => {
|
||||
if (data.direction === "down" && data.result instanceof TRPCClientError) {
|
||||
const serialized = data.result.data?.serialized;
|
||||
const error = BaseTrpcError.parse(serialized);
|
||||
|
||||
if (error instanceof JwtTokenExpiredError) {
|
||||
useErrorModalStore.setState({
|
||||
isOpen: true,
|
||||
message: "JWT Token expired. Please refresh the page.",
|
||||
});
|
||||
}
|
||||
|
||||
if (error instanceof JwtInvalidError) {
|
||||
useErrorModalStore.setState({
|
||||
isOpen: true,
|
||||
message: "JWT Token is invalid. Please refresh the page.",
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
}),
|
||||
httpBatchLink({
|
||||
url: "/api/trpc",
|
||||
headers() {
|
||||
return {
|
||||
/**
|
||||
* Attach headers from app to client requests, so tRPC can add them to context
|
||||
*/
|
||||
[SALEOR_AUTHORIZATION_BEARER_HEADER]: appBridgeInstance?.getState().token,
|
||||
[SALEOR_API_URL_HEADER]: appBridgeInstance?.getState().saleorApiUrl,
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
queryClientConfig: {
|
||||
defaultOptions: {
|
||||
mutations: {
|
||||
onError: genericErrorHandler,
|
||||
retry: false,
|
||||
},
|
||||
queries: {
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
onError: genericErrorHandler,
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
ssr: false,
|
||||
});
|
||||
17
src/modules/trpc/trpc-context.ts
Normal file
17
src/modules/trpc/trpc-context.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type * as trpcNext from "@trpc/server/adapters/next";
|
||||
import { SALEOR_AUTHORIZATION_BEARER_HEADER, SALEOR_API_URL_HEADER } from "@saleor/app-sdk/const";
|
||||
import { type inferAsyncReturnType } from "@trpc/server";
|
||||
|
||||
export const createTrpcContext = async ({ req }: trpcNext.CreateNextContextOptions) => {
|
||||
const token = req.headers[SALEOR_AUTHORIZATION_BEARER_HEADER];
|
||||
const saleorApiUrl = req.headers[SALEOR_API_URL_HEADER];
|
||||
|
||||
return {
|
||||
token: Array.isArray(token) ? token[0] : token,
|
||||
saleorApiUrl: Array.isArray(saleorApiUrl) ? saleorApiUrl[0] : saleorApiUrl,
|
||||
appId: undefined as undefined | string,
|
||||
appUrl: req.headers["origin"],
|
||||
};
|
||||
};
|
||||
|
||||
export type TrpcContext = inferAsyncReturnType<typeof createTrpcContext>;
|
||||
43
src/modules/trpc/trpc-server.ts
Normal file
43
src/modules/trpc/trpc-server.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { type TRPCError, initTRPC } from "@trpc/server";
|
||||
import { TRPC_ERROR_CODES_BY_KEY, type TRPC_ERROR_CODE_KEY } from "@trpc/server/rpc";
|
||||
import { type TrpcContext } from "./trpc-context";
|
||||
import { BaseTrpcError } from "@/errors";
|
||||
import { isProduction } from "@/lib/isEnv";
|
||||
|
||||
const getErrorCode = (error: TRPCError): TRPC_ERROR_CODE_KEY => {
|
||||
const cause = error.cause;
|
||||
if (cause && cause instanceof BaseTrpcError) {
|
||||
return cause.trpcCode || "INTERNAL_SERVER_ERROR";
|
||||
}
|
||||
return error.code;
|
||||
};
|
||||
|
||||
const getSerialized = (error: TRPCError) => {
|
||||
const cause = error.cause;
|
||||
if (cause && cause instanceof BaseTrpcError) {
|
||||
const serializedError = BaseTrpcError.serialize(cause);
|
||||
if (isProduction()) {
|
||||
serializedError.stack = "[HIDDEN]";
|
||||
}
|
||||
return serializedError;
|
||||
}
|
||||
};
|
||||
|
||||
const t = initTRPC.context<TrpcContext>().create({
|
||||
errorFormatter({ shape, error }) {
|
||||
return {
|
||||
...shape,
|
||||
code: TRPC_ERROR_CODES_BY_KEY[getErrorCode(error)],
|
||||
data: {
|
||||
...shape.data,
|
||||
code: getErrorCode(error),
|
||||
serialized: getSerialized(error),
|
||||
stack: isProduction() ? null : error.stack,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const router = t.router;
|
||||
export const procedure = t.procedure;
|
||||
export const middleware = t.middleware;
|
||||
82
src/modules/trpc/utils.ts
Normal file
82
src/modules/trpc/utils.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { type TRPCClientErrorLike } from "@trpc/client";
|
||||
import {
|
||||
type DeepPartial,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
type UseFormSetError,
|
||||
} from "react-hook-form";
|
||||
import { type AppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
import { type AppRouter } from "./trpc-app-router";
|
||||
import { BaseTrpcError, FieldError } from "@/errors";
|
||||
|
||||
interface HandlerInput {
|
||||
message?: string;
|
||||
title?: string;
|
||||
actionId: string;
|
||||
appBridge: AppBridge | undefined;
|
||||
}
|
||||
|
||||
const getParsedError = <T extends TRPCClientErrorLike<AppRouter>>(error: T) => {
|
||||
if (error.data?.serialized) {
|
||||
return BaseTrpcError.parse(error.data.serialized);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getErrorHandler =
|
||||
(input: HandlerInput) =>
|
||||
<T extends TRPCClientErrorLike<AppRouter>>(error: T) => {
|
||||
if (input.appBridge) {
|
||||
const parsedError = getParsedError(error);
|
||||
void input.appBridge.dispatch({
|
||||
type: "notification",
|
||||
payload: {
|
||||
title: input.title || "Request failed",
|
||||
text: input.message || parsedError?.message || error.message,
|
||||
apiMessage: error.shape ? JSON.stringify(error.shape ?? {}, null, 2) : undefined,
|
||||
actionId: input.actionId,
|
||||
status: "error",
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
interface FieldHandlerInput<TFieldValues extends FieldValues> extends HandlerInput {
|
||||
fieldName: FieldPath<TFieldValues> | `root.${string}` | "root";
|
||||
formFields: FieldPath<TFieldValues>[];
|
||||
setError: UseFormSetError<TFieldValues>;
|
||||
}
|
||||
|
||||
export const getFormFields = <TFieldValues extends FieldValues>(
|
||||
defaultValues: Readonly<DeepPartial<TFieldValues>> | undefined,
|
||||
): FieldPath<TFieldValues>[] => {
|
||||
if (!defaultValues) {
|
||||
return [];
|
||||
}
|
||||
return Object.keys(defaultValues) as FieldPath<TFieldValues>[];
|
||||
};
|
||||
|
||||
const isMatchingField = <TFieldValues extends FieldValues>(
|
||||
fieldName: string,
|
||||
formFields: FieldPath<TFieldValues>[],
|
||||
): fieldName is FieldPath<TFieldValues> => {
|
||||
return formFields.some((field) => field === fieldName);
|
||||
};
|
||||
|
||||
export const getFieldErrorHandler =
|
||||
<TFieldValues extends FieldValues>(input: FieldHandlerInput<TFieldValues>) =>
|
||||
<T extends TRPCClientErrorLike<AppRouter>>(error: T) => {
|
||||
getErrorHandler(input)(error);
|
||||
const parsedError = FieldError.parse(error.data?.serialized);
|
||||
|
||||
if (
|
||||
parsedError instanceof FieldError &&
|
||||
isMatchingField(parsedError.fieldName, input.formFields)
|
||||
) {
|
||||
input.setError(parsedError.fieldName, { message: parsedError.message });
|
||||
} else {
|
||||
input.setError(input.fieldName, {
|
||||
message: input.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
38
src/modules/ui/atoms/Chip/Chip.tsx
Normal file
38
src/modules/ui/atoms/Chip/Chip.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Chip, Text } from "@saleor/macaw-ui/next";
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
export const ChipNeutral = ({ children }: { children: ReactNode }) => (
|
||||
<Chip size="medium" backgroundColor="surfaceNeutralHighlight">
|
||||
<Text size="small" variant="caption">
|
||||
{children}
|
||||
</Text>
|
||||
</Chip>
|
||||
);
|
||||
export const ChipDanger = ({ children }: { children: ReactNode }) => (
|
||||
<Chip size="medium" backgroundColor="surfaceCriticalDepressed" borderColor="neutralHighlight">
|
||||
<Text color="textCriticalDefault" size="small" variant="caption">
|
||||
{children}
|
||||
</Text>
|
||||
</Chip>
|
||||
);
|
||||
export const ChipStripeOrange = ({ children }: { children: ReactNode }) => (
|
||||
<Chip size="medium" __backgroundColor="#ed6704">
|
||||
<Text __color="white" size="small" variant="caption">
|
||||
{children}
|
||||
</Text>
|
||||
</Chip>
|
||||
);
|
||||
export const ChipSuccess = ({ children }: { children: ReactNode }) => (
|
||||
<Chip size="medium" backgroundColor="decorativeSurfaceSubdued2" borderColor="neutralHighlight">
|
||||
<Text color="text2Decorative" size="small" variant="caption">
|
||||
{children}
|
||||
</Text>
|
||||
</Chip>
|
||||
);
|
||||
export const ChipInfo = ({ children }: { children: ReactNode }) => (
|
||||
<Chip size="medium" backgroundColor="surfaceBrandSubdued" borderColor="neutralHighlight">
|
||||
<Text color="textBrandDefault" size="small" variant="caption">
|
||||
{children}
|
||||
</Text>
|
||||
</Chip>
|
||||
);
|
||||
56
src/modules/ui/atoms/FileUploadInput/FileUploadInput.tsx
Normal file
56
src/modules/ui/atoms/FileUploadInput/FileUploadInput.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { ArrowLeftIcon, Box, Button, Text } from "@saleor/macaw-ui/next";
|
||||
import { forwardRef } from "react";
|
||||
import { fileUploadInput } from "./fileUploadInput.css";
|
||||
|
||||
export type FileUploadInputProps = {
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
} & JSX.IntrinsicElements["input"];
|
||||
|
||||
export const FileUploadInput = forwardRef<HTMLInputElement, FileUploadInputProps>(
|
||||
({ label, disabled = false, ...props }, ref) => {
|
||||
return (
|
||||
<Box display="flex" alignItems="stretch" flexDirection="row" columnGap={4}>
|
||||
<Box as="label" className={fileUploadInput}>
|
||||
<Text variant="body" size="medium" color="textNeutralSubdued">
|
||||
{label}
|
||||
</Text>
|
||||
<input
|
||||
{...props}
|
||||
value={undefined}
|
||||
type="file"
|
||||
className="visually-hidden"
|
||||
disabled={disabled}
|
||||
ref={ref}
|
||||
/>
|
||||
</Box>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={disabled}
|
||||
size="medium"
|
||||
marginTop={6}
|
||||
whiteSpace="nowrap"
|
||||
type="submit"
|
||||
>
|
||||
<Box
|
||||
display="inline-flex"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
paddingY={"px"}
|
||||
paddingX={0.5}
|
||||
__transform="rotate(90deg) scale(0.75)"
|
||||
__borderWidth={0.5}
|
||||
borderColor="neutralPlain"
|
||||
borderRadius={2}
|
||||
borderStyle="solid"
|
||||
outlineStyle="none"
|
||||
>
|
||||
<ArrowLeftIcon size="small" />
|
||||
</Box>
|
||||
Upload certificate
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
);
|
||||
FileUploadInput.displayName = "FileUploadInput";
|
||||
25
src/modules/ui/atoms/FileUploadInput/fileUploadInput.css.ts
Normal file
25
src/modules/ui/atoms/FileUploadInput/fileUploadInput.css.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { sprinkles } from "@saleor/macaw-ui/next";
|
||||
import { style } from "@vanilla-extract/css";
|
||||
|
||||
export const fileUploadInput = style([
|
||||
sprinkles({
|
||||
color: "textNeutralSubdued",
|
||||
backgroundColor: {
|
||||
default: "surfaceNeutralHighlight",
|
||||
hover: "surfaceNeutralPlain",
|
||||
},
|
||||
borderRadius: 3,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: {
|
||||
default: "transparent",
|
||||
hover: "neutralHighlight",
|
||||
},
|
||||
paddingX: 2,
|
||||
cursor: "pointer",
|
||||
flexGrow: "1",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}),
|
||||
{ height: 56 },
|
||||
]);
|
||||
83
src/modules/ui/atoms/RoundedActionBox/RoundedActionBox.tsx
Normal file
83
src/modules/ui/atoms/RoundedActionBox/RoundedActionBox.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Box, type BoxProps } from "@saleor/macaw-ui/next";
|
||||
import { type ReactNode } from "react";
|
||||
import { roundedActionBoxRecipe } from "./roundedActionBox.css";
|
||||
|
||||
export const RoundedBox = ({
|
||||
children,
|
||||
disabled = false,
|
||||
error = false,
|
||||
...boxProps
|
||||
}: BoxProps & {
|
||||
disabled?: boolean;
|
||||
error?: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<Box
|
||||
{...boxProps}
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
rowGap={0}
|
||||
borderWidth={1}
|
||||
borderStyle="solid"
|
||||
borderRadius={5}
|
||||
className={roundedActionBoxRecipe({ disabled, error })}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const RoundedBoxWithFooter = ({
|
||||
children,
|
||||
footer,
|
||||
error = false,
|
||||
disabled = false,
|
||||
...boxProps
|
||||
}: BoxProps & {
|
||||
footer: ReactNode;
|
||||
error?: boolean;
|
||||
disabled?: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<RoundedBox {...boxProps} error={error} disabled={disabled}>
|
||||
<Box paddingX={6} paddingTop={4} display="flex">
|
||||
{children}
|
||||
</Box>
|
||||
<Box
|
||||
paddingX={6}
|
||||
paddingY={4}
|
||||
display="flex"
|
||||
justifyContent="flex-end"
|
||||
borderColor="neutralPlain"
|
||||
borderTopWidth={1}
|
||||
borderTopStyle="solid"
|
||||
>
|
||||
{footer}
|
||||
</Box>
|
||||
</RoundedBox>
|
||||
);
|
||||
};
|
||||
|
||||
export const RoundedActionBox = ({
|
||||
children,
|
||||
disabled,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
disabled?: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<RoundedBox disabled={disabled}>
|
||||
<Box
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
rowGap={6}
|
||||
textAlign="center"
|
||||
__minHeight={312}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</RoundedBox>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import { sprinkles } from "@saleor/macaw-ui/next";
|
||||
import { recipe } from "@vanilla-extract/recipes";
|
||||
|
||||
export const roundedActionBoxRecipe = recipe({
|
||||
variants: {
|
||||
error: {
|
||||
true: {},
|
||||
false: {},
|
||||
},
|
||||
disabled: { true: {}, false: {} },
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
variants: {
|
||||
disabled: false,
|
||||
error: false,
|
||||
},
|
||||
style: sprinkles({
|
||||
borderColor: "neutralPlain",
|
||||
}),
|
||||
},
|
||||
{
|
||||
variants: {
|
||||
disabled: true,
|
||||
error: false,
|
||||
},
|
||||
style: sprinkles({
|
||||
borderColor: "neutralHighlight",
|
||||
}),
|
||||
},
|
||||
{
|
||||
variants: {
|
||||
disabled: false,
|
||||
error: true,
|
||||
},
|
||||
style: sprinkles({
|
||||
backgroundColor: "surfaceCriticalHighlight",
|
||||
borderColor: "criticalDefault",
|
||||
}),
|
||||
},
|
||||
{
|
||||
variants: {
|
||||
disabled: true,
|
||||
error: true,
|
||||
},
|
||||
style: sprinkles({
|
||||
backgroundColor: "surfaceCriticalSubdued",
|
||||
borderColor: "criticalHighlight",
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
11
src/modules/ui/atoms/Skeleton/Skeleton.css.ts
Normal file
11
src/modules/ui/atoms/Skeleton/Skeleton.css.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { keyframes, style } from "@vanilla-extract/css";
|
||||
|
||||
const pulse = keyframes({
|
||||
"50%": {
|
||||
opacity: "0.5",
|
||||
},
|
||||
});
|
||||
|
||||
export const skeleton = style({
|
||||
animation: `${pulse} 2s cubic-bezier(.4,0,.6,1) infinite`,
|
||||
});
|
||||
16
src/modules/ui/atoms/Skeleton/Skeleton.tsx
Normal file
16
src/modules/ui/atoms/Skeleton/Skeleton.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Box, type BoxProps } from "@saleor/macaw-ui/next";
|
||||
import classNames from "classnames";
|
||||
import { skeleton } from "./Skeleton.css";
|
||||
|
||||
export const Skeleton = ({ className, ...props }: BoxProps) => {
|
||||
return (
|
||||
<Box
|
||||
className={classNames(skeleton, className)}
|
||||
backgroundColor="interactiveNeutralSecondaryPressing"
|
||||
width="100%"
|
||||
height={1}
|
||||
borderRadius={2}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
22
src/modules/ui/atoms/Table/Table.tsx
Normal file
22
src/modules/ui/atoms/Table/Table.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import classNames from "classnames";
|
||||
import { type ReactNode } from "react";
|
||||
import * as tableStyles from "./table.css";
|
||||
|
||||
export const Table = ({ children, className }: { children: ReactNode; className?: string }) => (
|
||||
<table className={classNames(tableStyles.table, className)}>{children}</table>
|
||||
);
|
||||
export const Thead = ({ children, className }: { children: ReactNode; className?: string }) => (
|
||||
<thead className={classNames(tableStyles.thead, className)}>{children}</thead>
|
||||
);
|
||||
export const Tr = ({ children, className }: { children: ReactNode; className?: string }) => (
|
||||
<tr className={classNames(tableStyles.tr, className)}>{children}</tr>
|
||||
);
|
||||
export const Th = ({ children, className }: { children: ReactNode; className?: string }) => (
|
||||
<th className={classNames(tableStyles.th, className)}>{children}</th>
|
||||
);
|
||||
export const Tbody = ({ children, className }: { children: ReactNode; className?: string }) => (
|
||||
<tbody className={classNames(tableStyles.tbody, className)}>{children}</tbody>
|
||||
);
|
||||
export const Td = ({ children, className }: { children?: ReactNode; className?: string }) => (
|
||||
<td className={classNames(tableStyles.td, className)}>{children}</td>
|
||||
);
|
||||
74
src/modules/ui/atoms/Table/table.css.ts
Normal file
74
src/modules/ui/atoms/Table/table.css.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { sprinkles } from "@saleor/macaw-ui/next";
|
||||
import { style } from "@vanilla-extract/css";
|
||||
|
||||
export const table = style(
|
||||
[
|
||||
sprinkles({ width: "100%" }),
|
||||
{
|
||||
borderCollapse: "collapse",
|
||||
},
|
||||
],
|
||||
"table",
|
||||
);
|
||||
export const thead = style([sprinkles({})], "thead");
|
||||
export const tbody = style([sprinkles({})], "tbody");
|
||||
|
||||
export const tr = style([sprinkles({})], "tr");
|
||||
// --mu-space-0: 0px;
|
||||
// --mu-space-1: 1px;
|
||||
// --mu-space-2: 2px;
|
||||
// --mu-space-3: 4px;
|
||||
// --mu-space-4: 6px;
|
||||
// --mu-space-5: 8px;
|
||||
// --mu-space-6: 12px;
|
||||
// --mu-space-7: 16px;
|
||||
// --mu-space-8: 20px;
|
||||
// --mu-space-9: 24px;
|
||||
// --mu-space-10: 30px;
|
||||
// --mu-space-11: 32px;
|
||||
// --mu-space-12: 38px;
|
||||
// --mu-space-13: 40px;
|
||||
|
||||
export const th = style(
|
||||
[
|
||||
sprinkles({
|
||||
color: "textNeutralSubdued",
|
||||
fontSize: "captionMedium",
|
||||
fontWeight: "captionMedium",
|
||||
paddingTop: 0.5,
|
||||
paddingBottom: 3,
|
||||
paddingLeft: 8,
|
||||
}),
|
||||
{
|
||||
textAlign: "left",
|
||||
selectors: {
|
||||
"&:first-child": {
|
||||
paddingLeft: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
"th",
|
||||
);
|
||||
|
||||
export const td = style(
|
||||
[
|
||||
sprinkles({
|
||||
paddingTop: 2,
|
||||
paddingBottom: 3,
|
||||
borderTopWidth: 1,
|
||||
borderTopStyle: "solid",
|
||||
borderColor: "neutralPlain",
|
||||
paddingLeft: 8,
|
||||
}),
|
||||
{
|
||||
verticalAlign: "top",
|
||||
selectors: {
|
||||
"&:first-child": {
|
||||
paddingLeft: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
"td",
|
||||
);
|
||||
24
src/modules/ui/atoms/macaw-ui/FormFileUploadInput.tsx
Normal file
24
src/modules/ui/atoms/macaw-ui/FormFileUploadInput.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
type UseControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
useController,
|
||||
} from "react-hook-form";
|
||||
import {
|
||||
FileUploadInput as $FileUploadInput,
|
||||
type FileUploadInputProps as $FileUploadInputProps,
|
||||
} from "../FileUploadInput/FileUploadInput";
|
||||
|
||||
export type FormFileUploadInputProps<
|
||||
TFieldValues extends FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = UseControllerProps<TFieldValues, TName> & $FileUploadInputProps;
|
||||
|
||||
export function FormFileUploadInput<
|
||||
TFieldValues extends FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>(props: FormFileUploadInputProps<TFieldValues, TName>) {
|
||||
const { field } = useController<TFieldValues, TName>(props);
|
||||
|
||||
return <$FileUploadInput {...props} {...field} />;
|
||||
}
|
||||
39
src/modules/ui/atoms/macaw-ui/FormInput.tsx
Normal file
39
src/modules/ui/atoms/macaw-ui/FormInput.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Input as $Input, type InputProps as $InputProps } from "@saleor/macaw-ui/next";
|
||||
import {
|
||||
type UseControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
useController,
|
||||
} from "react-hook-form";
|
||||
|
||||
export type FormInputProps<
|
||||
TFieldValues extends FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = UseControllerProps<TFieldValues, TName> & $InputProps;
|
||||
|
||||
export function FormInput<
|
||||
TFieldValues extends FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>(props: FormInputProps<TFieldValues, TName>) {
|
||||
const { field, fieldState } = useController<TFieldValues, TName>(props);
|
||||
|
||||
return (
|
||||
<$Input
|
||||
error={!!fieldState.error?.message}
|
||||
{...props}
|
||||
{...field}
|
||||
helperText={fieldState.error?.message || props.helperText}
|
||||
onChange={(e) => {
|
||||
field.onChange(e);
|
||||
props.onChange?.(e);
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
props.onFocus?.(e);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
field.onBlur();
|
||||
props.onBlur?.(e);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
42
src/modules/ui/atoms/macaw-ui/FormSelect.tsx
Normal file
42
src/modules/ui/atoms/macaw-ui/FormSelect.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Select as $Select } from "@saleor/macaw-ui/next";
|
||||
import { type ComponentProps } from "react";
|
||||
import {
|
||||
type UseControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
useController,
|
||||
type PathValue,
|
||||
} from "react-hook-form";
|
||||
|
||||
type $FormSelectProps = ComponentProps<typeof $Select>;
|
||||
|
||||
export type FormSelectProps<
|
||||
TFieldValues extends FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = UseControllerProps<TFieldValues, TName> & Omit<$FormSelectProps, "value">;
|
||||
|
||||
export function FormSelect<
|
||||
TFieldValues extends FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>(props: FormSelectProps<TFieldValues, TName>) {
|
||||
const { field, fieldState } = useController<TFieldValues, TName>(props);
|
||||
|
||||
return (
|
||||
<$Select
|
||||
error={!!fieldState.error?.message}
|
||||
{...props}
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(e as PathValue<TFieldValues, TName>);
|
||||
props.onChange?.(e);
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
props.onFocus?.(e);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
field.onBlur();
|
||||
props.onBlur?.(e);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
42
src/modules/ui/atoms/macaw-ui/RadioGroup.tsx
Normal file
42
src/modules/ui/atoms/macaw-ui/RadioGroup.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { RadioGroup as $RadioGroup } from "@saleor/macaw-ui/next";
|
||||
import { type ComponentProps } from "react";
|
||||
import {
|
||||
type UseControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
useController,
|
||||
type PathValue,
|
||||
} from "react-hook-form";
|
||||
|
||||
type $RadioGroupProps = ComponentProps<typeof $RadioGroup>;
|
||||
|
||||
export type FormRadioGroupProps<
|
||||
TFieldValues extends FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = UseControllerProps<TFieldValues, TName> & $RadioGroupProps;
|
||||
|
||||
export function FormRadioGroup<
|
||||
TFieldValues extends FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>(props: FormRadioGroupProps<TFieldValues, TName>) {
|
||||
const { field, fieldState } = useController<TFieldValues, TName>(props);
|
||||
|
||||
return (
|
||||
<$RadioGroup
|
||||
error={!!fieldState.error?.message}
|
||||
{...props}
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(e as PathValue<TFieldValues, TName>);
|
||||
props.onChange?.(e);
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
props.onFocus?.(e);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
field.onBlur();
|
||||
props.onBlur?.(e);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
35
src/modules/ui/atoms/modal.css.ts
Normal file
35
src/modules/ui/atoms/modal.css.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { sprinkles } from "@saleor/macaw-ui/next";
|
||||
import { style } from "@vanilla-extract/css";
|
||||
|
||||
export const modal = style(
|
||||
[
|
||||
sprinkles({
|
||||
backgroundColor: "surfaceNeutralPlain",
|
||||
borderRadius: 4,
|
||||
position: "fixed",
|
||||
padding: 5,
|
||||
boxShadow: "modal",
|
||||
}),
|
||||
{
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
maxWidth: "30rem",
|
||||
},
|
||||
],
|
||||
"modal",
|
||||
);
|
||||
|
||||
export const modalOverlay = style(
|
||||
[
|
||||
sprinkles({
|
||||
backgroundColor: "surfaceNeutralSubdued",
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
}),
|
||||
{
|
||||
opacity: 0.6,
|
||||
},
|
||||
],
|
||||
"modalOverlay",
|
||||
);
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Box } from "@saleor/macaw-ui/next";
|
||||
import { ChipSuccess, ChipStripeOrange, ChipInfo } from "@/modules/ui/atoms/Chip/Chip";
|
||||
import { type PaymentAppUserVisibleConfigEntry } from "@/modules/payment-app-configuration/config-entry";
|
||||
import { getEnvironmentFromKey, getStripeWebhookDashboardLink } from "@/modules/stripe/stripe-api";
|
||||
import { appBridgeInstance } from "@/app-bridge-instance";
|
||||
|
||||
export const ConfigurationSummary = ({ config }: { config: PaymentAppUserVisibleConfigEntry }) => {
|
||||
return (
|
||||
<Box
|
||||
as="dl"
|
||||
display="grid"
|
||||
__gridTemplateColumns="max-content 1fr"
|
||||
rowGap={2}
|
||||
columnGap={2}
|
||||
alignItems="center"
|
||||
margin={0}
|
||||
>
|
||||
<Box as="dt" margin={0} fontSize="captionSmall" color="textNeutralSubdued">
|
||||
Environment
|
||||
</Box>
|
||||
<Box as="dd" margin={0} textAlign="right">
|
||||
{getEnvironmentFromKey(config.publishableKey) === "live" ? (
|
||||
<ChipSuccess>LIVE</ChipSuccess>
|
||||
) : (
|
||||
<ChipStripeOrange>TESTING</ChipStripeOrange>
|
||||
)}
|
||||
</Box>
|
||||
<Box as="dt" margin={0} fontSize="captionSmall" color="textNeutralSubdued">
|
||||
Webhook ID
|
||||
</Box>
|
||||
<Box as="dd" margin={0} textAlign="right">
|
||||
<a
|
||||
href={getStripeWebhookDashboardLink(
|
||||
config.webhookId,
|
||||
getEnvironmentFromKey(config.publishableKey),
|
||||
)}
|
||||
onClick={() =>
|
||||
void appBridgeInstance?.dispatch({
|
||||
type: "redirect",
|
||||
payload: {
|
||||
actionId: "getStripeWebhookDashboardLink",
|
||||
to: getStripeWebhookDashboardLink(
|
||||
config.webhookId,
|
||||
getEnvironmentFromKey(config.publishableKey),
|
||||
),
|
||||
newContext: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
target="_blank"
|
||||
>
|
||||
<ChipInfo>{config.webhookId}</ChipInfo>
|
||||
</a>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,153 @@
|
||||
import { Combobox, Text } from "@saleor/macaw-ui/next";
|
||||
import classNames from "classnames";
|
||||
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
import * as tableStyles from "./channelToConfigurationTable.css";
|
||||
import { Table, Thead, Tr, Th, Tbody, Td } from "@/modules/ui/atoms/Table/Table";
|
||||
import { ChipStripeOrange, ChipNeutral, ChipSuccess } from "@/modules/ui/atoms/Chip/Chip";
|
||||
import { trpcClient } from "@/modules/trpc/trpc-client";
|
||||
import { type Channel } from "@/types";
|
||||
import { getErrorHandler } from "@/modules/trpc/utils";
|
||||
import {
|
||||
type PaymentAppUserVisibleEntries,
|
||||
type ChannelMapping,
|
||||
} from "@/modules/payment-app-configuration/app-config";
|
||||
import { type PaymentAppConfigEntry } from "@/modules/payment-app-configuration/config-entry";
|
||||
import { getEnvironmentFromKey } from "@/modules/stripe/stripe-api";
|
||||
|
||||
const ChannelToConfigurationTableRow = ({
|
||||
channel,
|
||||
configurations,
|
||||
selectedConfigurationId,
|
||||
disabled,
|
||||
}: {
|
||||
channel: Channel;
|
||||
configurations: PaymentAppUserVisibleEntries;
|
||||
selectedConfigurationId?: PaymentAppConfigEntry["configurationId"] | null;
|
||||
disabled?: boolean;
|
||||
}) => {
|
||||
const { appBridge } = useAppBridge();
|
||||
const selectedConfiguration = configurations.find(
|
||||
(config) => config.configurationId === selectedConfigurationId,
|
||||
);
|
||||
|
||||
const context = trpcClient.useContext();
|
||||
const { mutate: saveMapping } =
|
||||
trpcClient.paymentAppConfigurationRouter.mapping.update.useMutation({
|
||||
onSettled: () => {
|
||||
return context.paymentAppConfigurationRouter.mapping.getAll.invalidate();
|
||||
},
|
||||
onSuccess: () => {
|
||||
void appBridge?.dispatch({
|
||||
type: "notification",
|
||||
payload: {
|
||||
title: "Saved",
|
||||
status: "success",
|
||||
actionId: "ChannelToConfigurationTableRow",
|
||||
},
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
getErrorHandler({
|
||||
appBridge,
|
||||
actionId: "ChannelToConfigurationTableRow",
|
||||
message: "Error while saving mappings",
|
||||
title: "Mapping error",
|
||||
})(err);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Tr>
|
||||
<Td className={tableStyles.td}>
|
||||
<Text
|
||||
variant="bodyStrong"
|
||||
size="medium"
|
||||
color={selectedConfigurationId ? "textNeutralDefault" : "textNeutralDisabled"}
|
||||
>
|
||||
{channel.name}
|
||||
</Text>
|
||||
</Td>
|
||||
<Td className={classNames(tableStyles.td, tableStyles.dropdownColumnTd)}>
|
||||
<Combobox
|
||||
label="Configuration name"
|
||||
size="small"
|
||||
disabled={disabled}
|
||||
value={selectedConfigurationId || ""}
|
||||
options={[
|
||||
{ value: "", label: "(disabled)" },
|
||||
...configurations.map((c) => ({
|
||||
value: c.configurationId,
|
||||
label: c.configurationName,
|
||||
})),
|
||||
]}
|
||||
onChange={(e: string | { value: string | null } | null) => {
|
||||
const newMapping = {
|
||||
channelId: channel.id,
|
||||
configurationId: e === null || typeof e === "string" ? e : e.value,
|
||||
};
|
||||
context.paymentAppConfigurationRouter.mapping.getAll.setData(undefined, (mappings) => {
|
||||
return {
|
||||
...mappings,
|
||||
[newMapping.channelId]: newMapping.configurationId,
|
||||
};
|
||||
});
|
||||
saveMapping(newMapping);
|
||||
}}
|
||||
/>
|
||||
</Td>
|
||||
<Td className={classNames(tableStyles.td, tableStyles.statusColumnTd)}>
|
||||
{!selectedConfiguration ? (
|
||||
<ChipNeutral>Disabled</ChipNeutral>
|
||||
) : getEnvironmentFromKey(selectedConfiguration.publishableKey) === "live" ? (
|
||||
<ChipSuccess>LIVE</ChipSuccess>
|
||||
) : (
|
||||
<ChipStripeOrange>TESTING</ChipStripeOrange>
|
||||
)}
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
};
|
||||
|
||||
export const ChannelToConfigurationTable = ({
|
||||
channelMappings,
|
||||
channels,
|
||||
configurations,
|
||||
disabled,
|
||||
}: {
|
||||
channelMappings: ChannelMapping;
|
||||
channels: readonly Channel[];
|
||||
configurations: PaymentAppUserVisibleEntries;
|
||||
disabled?: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Saleor Channel</Th>
|
||||
<Th className={tableStyles.dropdownColumnTd}>Configuration</Th>
|
||||
<Th className={tableStyles.statusColumnTd}>
|
||||
<span className="visually-hidden">Status</span>
|
||||
</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{Object.entries(channelMappings).map(([channelId, configurationId]) => {
|
||||
const channel = channels.find((c) => c.id === channelId);
|
||||
if (!channel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ChannelToConfigurationTableRow
|
||||
key={channel.id}
|
||||
channel={channel}
|
||||
configurations={configurations}
|
||||
selectedConfigurationId={configurationId}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Tbody>
|
||||
</Table>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import { sprinkles } from "@saleor/macaw-ui/next";
|
||||
import { style } from "@vanilla-extract/css";
|
||||
|
||||
export const dropdownColumnTd = style(
|
||||
[
|
||||
sprinkles({
|
||||
paddingLeft: 10,
|
||||
}),
|
||||
{
|
||||
width: "50%",
|
||||
},
|
||||
],
|
||||
"summaryColumnTd",
|
||||
);
|
||||
export const statusColumnTd = style(
|
||||
[
|
||||
sprinkles({
|
||||
textAlign: "right",
|
||||
paddingLeft: 10,
|
||||
}),
|
||||
],
|
||||
"actionsColumnTd",
|
||||
);
|
||||
export const td = style([{ verticalAlign: "middle" }], "td");
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Text, EditIcon } from "@saleor/macaw-ui/next";
|
||||
import Link from "next/link";
|
||||
import { ConfigurationSummary } from "../ConfigurationSummary/ConfigurationSummary";
|
||||
import * as tableStyles from "./configurationsTable.css";
|
||||
import { Tr, Td, Table, Tbody, Th, Thead } from "@/modules/ui/atoms/Table/Table";
|
||||
import { type PaymentAppUserVisibleConfigEntry } from "@/modules/payment-app-configuration/config-entry";
|
||||
import { type PaymentAppUserVisibleEntries } from "@/modules/payment-app-configuration/app-config";
|
||||
|
||||
const ConfigurationsTableRow = ({ item }: { item: PaymentAppUserVisibleConfigEntry }) => {
|
||||
return (
|
||||
<Tr>
|
||||
<Td>
|
||||
<Text variant="bodyStrong" size="medium">
|
||||
{item.configurationName}
|
||||
</Text>
|
||||
</Td>
|
||||
<Td className={tableStyles.summaryColumnTd}>
|
||||
<ConfigurationSummary config={item} />
|
||||
</Td>
|
||||
<Td className={tableStyles.actionsColumnTd}>
|
||||
<Link href={`/configurations/edit/${item.configurationId}`} passHref legacyBehavior>
|
||||
<Text
|
||||
as="a"
|
||||
size="medium"
|
||||
color="textNeutralSubdued"
|
||||
textDecoration="none"
|
||||
display="inline-flex"
|
||||
alignItems="center"
|
||||
>
|
||||
<EditIcon size="small" />
|
||||
Edit
|
||||
</Text>
|
||||
</Link>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
};
|
||||
|
||||
export const ConfigurationsTable = ({
|
||||
configurations,
|
||||
}: {
|
||||
configurations: PaymentAppUserVisibleEntries;
|
||||
}) => {
|
||||
return (
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Configuration name</Th>
|
||||
<Th className={tableStyles.summaryColumnTd}>Stripe Configuration</Th>
|
||||
<Th className={tableStyles.actionsColumnTd}>
|
||||
<span className="visually-hidden">Actions</span>
|
||||
</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{configurations.map((item) => (
|
||||
<ConfigurationsTableRow key={item.configurationId} item={item} />
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import { sprinkles } from "@saleor/macaw-ui/next";
|
||||
import { style } from "@vanilla-extract/css";
|
||||
|
||||
export const summaryColumnTd = style(
|
||||
[
|
||||
sprinkles({
|
||||
paddingLeft: 8,
|
||||
}),
|
||||
{
|
||||
width: "50%",
|
||||
},
|
||||
],
|
||||
"summaryColumnTd",
|
||||
);
|
||||
export const actionsColumnTd = style(
|
||||
[
|
||||
sprinkles({
|
||||
textAlign: "right",
|
||||
}),
|
||||
],
|
||||
"actionsColumnTd",
|
||||
);
|
||||
@@ -0,0 +1,118 @@
|
||||
import * as AlertDialog from "@radix-ui/react-alert-dialog";
|
||||
import { Box, Button, TrashBinIcon, type ButtonProps, Text, Input } from "@saleor/macaw-ui/next";
|
||||
import { type MouseEventHandler, useCallback, useState } from "react";
|
||||
import { modalOverlay, modal } from "../../atoms/modal.css";
|
||||
|
||||
type ConfirmationButtonProps = Omit<ButtonProps, "type" | "onClick"> & {
|
||||
onClick: () => void | Promise<void>;
|
||||
configurationName: string;
|
||||
};
|
||||
|
||||
type State = "idle" | "prompt" | "inProgress";
|
||||
|
||||
export const ConfirmationButton = ({
|
||||
onClick,
|
||||
configurationName,
|
||||
...props
|
||||
}: ConfirmationButtonProps) => {
|
||||
const [state, setState] = useState<State>("idle");
|
||||
const [inputConfiguratioName, setInputConfiguratioName] = useState("");
|
||||
|
||||
const handleDeleteClick = useCallback<MouseEventHandler<HTMLButtonElement>>((e) => {
|
||||
e.preventDefault();
|
||||
setState("prompt");
|
||||
}, []);
|
||||
|
||||
const handleConfirmationClick = useCallback<MouseEventHandler<HTMLButtonElement>>(
|
||||
async (e) => {
|
||||
e.preventDefault();
|
||||
setState("inProgress");
|
||||
await onClick();
|
||||
setState("idle");
|
||||
},
|
||||
[onClick],
|
||||
);
|
||||
|
||||
const handleOpenChange = useCallback((open: boolean) => {
|
||||
setState(open ? "prompt" : "idle");
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AlertDialog.Root
|
||||
open={state === "prompt" || state === "inProgress"}
|
||||
onOpenChange={handleOpenChange}
|
||||
>
|
||||
<AlertDialog.Trigger asChild>
|
||||
<Button {...props} type="button" onClick={handleDeleteClick} />
|
||||
</AlertDialog.Trigger>
|
||||
<AlertDialog.Portal>
|
||||
<AlertDialog.Overlay className={modalOverlay} />
|
||||
<AlertDialog.Content className={modal}>
|
||||
<AlertDialog.Title asChild>
|
||||
<Text variant="title" size="medium">
|
||||
Delete {configurationName}
|
||||
</Text>
|
||||
</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
<Box display="flex" flexDirection="column" rowGap={4}>
|
||||
<Box>
|
||||
<Text
|
||||
color={state === "inProgress" ? "textNeutralDisabled" : "textNeutralDefault"}
|
||||
as="p"
|
||||
variant="body"
|
||||
size="medium"
|
||||
>
|
||||
Are you sure you want to delete the configuration?
|
||||
</Text>
|
||||
<Text
|
||||
as="p"
|
||||
variant="body"
|
||||
size="medium"
|
||||
color={state === "inProgress" ? "textNeutralDisabled" : "textNeutralDefault"}
|
||||
>
|
||||
Type the configuration name to confirm:{" "}
|
||||
<Text
|
||||
as="strong"
|
||||
variant="bodyStrong"
|
||||
display="inline-block"
|
||||
wordBreak="break-word"
|
||||
size="medium"
|
||||
color={state === "inProgress" ? "textNeutralDisabled" : "textNeutralDefault"}
|
||||
>
|
||||
{configurationName}
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
<Input
|
||||
label="Configuration name"
|
||||
value={inputConfiguratioName}
|
||||
onChange={(e) => setInputConfiguratioName(e.currentTarget.value)}
|
||||
disabled={state === "inProgress"}
|
||||
/>
|
||||
</Box>
|
||||
</AlertDialog.Description>
|
||||
|
||||
<Box display="flex" justifyContent="flex-end" gap={1.5} marginTop={5}>
|
||||
<AlertDialog.Cancel asChild>
|
||||
<Button type="button" size="medium" disabled={state === "inProgress"}>
|
||||
Cancel
|
||||
</Button>
|
||||
</AlertDialog.Cancel>
|
||||
<Button
|
||||
type="button"
|
||||
size="medium"
|
||||
variant="error"
|
||||
disabled={inputConfiguratioName !== configurationName || state === "inProgress"}
|
||||
onClick={handleConfirmationClick}
|
||||
icon={<TrashBinIcon />}
|
||||
>
|
||||
{state === "inProgress" ? "Deleting..." : "Delete"}
|
||||
</Button>
|
||||
</Box>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Portal>
|
||||
</AlertDialog.Root>
|
||||
</>
|
||||
);
|
||||
};
|
||||
7
src/modules/ui/molecules/FullPageError/FullPageError.tsx
Normal file
7
src/modules/ui/molecules/FullPageError/FullPageError.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Box } from "@saleor/macaw-ui/next";
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
export const FullPageError = ({ children }: { children: ReactNode }) => {
|
||||
// @todo
|
||||
return <Box>{children}</Box>;
|
||||
};
|
||||
19
src/modules/ui/no-ssr-wrapper.tsx
Normal file
19
src/modules/ui/no-ssr-wrapper.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React, { type PropsWithChildren } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const Wrapper = (props: PropsWithChildren<{}>) => <React.Fragment>{props.children}</React.Fragment>;
|
||||
|
||||
/**
|
||||
* Saleor App can be rendered only as a Saleor Dashboard iframe.
|
||||
* All content is rendered after Dashboard exchanges auth with the app.
|
||||
* Hence, there is no reason to render app server side.
|
||||
*
|
||||
* This component forces app to work in SPA-mode. It simplifies browser-only code and reduces need
|
||||
* of using dynamic() calls
|
||||
*
|
||||
* You can use this wrapper selectively for some pages or remove it completely.
|
||||
* It doesn't affect Saleor communication, but may cause problems with some client-only code.
|
||||
*/
|
||||
export const NoSSRWrapper = dynamic(() => Promise.resolve(Wrapper), {
|
||||
ssr: false,
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Text } from "@saleor/macaw-ui/next";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
import { useForm, FormProvider } from "react-hook-form";
|
||||
import { AppLayoutRow } from "../../templates/AppLayout";
|
||||
import { FullPageError } from "../../molecules/FullPageError/FullPageError";
|
||||
import { AddStripeCredentialsForm } from "./AddStripeCredentialsForm";
|
||||
import { DeleteStripeConfigurationForm } from "./DeleteStripeConfigurationForm";
|
||||
import { checkTokenPermissions } from "@/modules/jwt/check-token-offline";
|
||||
import { REQUIRED_SALEOR_PERMISSIONS } from "@/modules/jwt/consts";
|
||||
import { trpcClient } from "@/modules/trpc/trpc-client";
|
||||
import {
|
||||
type PaymentAppFormConfigEntry,
|
||||
paymentAppFormConfigEntrySchema,
|
||||
} from "@/modules/payment-app-configuration/config-entry";
|
||||
|
||||
export const StripeConfigurationForm = ({
|
||||
configurationId,
|
||||
}: {
|
||||
configurationId: string | undefined | null;
|
||||
}) => {
|
||||
const { appBridgeState } = useAppBridge();
|
||||
const { token } = appBridgeState ?? {};
|
||||
|
||||
const hasPermissions = true || checkTokenPermissions(token, REQUIRED_SALEOR_PERMISSIONS);
|
||||
|
||||
const formMethods = useForm<PaymentAppFormConfigEntry>({
|
||||
resolver: zodResolver(paymentAppFormConfigEntrySchema),
|
||||
defaultValues: {
|
||||
configurationName: "",
|
||||
publishableKey: "",
|
||||
secretKey: "",
|
||||
},
|
||||
});
|
||||
|
||||
const { data } = trpcClient.paymentAppConfigurationRouter.paymentConfig.get.useQuery(
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- query is not enabled if configurationId is missing
|
||||
{ configurationId: configurationId! },
|
||||
{ enabled: !!configurationId },
|
||||
);
|
||||
|
||||
if (!hasPermissions) {
|
||||
return (
|
||||
<FullPageError>
|
||||
<Text variant="hero">{"You don't have permissions to configure this app."}</Text>
|
||||
</FullPageError>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...formMethods}>
|
||||
<AppLayoutRow title="Stripe Credentials" description="Enter Private API Key from Stripe.">
|
||||
<AddStripeCredentialsForm configurationId={configurationId} />
|
||||
</AppLayoutRow>
|
||||
{data && configurationId && (
|
||||
<AppLayoutRow error={true} title="Danger zone">
|
||||
<DeleteStripeConfigurationForm
|
||||
configurationName={data.configurationName}
|
||||
configurationId={configurationId}
|
||||
/>
|
||||
</AppLayoutRow>
|
||||
)}
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,157 @@
|
||||
import { Button, Box } from "@saleor/macaw-ui/next";
|
||||
import { type SubmitHandler, useFormContext } from "react-hook-form";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
import { useRouter } from "next/router";
|
||||
import { RoundedBoxWithFooter } from "../../atoms/RoundedActionBox/RoundedActionBox";
|
||||
import { FormInput } from "@/modules/ui/atoms/macaw-ui/FormInput";
|
||||
import { trpcClient } from "@/modules/trpc/trpc-client";
|
||||
import { getErrorHandler, getFieldErrorHandler, getFormFields } from "@/modules/trpc/utils";
|
||||
import { invariant } from "@/lib/invariant";
|
||||
import { type PaymentAppFormConfigEntry } from "@/modules/payment-app-configuration/config-entry";
|
||||
|
||||
const actionId = "payment-form";
|
||||
|
||||
export const AddStripeCredentialsForm = ({
|
||||
configurationId,
|
||||
}: {
|
||||
configurationId?: string | undefined | null;
|
||||
}) => {
|
||||
const formMethods = useFormContext<PaymentAppFormConfigEntry>();
|
||||
const { appBridge } = useAppBridge();
|
||||
const router = useRouter();
|
||||
|
||||
const context = trpcClient.useContext();
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
reset,
|
||||
setError,
|
||||
control,
|
||||
formState: { defaultValues },
|
||||
} = formMethods;
|
||||
const { data: stripeConfigurationData } =
|
||||
trpcClient.paymentAppConfigurationRouter.paymentConfig.get.useQuery(
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- query is not enabled if configurationId is missing
|
||||
{ configurationId: configurationId! },
|
||||
{
|
||||
enabled: !!configurationId,
|
||||
onError: (err) => {
|
||||
getErrorHandler({
|
||||
appBridge,
|
||||
actionId,
|
||||
message: "Error while fetching initial form data",
|
||||
title: "Form error",
|
||||
})(err);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (stripeConfigurationData) {
|
||||
reset(stripeConfigurationData);
|
||||
}
|
||||
}, [stripeConfigurationData, reset]);
|
||||
|
||||
const getOnSuccess = useCallback(
|
||||
(message: string) => {
|
||||
return () => {
|
||||
void appBridge?.dispatch({
|
||||
type: "notification",
|
||||
payload: {
|
||||
title: "Form saved",
|
||||
text: message,
|
||||
status: "success",
|
||||
actionId,
|
||||
},
|
||||
});
|
||||
void context.paymentAppConfigurationRouter.paymentConfig.invalidate();
|
||||
};
|
||||
},
|
||||
[appBridge, context.paymentAppConfigurationRouter.paymentConfig],
|
||||
);
|
||||
|
||||
const onError = getFieldErrorHandler({
|
||||
appBridge,
|
||||
setError,
|
||||
actionId,
|
||||
fieldName: "root",
|
||||
formFields: getFormFields(defaultValues),
|
||||
});
|
||||
|
||||
const { mutate: updateConfig } =
|
||||
trpcClient.paymentAppConfigurationRouter.paymentConfig.update.useMutation({
|
||||
onSuccess: (data) => {
|
||||
invariant(data.configurationId);
|
||||
getOnSuccess("App configuration was updated successfully")();
|
||||
return router.replace(`/configurations/edit/${data.configurationId}`);
|
||||
},
|
||||
onError,
|
||||
});
|
||||
const { mutate: addNewConfig } =
|
||||
trpcClient.paymentAppConfigurationRouter.paymentConfig.add.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
invariant(data.configurationId);
|
||||
context.paymentAppConfigurationRouter.paymentConfig.get.setData(
|
||||
{ configurationId: data.configurationId },
|
||||
data,
|
||||
);
|
||||
if (!configurationId) {
|
||||
await router.replace(`/configurations/edit/${data.configurationId}`);
|
||||
}
|
||||
},
|
||||
onError,
|
||||
});
|
||||
|
||||
const handleConfigSave: SubmitHandler<PaymentAppFormConfigEntry> = (data) => {
|
||||
configurationId
|
||||
? updateConfig({
|
||||
configurationId,
|
||||
entry: data,
|
||||
})
|
||||
: addNewConfig(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<RoundedBoxWithFooter
|
||||
as="form"
|
||||
method="POST"
|
||||
autoComplete="off"
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
onSubmit={handleSubmit(handleConfigSave)}
|
||||
footer={
|
||||
<Box display="flex" flexDirection="row" columnGap={4}>
|
||||
<Button variant="primary" size="medium" type="submit">
|
||||
Save
|
||||
</Button>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Box paddingBottom={6} rowGap={4} display="flex" flexDirection="column" width="100%">
|
||||
<FormInput
|
||||
control={control}
|
||||
label="Configuration name"
|
||||
helperText="Enter configuration name that uniquely identifies this configuration. This name will be used later to assign configuration to Saleor Channels."
|
||||
name="configurationName"
|
||||
autoComplete="off"
|
||||
size="medium"
|
||||
/>
|
||||
<FormInput
|
||||
control={control}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
label="Secret Key"
|
||||
name="secretKey"
|
||||
size="medium"
|
||||
/>
|
||||
<FormInput
|
||||
control={control}
|
||||
autoComplete="off"
|
||||
label="Publishable Key"
|
||||
name="publishableKey"
|
||||
size="medium"
|
||||
/>
|
||||
</Box>
|
||||
</RoundedBoxWithFooter>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Box, Text } from "@saleor/macaw-ui/next";
|
||||
import { useRouter } from "next/router";
|
||||
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
import { RoundedBoxWithFooter } from "../../atoms/RoundedActionBox/RoundedActionBox";
|
||||
import { ConfirmationButton } from "../../molecules/ConfirmationButton/ConfirmationButton";
|
||||
import { trpcClient } from "@/modules/trpc/trpc-client";
|
||||
import { getErrorHandler } from "@/modules/trpc/utils";
|
||||
|
||||
export const DeleteStripeConfigurationForm = ({
|
||||
configurationId,
|
||||
configurationName,
|
||||
}: {
|
||||
configurationId: string | null | undefined;
|
||||
configurationName: string;
|
||||
}) => {
|
||||
const context = trpcClient.useContext();
|
||||
const router = useRouter();
|
||||
const { appBridge } = useAppBridge();
|
||||
|
||||
const { mutateAsync: deleteConfig } =
|
||||
trpcClient.paymentAppConfigurationRouter.paymentConfig.delete.useMutation({
|
||||
onError: (err) => {
|
||||
getErrorHandler({
|
||||
appBridge,
|
||||
actionId: "DeleteStripeConfigurationForm",
|
||||
message: "Error while deleting configuration",
|
||||
title: "Error",
|
||||
})(err);
|
||||
},
|
||||
});
|
||||
|
||||
const handleConfigDelete = async () => {
|
||||
if (!configurationId) {
|
||||
return;
|
||||
}
|
||||
await deleteConfig({ configurationId });
|
||||
await router.replace("/configurations/list");
|
||||
await context.paymentAppConfigurationRouter.invalidate();
|
||||
};
|
||||
|
||||
return (
|
||||
<RoundedBoxWithFooter
|
||||
error={true}
|
||||
footer={
|
||||
<Box display="flex" flexDirection="row" width="100%">
|
||||
<ConfirmationButton
|
||||
configurationName={configurationName}
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
onClick={handleConfigDelete}
|
||||
variant="error"
|
||||
size="medium"
|
||||
>
|
||||
Delete configuration
|
||||
</ConfirmationButton>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<form method="POST">
|
||||
<Box paddingBottom={6} rowGap={6} display="flex" flexDirection="column">
|
||||
<Text as="h3" variant="heading" size="medium">
|
||||
Remove configuration
|
||||
</Text>
|
||||
<Box display="flex" flexDirection="column" rowGap={5}>
|
||||
<Text as="p" variant="body" size="medium">
|
||||
You can remove the configuration{" "}
|
||||
<Text as="strong" variant="bodyStrong" size="medium">
|
||||
{configurationName}
|
||||
</Text>
|
||||
</Text>
|
||||
<Text as="p" variant="body" size="medium">
|
||||
This operation will permanently remove all settings related to this configuration and
|
||||
disable Stripe in all assigned channels.
|
||||
</Text>
|
||||
<Text as="p" variant="body" size="medium">
|
||||
This operation cannot be undone.{" "}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</form>
|
||||
</RoundedBoxWithFooter>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Box, Button, Text } from "@saleor/macaw-ui/next";
|
||||
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
import { RoundedActionBox, RoundedBox } from "../../atoms/RoundedActionBox/RoundedActionBox";
|
||||
import { ChannelToConfigurationTable } from "../../molecules/ConfigurationsTable/ChannelToConfigurationTable/ChannelToConfigurationTable";
|
||||
import { type Channel } from "@/types";
|
||||
import {
|
||||
type PaymentAppUserVisibleEntries,
|
||||
type ChannelMapping,
|
||||
} from "@/modules/payment-app-configuration/app-config";
|
||||
|
||||
export const ChannelToConfigurationList = ({
|
||||
channelMappings,
|
||||
configurations,
|
||||
channels,
|
||||
disabled,
|
||||
}: {
|
||||
channelMappings: ChannelMapping;
|
||||
configurations: PaymentAppUserVisibleEntries;
|
||||
channels: readonly Channel[];
|
||||
disabled?: boolean;
|
||||
}) => {
|
||||
return Object.keys(channelMappings).length > 0 ? (
|
||||
<NotEmpty
|
||||
disabled={disabled}
|
||||
channelMappings={channelMappings}
|
||||
configurations={configurations}
|
||||
channels={channels}
|
||||
/>
|
||||
) : (
|
||||
<Empty disabled={disabled} />
|
||||
);
|
||||
};
|
||||
|
||||
const NotEmpty = ({
|
||||
channelMappings,
|
||||
configurations,
|
||||
channels,
|
||||
disabled,
|
||||
}: {
|
||||
channelMappings: ChannelMapping;
|
||||
configurations: PaymentAppUserVisibleEntries;
|
||||
channels: readonly Channel[];
|
||||
disabled?: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<RoundedBox>
|
||||
<Box paddingX={6} paddingTop={4} paddingBottom={6} display="flex">
|
||||
<ChannelToConfigurationTable
|
||||
disabled={disabled}
|
||||
channels={channels}
|
||||
configurations={configurations}
|
||||
channelMappings={channelMappings}
|
||||
/>
|
||||
</Box>
|
||||
</RoundedBox>
|
||||
);
|
||||
};
|
||||
|
||||
const Empty = ({ disabled }: { disabled?: boolean }) => {
|
||||
const { appBridge } = useAppBridge();
|
||||
|
||||
return (
|
||||
<RoundedActionBox>
|
||||
<Box>
|
||||
<Text as="p" variant="body" size="medium" color="textCriticalDefault">
|
||||
It appears you have 0 channels.
|
||||
</Text>
|
||||
<Text as="p" variant="body" size="medium" color="textCriticalDefault">
|
||||
You need to add at least one channel to proceed.
|
||||
</Text>
|
||||
</Box>
|
||||
<Button
|
||||
size="large"
|
||||
variant="primary"
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void appBridge?.dispatch({
|
||||
type: "redirect",
|
||||
payload: { actionId: "go-to-channels", to: "/channels/" },
|
||||
});
|
||||
}}
|
||||
>
|
||||
Configure channels
|
||||
</Button>
|
||||
</RoundedActionBox>
|
||||
);
|
||||
};
|
||||
42
src/modules/ui/organisms/GlobalErrorModal/modal.tsx
Normal file
42
src/modules/ui/organisms/GlobalErrorModal/modal.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import * as AlertDialog from "@radix-ui/react-alert-dialog";
|
||||
import { Box, Button, Text } from "@saleor/macaw-ui/next";
|
||||
import { modalOverlay, modal } from "../../atoms/modal.css";
|
||||
import {
|
||||
useErrorModalActions,
|
||||
useErrorModalMessage,
|
||||
useErrorModalOpen,
|
||||
useErrorModalTitle,
|
||||
} from "./state";
|
||||
|
||||
export const ErrorModal = () => {
|
||||
const isOpen = useErrorModalOpen();
|
||||
const title = useErrorModalTitle();
|
||||
const message = useErrorModalMessage();
|
||||
const { closeModal } = useErrorModalActions();
|
||||
|
||||
return (
|
||||
<AlertDialog.Root open={isOpen}>
|
||||
<AlertDialog.Portal>
|
||||
<AlertDialog.Overlay className={modalOverlay} />
|
||||
<AlertDialog.Content className={modal}>
|
||||
{title && (
|
||||
<AlertDialog.Title asChild>
|
||||
<Text variant="title">Confirm delete</Text>
|
||||
</AlertDialog.Title>
|
||||
)}
|
||||
<AlertDialog.Description>
|
||||
<Text variant="bodyEmp">{message}</Text>
|
||||
</AlertDialog.Description>
|
||||
|
||||
<Box display="flex" justifyContent="flex-end" gap={1.5} marginTop={5}>
|
||||
<AlertDialog.Cancel asChild>
|
||||
<Button type="button" size="large" onClick={() => closeModal()}>
|
||||
Close
|
||||
</Button>
|
||||
</AlertDialog.Cancel>
|
||||
</Box>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Portal>
|
||||
</AlertDialog.Root>
|
||||
);
|
||||
};
|
||||
29
src/modules/ui/organisms/GlobalErrorModal/state.ts
Normal file
29
src/modules/ui/organisms/GlobalErrorModal/state.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
export interface ErrorModalState {
|
||||
isOpen: boolean;
|
||||
title?: string;
|
||||
message: string | null;
|
||||
|
||||
actions: {
|
||||
openModal: ({ message, title }: { message: string; title?: string }) => void;
|
||||
closeModal: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
export const useErrorModalStore = create<ErrorModalState>((set) => ({
|
||||
isOpen: false,
|
||||
message: null,
|
||||
title: undefined,
|
||||
|
||||
actions: {
|
||||
openModal: ({ message, title }: { message: string; title?: string }) =>
|
||||
set(() => ({ isOpen: true, message, title })),
|
||||
closeModal: () => set(() => ({ isOpen: false, message: null, title: undefined })),
|
||||
},
|
||||
}));
|
||||
|
||||
export const useErrorModalOpen = () => useErrorModalStore((state) => state.isOpen);
|
||||
export const useErrorModalMessage = () => useErrorModalStore((state) => state.message);
|
||||
export const useErrorModalTitle = () => useErrorModalStore((state) => state.title);
|
||||
export const useErrorModalActions = () => useErrorModalStore((state) => state.actions);
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Box, Button, Text } from "@saleor/macaw-ui/next";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
RoundedActionBox,
|
||||
RoundedBoxWithFooter,
|
||||
} from "@/modules/ui/atoms/RoundedActionBox/RoundedActionBox";
|
||||
import { ConfigurationsTable } from "@/modules/ui/molecules/ConfigurationsTable/ConfigurationsTable";
|
||||
import { type PaymentAppUserVisibleEntries } from "@/modules/payment-app-configuration/app-config";
|
||||
|
||||
export const StripeConfigurationsList = ({
|
||||
configurations,
|
||||
}: {
|
||||
configurations: PaymentAppUserVisibleEntries;
|
||||
}) => {
|
||||
return configurations.length > 0 ? <NotEmpty configurations={configurations} /> : <Empty />;
|
||||
};
|
||||
|
||||
const NotEmpty = ({ configurations }: { configurations: PaymentAppUserVisibleEntries }) => {
|
||||
return (
|
||||
<RoundedBoxWithFooter
|
||||
footer={
|
||||
<Link href={"/configurations/add"} passHref legacyBehavior>
|
||||
<Button as="a" size="large" variant="primary">
|
||||
Add new configuration
|
||||
</Button>
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
<ConfigurationsTable configurations={configurations} />
|
||||
</RoundedBoxWithFooter>
|
||||
);
|
||||
};
|
||||
|
||||
const Empty = () => {
|
||||
return (
|
||||
<RoundedActionBox>
|
||||
<Box>
|
||||
<Text as="p" variant="body" size="medium" color="textCriticalDefault">
|
||||
No Stripe configurations added.
|
||||
</Text>
|
||||
<Text as="p" variant="body" size="medium" color="textCriticalDefault">
|
||||
This means payments are not processed by Stripe.
|
||||
</Text>
|
||||
</Box>
|
||||
<Link href={"/configurations/add"} passHref legacyBehavior>
|
||||
<Button as="a" size="large" variant="primary">
|
||||
Add new configuration
|
||||
</Button>
|
||||
</Link>
|
||||
</RoundedActionBox>
|
||||
);
|
||||
};
|
||||
92
src/modules/ui/templates/AppLayout.tsx
Normal file
92
src/modules/ui/templates/AppLayout.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { Box, Text } from "@saleor/macaw-ui/next";
|
||||
import { type ReactNode, isValidElement } from "react";
|
||||
import Head from "next/head";
|
||||
import { appLayoutBoxRecipe, appLayoutTextRecipe } from "./appLayout.css";
|
||||
|
||||
export const AppLayout = ({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
description?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Saleor App Payment {title}</title>
|
||||
</Head>
|
||||
|
||||
<Box
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
rowGap={9}
|
||||
marginX="auto"
|
||||
__maxWidth={1156}
|
||||
paddingTop={10}
|
||||
__paddingBottom="20rem"
|
||||
>
|
||||
<Box display="flex" flexDirection="column" rowGap={2}>
|
||||
<Text as="h1" variant="hero" size="medium">
|
||||
{title}
|
||||
</Text>
|
||||
{isValidElement(description) ? (
|
||||
description
|
||||
) : (
|
||||
<Text as="p" variant="body" size="medium">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
{children}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const AppLayoutRow = ({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
disabled = false,
|
||||
error = false,
|
||||
}: {
|
||||
title: string;
|
||||
description?: ReactNode;
|
||||
disabled?: boolean;
|
||||
error?: boolean;
|
||||
children: ReactNode;
|
||||
}) => {
|
||||
return (
|
||||
<Box display="grid" className={appLayoutBoxRecipe({ error, disabled })}>
|
||||
<Box display="flex" flexDirection="column" rowGap={10}>
|
||||
<Text
|
||||
as="h2"
|
||||
className={appLayoutTextRecipe({ error, disabled })}
|
||||
size="large"
|
||||
variant="heading"
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
{isValidElement(description) ? (
|
||||
<Box display="flex" flexDirection="column" rowGap={2}>
|
||||
{description}
|
||||
</Box>
|
||||
) : (
|
||||
<Text
|
||||
as="p"
|
||||
className={appLayoutTextRecipe({ error, disabled })}
|
||||
variant="body"
|
||||
size="medium"
|
||||
>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box display="flex" flexDirection="column" rowGap={10}>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
66
src/modules/ui/templates/appLayout.css.ts
Normal file
66
src/modules/ui/templates/appLayout.css.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { sprinkles } from "@saleor/macaw-ui/next";
|
||||
import { recipe } from "@vanilla-extract/recipes";
|
||||
|
||||
export const appLayoutBoxRecipe = recipe({
|
||||
base: {
|
||||
gridTemplateColumns: "5fr 9fr",
|
||||
columnGap: "7%",
|
||||
},
|
||||
variants: {
|
||||
disabled: {
|
||||
true: {
|
||||
opacity: 0.6,
|
||||
cursor: "not-allowed",
|
||||
},
|
||||
false: {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
error: { true: {}, false: {} },
|
||||
},
|
||||
});
|
||||
|
||||
export const appLayoutTextRecipe = recipe({
|
||||
variants: {
|
||||
disabled: { true: {}, false: {} },
|
||||
error: { true: {}, false: {} },
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
variants: {
|
||||
disabled: false,
|
||||
error: false,
|
||||
},
|
||||
style: sprinkles({
|
||||
color: "textNeutralDefault",
|
||||
}),
|
||||
},
|
||||
{
|
||||
variants: {
|
||||
disabled: true,
|
||||
error: false,
|
||||
},
|
||||
style: sprinkles({
|
||||
color: "textNeutralDisabled",
|
||||
}),
|
||||
},
|
||||
{
|
||||
variants: {
|
||||
disabled: false,
|
||||
error: true,
|
||||
},
|
||||
style: sprinkles({
|
||||
color: "textCriticalSubdued",
|
||||
}),
|
||||
},
|
||||
{
|
||||
variants: {
|
||||
disabled: true,
|
||||
error: true,
|
||||
},
|
||||
style: sprinkles({
|
||||
color: "textCriticalDisabled",
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
50
src/modules/ui/theme-synchronizer.test.tsx
Normal file
50
src/modules/ui/theme-synchronizer.test.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { type AppBridgeState } from "@saleor/app-sdk/app-bridge";
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import { type ThemeTokensValues, type DefaultTheme } from "@saleor/macaw-ui/next";
|
||||
import { ThemeSynchronizer } from "./theme-synchronizer";
|
||||
|
||||
const appBridgeState: AppBridgeState = {
|
||||
ready: true,
|
||||
token: "token",
|
||||
domain: "some-domain.saleor.cloud",
|
||||
theme: "dark",
|
||||
path: "/",
|
||||
locale: "en",
|
||||
id: "app-id",
|
||||
saleorApiUrl: "https://some-domain.saleor.cloud/graphql/",
|
||||
};
|
||||
|
||||
const mockThemeChange = vi.fn();
|
||||
|
||||
vi.mock("@saleor/app-sdk/app-bridge", () => {
|
||||
return {
|
||||
useAppBridge() {
|
||||
return {
|
||||
appBridgeState: appBridgeState,
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@saleor/macaw-ui/next", () => {
|
||||
return {
|
||||
useTheme() {
|
||||
return {
|
||||
setTheme: mockThemeChange,
|
||||
theme: "defaultLight",
|
||||
themeValues: {} as ThemeTokensValues,
|
||||
} satisfies ReturnType<typeof import("@saleor/macaw-ui/next").useTheme>;
|
||||
},
|
||||
} satisfies Pick<typeof import("@saleor/macaw-ui/next"), "useTheme">;
|
||||
});
|
||||
|
||||
describe("ThemeSynchronizer", () => {
|
||||
it("Updates MacawUI theme when AppBridgeState theme changes", () => {
|
||||
render(<ThemeSynchronizer />);
|
||||
|
||||
return waitFor(() => {
|
||||
expect(mockThemeChange).toHaveBeenCalledWith<[DefaultTheme]>("defaultDark");
|
||||
});
|
||||
});
|
||||
});
|
||||
33
src/modules/ui/theme-synchronizer.tsx
Normal file
33
src/modules/ui/theme-synchronizer.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { type ThemeType, useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
import { type DefaultTheme, useTheme } from "@saleor/macaw-ui/next";
|
||||
import { memo, useEffect } from "react";
|
||||
|
||||
const mapAppBridgeToMacawTheme: Record<ThemeType, DefaultTheme> = {
|
||||
light: "defaultLight",
|
||||
dark: "defaultDark",
|
||||
};
|
||||
|
||||
/**
|
||||
* Macaw-ui stores its theme mode in memory and local storage. To synchronize App with Dashboard,
|
||||
* Macaw must be informed about this change from AppBridge.
|
||||
*
|
||||
* If you are not using Macaw, you can remove this.
|
||||
*/
|
||||
export const ThemeSynchronizer = memo(function ThemeSynchronizer() {
|
||||
const { appBridgeState } = useAppBridge();
|
||||
const { setTheme, theme } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
if (!setTheme || !appBridgeState?.theme) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mappedAppBridgeTheme = mapAppBridgeToMacawTheme[appBridgeState?.theme];
|
||||
|
||||
if (theme !== mappedAppBridgeTheme) {
|
||||
setTheme(mappedAppBridgeTheme);
|
||||
}
|
||||
}, [appBridgeState?.theme, setTheme, theme]);
|
||||
|
||||
return null;
|
||||
});
|
||||
@@ -0,0 +1,470 @@
|
||||
{
|
||||
"log": {
|
||||
"_recordingName": "TransactionCancelationRequestedWebhookHandler/'American Express' 'pm_card_amex'/should cancel pre-authorized card",
|
||||
"creator": {
|
||||
"comment": "persister:fs",
|
||||
"name": "Polly.JS",
|
||||
"version": "6.0.5"
|
||||
},
|
||||
"entries": [
|
||||
{
|
||||
"_id": "e832e0e9ad4807ac377f5e27c0fd1344",
|
||||
"_order": 0,
|
||||
"cache": {},
|
||||
"request": {
|
||||
"bodySize": 239,
|
||||
"cookies": [],
|
||||
"headers": [
|
||||
{
|
||||
"name": "accept",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"name": "content-length",
|
||||
"value": "239"
|
||||
},
|
||||
{
|
||||
"name": "content-type",
|
||||
"value": "application/x-www-form-urlencoded"
|
||||
},
|
||||
{
|
||||
"name": "idempotency-key",
|
||||
"value": "stripe-node-retry-54ebe21d-d3f9-40ef-b389-8fee72ba37fe"
|
||||
},
|
||||
{
|
||||
"name": "stripe-version",
|
||||
"value": "2023-10-16"
|
||||
},
|
||||
{
|
||||
"name": "user-agent",
|
||||
"value": "Stripe/v1 NodeBindings/14.8.0"
|
||||
}
|
||||
],
|
||||
"headersSize": 814,
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"method": "POST",
|
||||
"postData": {
|
||||
"mimeType": "application/x-www-form-urlencoded",
|
||||
"params": [],
|
||||
"text": "automatic_payment_methods[enabled]=true&automatic_payment_methods[allow_redirects]=never&amount=22299¤cy=PLN&capture_method=manual&metadata[transactionId]=555555&metadata[channelId]=1&metadata[checkoutId]=c29tZS1jaGVja291dC1pZA%3D%3D"
|
||||
},
|
||||
"queryString": [],
|
||||
"url": "https://api.stripe.com/v1/payment_intents"
|
||||
},
|
||||
"response": {
|
||||
"bodySize": 1695,
|
||||
"content": {
|
||||
"mimeType": "application/json",
|
||||
"size": 1695,
|
||||
"text": {
|
||||
"amount": 22299,
|
||||
"amount_capturable": 0,
|
||||
"amount_details": {
|
||||
"tip": {}
|
||||
},
|
||||
"amount_received": 0,
|
||||
"application": null,
|
||||
"application_fee_amount": null,
|
||||
"automatic_payment_methods": {
|
||||
"allow_redirects": "never",
|
||||
"enabled": true
|
||||
},
|
||||
"canceled_at": null,
|
||||
"cancellation_reason": null,
|
||||
"capture_method": "manual",
|
||||
"client_secret": "pi_FAKE_CLIENT_SECRET",
|
||||
"confirmation_method": "automatic",
|
||||
"created": 1702477365,
|
||||
"currency": "pln",
|
||||
"customer": null,
|
||||
"description": null,
|
||||
"id": "pi_3OMtKjEosEcNBN5m0IMpKdZI",
|
||||
"invoice": null,
|
||||
"last_payment_error": null,
|
||||
"latest_charge": null,
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
"channelId": "1",
|
||||
"checkoutId": "c29tZS1jaGVja291dC1pZA==",
|
||||
"transactionId": "555555"
|
||||
},
|
||||
"next_action": null,
|
||||
"object": "payment_intent",
|
||||
"on_behalf_of": null,
|
||||
"payment_method": null,
|
||||
"payment_method_configuration_details": {
|
||||
"id": "pmc_1LVZxMEosEcNBN5manO2iTW7",
|
||||
"parent": null
|
||||
},
|
||||
"payment_method_options": {
|
||||
"card": {
|
||||
"installments": null,
|
||||
"mandate_options": null,
|
||||
"network": null,
|
||||
"request_three_d_secure": "automatic"
|
||||
},
|
||||
"link": {
|
||||
"persistent_token": null
|
||||
},
|
||||
"paypal": {
|
||||
"preferred_locale": null,
|
||||
"reference": null
|
||||
}
|
||||
},
|
||||
"payment_method_types": [
|
||||
"card",
|
||||
"link",
|
||||
"paypal"
|
||||
],
|
||||
"processing": null,
|
||||
"receipt_email": null,
|
||||
"review": null,
|
||||
"setup_future_usage": null,
|
||||
"shipping": null,
|
||||
"source": null,
|
||||
"statement_descriptor": null,
|
||||
"statement_descriptor_suffix": null,
|
||||
"status": "requires_payment_method",
|
||||
"transfer_data": null,
|
||||
"transfer_group": null
|
||||
}
|
||||
},
|
||||
"cookies": [],
|
||||
"headers": [
|
||||
{
|
||||
"name": "access-control-allow-credentials",
|
||||
"value": "true"
|
||||
},
|
||||
{
|
||||
"name": "access-control-allow-methods",
|
||||
"value": "GET,HEAD,PUT,PATCH,POST,DELETE"
|
||||
},
|
||||
{
|
||||
"name": "access-control-allow-origin",
|
||||
"value": "*"
|
||||
},
|
||||
{
|
||||
"name": "access-control-expose-headers",
|
||||
"value": "Request-Id, Stripe-Manage-Version, Stripe-Should-Retry, X-Stripe-External-Auth-Required, X-Stripe-Privileged-Session-Required"
|
||||
},
|
||||
{
|
||||
"name": "access-control-max-age",
|
||||
"value": "300"
|
||||
},
|
||||
{
|
||||
"name": "cache-control",
|
||||
"value": "no-cache, no-store"
|
||||
},
|
||||
{
|
||||
"name": "connection",
|
||||
"value": "keep-alive"
|
||||
},
|
||||
{
|
||||
"name": "content-length",
|
||||
"value": "1695"
|
||||
},
|
||||
{
|
||||
"name": "content-security-policy",
|
||||
"value": "report-uri https://q.stripe.com/csp-report?p=v1%2Fpayment_intents; block-all-mixed-content; default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; img-src 'self'; script-src 'self' 'report-sample'; style-src 'self'"
|
||||
},
|
||||
{
|
||||
"name": "content-type",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"name": "date",
|
||||
"value": "Wed, 13 Dec 2023 14:22:45 GMT"
|
||||
},
|
||||
{
|
||||
"name": "idempotency-key",
|
||||
"value": "stripe-node-retry-54ebe21d-d3f9-40ef-b389-8fee72ba37fe"
|
||||
},
|
||||
{
|
||||
"name": "original-request",
|
||||
"value": "req_wQMsaRdx3ch0Wm"
|
||||
},
|
||||
{
|
||||
"name": "request-id",
|
||||
"value": "req_wQMsaRdx3ch0Wm"
|
||||
},
|
||||
{
|
||||
"name": "server",
|
||||
"value": "nginx"
|
||||
},
|
||||
{
|
||||
"name": "strict-transport-security",
|
||||
"value": "max-age=63072000; includeSubDomains; preload"
|
||||
},
|
||||
{
|
||||
"name": "stripe-should-retry",
|
||||
"value": "false"
|
||||
},
|
||||
{
|
||||
"name": "stripe-version",
|
||||
"value": "2023-10-16"
|
||||
},
|
||||
{
|
||||
"name": "vary",
|
||||
"value": "Origin"
|
||||
},
|
||||
{
|
||||
"name": "x-stripe-routing-context-priority-tier",
|
||||
"value": "api-testmode"
|
||||
}
|
||||
],
|
||||
"headersSize": 1095,
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"redirectURL": "",
|
||||
"status": 200,
|
||||
"statusText": "OK"
|
||||
},
|
||||
"startedDateTime": "2023-12-13T14:22:34.853Z",
|
||||
"time": 10970,
|
||||
"timings": {
|
||||
"blocked": -1,
|
||||
"connect": -1,
|
||||
"dns": -1,
|
||||
"receive": 0,
|
||||
"send": 0,
|
||||
"ssl": -1,
|
||||
"wait": 10970
|
||||
}
|
||||
},
|
||||
{
|
||||
"_id": "09961e1cd5abe26724f68f52a532d4a9",
|
||||
"_order": 0,
|
||||
"cache": {},
|
||||
"request": {
|
||||
"bodySize": 27,
|
||||
"cookies": [],
|
||||
"headers": [
|
||||
{
|
||||
"name": "accept",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"name": "content-length",
|
||||
"value": "27"
|
||||
},
|
||||
{
|
||||
"name": "content-type",
|
||||
"value": "application/x-www-form-urlencoded"
|
||||
},
|
||||
{
|
||||
"name": "idempotency-key",
|
||||
"value": "stripe-node-retry-b7b7ace3-e56c-4d0e-96f2-a4658918ded1"
|
||||
},
|
||||
{
|
||||
"name": "stripe-version",
|
||||
"value": "2023-10-16"
|
||||
},
|
||||
{
|
||||
"name": "user-agent",
|
||||
"value": "Stripe/v1 NodeBindings/14.8.0"
|
||||
}
|
||||
],
|
||||
"headersSize": 849,
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"method": "POST",
|
||||
"postData": {
|
||||
"mimeType": "application/x-www-form-urlencoded",
|
||||
"params": [],
|
||||
"text": "payment_method=pm_card_amex"
|
||||
},
|
||||
"queryString": [],
|
||||
"url": "https://api.stripe.com/v1/payment_intents/pi_3OMtKjEosEcNBN5m0IMpKdZI/confirm"
|
||||
},
|
||||
"response": {
|
||||
"bodySize": 2642,
|
||||
"content": {
|
||||
"mimeType": "application/json",
|
||||
"size": 2642,
|
||||
"text": {
|
||||
"error": {
|
||||
"code": "payment_intent_unexpected_state",
|
||||
"doc_url": "https://stripe.com/docs/error-codes/payment-intent-unexpected-state",
|
||||
"message": "This PaymentIntent's payment_method could not be updated because it has a status of canceled. You may only update the payment_method of a PaymentIntent with one of the following statuses: requires_payment_method, requires_confirmation, requires_action.",
|
||||
"param": "payment_method",
|
||||
"payment_intent": {
|
||||
"amount": 22299,
|
||||
"amount_capturable": 0,
|
||||
"amount_details": {
|
||||
"tip": {}
|
||||
},
|
||||
"amount_received": 0,
|
||||
"application": null,
|
||||
"application_fee_amount": null,
|
||||
"automatic_payment_methods": {
|
||||
"allow_redirects": "never",
|
||||
"enabled": true
|
||||
},
|
||||
"canceled_at": 1702477367,
|
||||
"cancellation_reason": null,
|
||||
"capture_method": "manual",
|
||||
"client_secret": "pi_3OMtKjEosEcNBN5m0IMpKdZI_secret_yQKx08JxltBGb5fiLraqa2tXA",
|
||||
"confirmation_method": "automatic",
|
||||
"created": 1702477365,
|
||||
"currency": "pln",
|
||||
"customer": null,
|
||||
"description": null,
|
||||
"id": "pi_3OMtKjEosEcNBN5m0IMpKdZI",
|
||||
"invoice": null,
|
||||
"last_payment_error": null,
|
||||
"latest_charge": "ch_3OMtKjEosEcNBN5m0JHUOrqY",
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
"channelId": "1",
|
||||
"checkoutId": "c29tZS1jaGVja291dC1pZA==",
|
||||
"transactionId": "555555"
|
||||
},
|
||||
"next_action": null,
|
||||
"object": "payment_intent",
|
||||
"on_behalf_of": null,
|
||||
"payment_method": "pm_1OMtKkEosEcNBN5mSqNhoa5a",
|
||||
"payment_method_configuration_details": {
|
||||
"id": "pmc_1LVZxMEosEcNBN5manO2iTW7",
|
||||
"parent": null
|
||||
},
|
||||
"payment_method_options": {
|
||||
"card": {
|
||||
"installments": null,
|
||||
"mandate_options": null,
|
||||
"network": null,
|
||||
"request_three_d_secure": "automatic"
|
||||
},
|
||||
"link": {
|
||||
"persistent_token": null
|
||||
},
|
||||
"paypal": {
|
||||
"preferred_locale": null,
|
||||
"reference": null
|
||||
}
|
||||
},
|
||||
"payment_method_types": [
|
||||
"card",
|
||||
"link",
|
||||
"paypal"
|
||||
],
|
||||
"processing": null,
|
||||
"receipt_email": null,
|
||||
"review": null,
|
||||
"setup_future_usage": null,
|
||||
"shipping": null,
|
||||
"source": null,
|
||||
"statement_descriptor": null,
|
||||
"statement_descriptor_suffix": null,
|
||||
"status": "canceled",
|
||||
"transfer_data": null,
|
||||
"transfer_group": null
|
||||
},
|
||||
"request_log_url": "https://dashboard.stripe.com/test/logs/req_opm0NuqwQYrWqW?t=1702477938",
|
||||
"type": "invalid_request_error"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cookies": [],
|
||||
"headers": [
|
||||
{
|
||||
"name": "access-control-allow-credentials",
|
||||
"value": "true"
|
||||
},
|
||||
{
|
||||
"name": "access-control-allow-methods",
|
||||
"value": "GET,HEAD,PUT,PATCH,POST,DELETE"
|
||||
},
|
||||
{
|
||||
"name": "access-control-allow-origin",
|
||||
"value": "*"
|
||||
},
|
||||
{
|
||||
"name": "access-control-expose-headers",
|
||||
"value": "Request-Id, Stripe-Manage-Version, Stripe-Should-Retry, X-Stripe-External-Auth-Required, X-Stripe-Privileged-Session-Required"
|
||||
},
|
||||
{
|
||||
"name": "access-control-max-age",
|
||||
"value": "300"
|
||||
},
|
||||
{
|
||||
"name": "cache-control",
|
||||
"value": "no-cache, no-store"
|
||||
},
|
||||
{
|
||||
"name": "connection",
|
||||
"value": "keep-alive"
|
||||
},
|
||||
{
|
||||
"name": "content-length",
|
||||
"value": "2642"
|
||||
},
|
||||
{
|
||||
"name": "content-security-policy",
|
||||
"value": "report-uri https://q.stripe.com/csp-report?p=v1%2Fpayment_intents%2F%3Aintent%2Fconfirm; block-all-mixed-content; default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; img-src 'self'; script-src 'self' 'report-sample'; style-src 'self'"
|
||||
},
|
||||
{
|
||||
"name": "content-type",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"name": "date",
|
||||
"value": "Wed, 13 Dec 2023 14:32:18 GMT"
|
||||
},
|
||||
{
|
||||
"name": "idempotency-key",
|
||||
"value": "stripe-node-retry-b7b7ace3-e56c-4d0e-96f2-a4658918ded1"
|
||||
},
|
||||
{
|
||||
"name": "original-request",
|
||||
"value": "req_opm0NuqwQYrWqW"
|
||||
},
|
||||
{
|
||||
"name": "request-id",
|
||||
"value": "req_opm0NuqwQYrWqW"
|
||||
},
|
||||
{
|
||||
"name": "server",
|
||||
"value": "nginx"
|
||||
},
|
||||
{
|
||||
"name": "strict-transport-security",
|
||||
"value": "max-age=63072000; includeSubDomains; preload"
|
||||
},
|
||||
{
|
||||
"name": "stripe-should-retry",
|
||||
"value": "false"
|
||||
},
|
||||
{
|
||||
"name": "stripe-version",
|
||||
"value": "2023-10-16"
|
||||
},
|
||||
{
|
||||
"name": "vary",
|
||||
"value": "Origin"
|
||||
},
|
||||
{
|
||||
"name": "x-stripe-routing-context-priority-tier",
|
||||
"value": "api-testmode"
|
||||
}
|
||||
],
|
||||
"headersSize": 1117,
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"redirectURL": "",
|
||||
"status": 400,
|
||||
"statusText": "Bad Request"
|
||||
},
|
||||
"startedDateTime": "2023-12-13T14:32:18.339Z",
|
||||
"time": 502,
|
||||
"timings": {
|
||||
"blocked": -1,
|
||||
"connect": -1,
|
||||
"dns": -1,
|
||||
"receive": 0,
|
||||
"send": 0,
|
||||
"ssl": -1,
|
||||
"wait": 502
|
||||
}
|
||||
}
|
||||
],
|
||||
"pages": [],
|
||||
"version": "1.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,678 @@
|
||||
{
|
||||
"log": {
|
||||
"_recordingName": "TransactionCancelationRequestedWebhookHandler/'Diners Club' 'pm_card_diners'/should cancel pre-authorized card",
|
||||
"creator": {
|
||||
"comment": "persister:fs",
|
||||
"name": "Polly.JS",
|
||||
"version": "6.0.5"
|
||||
},
|
||||
"entries": [
|
||||
{
|
||||
"_id": "e832e0e9ad4807ac377f5e27c0fd1344",
|
||||
"_order": 0,
|
||||
"cache": {},
|
||||
"request": {
|
||||
"bodySize": 239,
|
||||
"cookies": [],
|
||||
"headers": [
|
||||
{
|
||||
"name": "accept",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"name": "content-length",
|
||||
"value": "239"
|
||||
},
|
||||
{
|
||||
"name": "content-type",
|
||||
"value": "application/x-www-form-urlencoded"
|
||||
},
|
||||
{
|
||||
"name": "idempotency-key",
|
||||
"value": "stripe-node-retry-93964a81-ef10-40a0-8f01-f68fe808a910"
|
||||
},
|
||||
{
|
||||
"name": "stripe-version",
|
||||
"value": "2023-10-16"
|
||||
},
|
||||
{
|
||||
"name": "user-agent",
|
||||
"value": "Stripe/v1 NodeBindings/14.8.0"
|
||||
}
|
||||
],
|
||||
"headersSize": 814,
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"method": "POST",
|
||||
"postData": {
|
||||
"mimeType": "application/x-www-form-urlencoded",
|
||||
"params": [],
|
||||
"text": "automatic_payment_methods[enabled]=true&automatic_payment_methods[allow_redirects]=never&amount=22299¤cy=PLN&capture_method=manual&metadata[transactionId]=555555&metadata[channelId]=1&metadata[checkoutId]=c29tZS1jaGVja291dC1pZA%3D%3D"
|
||||
},
|
||||
"queryString": [],
|
||||
"url": "https://api.stripe.com/v1/payment_intents"
|
||||
},
|
||||
"response": {
|
||||
"bodySize": 1695,
|
||||
"content": {
|
||||
"mimeType": "application/json",
|
||||
"size": 1695,
|
||||
"text": {
|
||||
"amount": 22299,
|
||||
"amount_capturable": 0,
|
||||
"amount_details": {
|
||||
"tip": {}
|
||||
},
|
||||
"amount_received": 0,
|
||||
"application": null,
|
||||
"application_fee_amount": null,
|
||||
"automatic_payment_methods": {
|
||||
"allow_redirects": "never",
|
||||
"enabled": true
|
||||
},
|
||||
"canceled_at": null,
|
||||
"cancellation_reason": null,
|
||||
"capture_method": "manual",
|
||||
"client_secret": "pi_FAKE_CLIENT_SECRET",
|
||||
"confirmation_method": "automatic",
|
||||
"created": 1702477368,
|
||||
"currency": "pln",
|
||||
"customer": null,
|
||||
"description": null,
|
||||
"id": "pi_3OMtKmEosEcNBN5m1mmpnxTV",
|
||||
"invoice": null,
|
||||
"last_payment_error": null,
|
||||
"latest_charge": null,
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
"channelId": "1",
|
||||
"checkoutId": "c29tZS1jaGVja291dC1pZA==",
|
||||
"transactionId": "555555"
|
||||
},
|
||||
"next_action": null,
|
||||
"object": "payment_intent",
|
||||
"on_behalf_of": null,
|
||||
"payment_method": null,
|
||||
"payment_method_configuration_details": {
|
||||
"id": "pmc_1LVZxMEosEcNBN5manO2iTW7",
|
||||
"parent": null
|
||||
},
|
||||
"payment_method_options": {
|
||||
"card": {
|
||||
"installments": null,
|
||||
"mandate_options": null,
|
||||
"network": null,
|
||||
"request_three_d_secure": "automatic"
|
||||
},
|
||||
"link": {
|
||||
"persistent_token": null
|
||||
},
|
||||
"paypal": {
|
||||
"preferred_locale": null,
|
||||
"reference": null
|
||||
}
|
||||
},
|
||||
"payment_method_types": [
|
||||
"card",
|
||||
"link",
|
||||
"paypal"
|
||||
],
|
||||
"processing": null,
|
||||
"receipt_email": null,
|
||||
"review": null,
|
||||
"setup_future_usage": null,
|
||||
"shipping": null,
|
||||
"source": null,
|
||||
"statement_descriptor": null,
|
||||
"statement_descriptor_suffix": null,
|
||||
"status": "requires_payment_method",
|
||||
"transfer_data": null,
|
||||
"transfer_group": null
|
||||
}
|
||||
},
|
||||
"cookies": [],
|
||||
"headers": [
|
||||
{
|
||||
"name": "access-control-allow-credentials",
|
||||
"value": "true"
|
||||
},
|
||||
{
|
||||
"name": "access-control-allow-methods",
|
||||
"value": "GET,HEAD,PUT,PATCH,POST,DELETE"
|
||||
},
|
||||
{
|
||||
"name": "access-control-allow-origin",
|
||||
"value": "*"
|
||||
},
|
||||
{
|
||||
"name": "access-control-expose-headers",
|
||||
"value": "Request-Id, Stripe-Manage-Version, Stripe-Should-Retry, X-Stripe-External-Auth-Required, X-Stripe-Privileged-Session-Required"
|
||||
},
|
||||
{
|
||||
"name": "access-control-max-age",
|
||||
"value": "300"
|
||||
},
|
||||
{
|
||||
"name": "cache-control",
|
||||
"value": "no-cache, no-store"
|
||||
},
|
||||
{
|
||||
"name": "connection",
|
||||
"value": "keep-alive"
|
||||
},
|
||||
{
|
||||
"name": "content-length",
|
||||
"value": "1695"
|
||||
},
|
||||
{
|
||||
"name": "content-security-policy",
|
||||
"value": "report-uri https://q.stripe.com/csp-report?p=v1%2Fpayment_intents; block-all-mixed-content; default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; img-src 'self'; script-src 'self' 'report-sample'; style-src 'self'"
|
||||
},
|
||||
{
|
||||
"name": "content-type",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"name": "date",
|
||||
"value": "Wed, 13 Dec 2023 14:22:49 GMT"
|
||||
},
|
||||
{
|
||||
"name": "idempotency-key",
|
||||
"value": "stripe-node-retry-93964a81-ef10-40a0-8f01-f68fe808a910"
|
||||
},
|
||||
{
|
||||
"name": "original-request",
|
||||
"value": "req_jKnKkwSyVeFiLk"
|
||||
},
|
||||
{
|
||||
"name": "request-id",
|
||||
"value": "req_jKnKkwSyVeFiLk"
|
||||
},
|
||||
{
|
||||
"name": "server",
|
||||
"value": "nginx"
|
||||
},
|
||||
{
|
||||
"name": "strict-transport-security",
|
||||
"value": "max-age=63072000; includeSubDomains; preload"
|
||||
},
|
||||
{
|
||||
"name": "stripe-should-retry",
|
||||
"value": "false"
|
||||
},
|
||||
{
|
||||
"name": "stripe-version",
|
||||
"value": "2023-10-16"
|
||||
},
|
||||
{
|
||||
"name": "vary",
|
||||
"value": "Origin"
|
||||
},
|
||||
{
|
||||
"name": "x-stripe-routing-context-priority-tier",
|
||||
"value": "api-testmode"
|
||||
}
|
||||
],
|
||||
"headersSize": 1095,
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"redirectURL": "",
|
||||
"status": 200,
|
||||
"statusText": "OK"
|
||||
},
|
||||
"startedDateTime": "2023-12-13T14:22:48.501Z",
|
||||
"time": 460,
|
||||
"timings": {
|
||||
"blocked": -1,
|
||||
"connect": -1,
|
||||
"dns": -1,
|
||||
"receive": 0,
|
||||
"send": 0,
|
||||
"ssl": -1,
|
||||
"wait": 460
|
||||
}
|
||||
},
|
||||
{
|
||||
"_id": "132eca35f40d77389f2749d4d0fed7dd",
|
||||
"_order": 0,
|
||||
"cache": {},
|
||||
"request": {
|
||||
"bodySize": 29,
|
||||
"cookies": [],
|
||||
"headers": [
|
||||
{
|
||||
"name": "accept",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"name": "content-length",
|
||||
"value": "29"
|
||||
},
|
||||
{
|
||||
"name": "content-type",
|
||||
"value": "application/x-www-form-urlencoded"
|
||||
},
|
||||
{
|
||||
"name": "idempotency-key",
|
||||
"value": "stripe-node-retry-8b87b28d-8707-4364-8ce7-5af147a6e72f"
|
||||
},
|
||||
{
|
||||
"name": "stripe-version",
|
||||
"value": "2023-10-16"
|
||||
},
|
||||
{
|
||||
"name": "user-agent",
|
||||
"value": "Stripe/v1 NodeBindings/14.8.0"
|
||||
}
|
||||
],
|
||||
"headersSize": 849,
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"method": "POST",
|
||||
"postData": {
|
||||
"mimeType": "application/x-www-form-urlencoded",
|
||||
"params": [],
|
||||
"text": "payment_method=pm_card_diners"
|
||||
},
|
||||
"queryString": [],
|
||||
"url": "https://api.stripe.com/v1/payment_intents/pi_3OMtKmEosEcNBN5m1mmpnxTV/confirm"
|
||||
},
|
||||
"response": {
|
||||
"bodySize": 1742,
|
||||
"content": {
|
||||
"mimeType": "application/json",
|
||||
"size": 1742,
|
||||
"text": {
|
||||
"amount": 22299,
|
||||
"amount_capturable": 22299,
|
||||
"amount_details": {
|
||||
"tip": {}
|
||||
},
|
||||
"amount_received": 0,
|
||||
"application": null,
|
||||
"application_fee_amount": null,
|
||||
"automatic_payment_methods": {
|
||||
"allow_redirects": "never",
|
||||
"enabled": true
|
||||
},
|
||||
"canceled_at": null,
|
||||
"cancellation_reason": null,
|
||||
"capture_method": "manual",
|
||||
"client_secret": "pi_FAKE_CLIENT_SECRET",
|
||||
"confirmation_method": "automatic",
|
||||
"created": 1702477368,
|
||||
"currency": "pln",
|
||||
"customer": null,
|
||||
"description": null,
|
||||
"id": "pi_3OMtKmEosEcNBN5m1mmpnxTV",
|
||||
"invoice": null,
|
||||
"last_payment_error": null,
|
||||
"latest_charge": "ch_3OMtKmEosEcNBN5m1yHmroRF",
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
"channelId": "1",
|
||||
"checkoutId": "c29tZS1jaGVja291dC1pZA==",
|
||||
"transactionId": "555555"
|
||||
},
|
||||
"next_action": null,
|
||||
"object": "payment_intent",
|
||||
"on_behalf_of": null,
|
||||
"payment_method": "pm_1OMtKnEosEcNBN5m4w8rFMPJ",
|
||||
"payment_method_configuration_details": {
|
||||
"id": "pmc_1LVZxMEosEcNBN5manO2iTW7",
|
||||
"parent": null
|
||||
},
|
||||
"payment_method_options": {
|
||||
"card": {
|
||||
"installments": null,
|
||||
"mandate_options": null,
|
||||
"network": null,
|
||||
"request_three_d_secure": "automatic"
|
||||
},
|
||||
"link": {
|
||||
"persistent_token": null
|
||||
},
|
||||
"paypal": {
|
||||
"preferred_locale": null,
|
||||
"reference": null
|
||||
}
|
||||
},
|
||||
"payment_method_types": [
|
||||
"card",
|
||||
"link",
|
||||
"paypal"
|
||||
],
|
||||
"processing": null,
|
||||
"receipt_email": null,
|
||||
"review": null,
|
||||
"setup_future_usage": null,
|
||||
"shipping": null,
|
||||
"source": null,
|
||||
"statement_descriptor": null,
|
||||
"statement_descriptor_suffix": null,
|
||||
"status": "requires_capture",
|
||||
"transfer_data": null,
|
||||
"transfer_group": null
|
||||
}
|
||||
},
|
||||
"cookies": [],
|
||||
"headers": [
|
||||
{
|
||||
"name": "access-control-allow-credentials",
|
||||
"value": "true"
|
||||
},
|
||||
{
|
||||
"name": "access-control-allow-methods",
|
||||
"value": "GET,HEAD,PUT,PATCH,POST,DELETE"
|
||||
},
|
||||
{
|
||||
"name": "access-control-allow-origin",
|
||||
"value": "*"
|
||||
},
|
||||
{
|
||||
"name": "access-control-expose-headers",
|
||||
"value": "Request-Id, Stripe-Manage-Version, Stripe-Should-Retry, X-Stripe-External-Auth-Required, X-Stripe-Privileged-Session-Required"
|
||||
},
|
||||
{
|
||||
"name": "access-control-max-age",
|
||||
"value": "300"
|
||||
},
|
||||
{
|
||||
"name": "cache-control",
|
||||
"value": "no-cache, no-store"
|
||||
},
|
||||
{
|
||||
"name": "connection",
|
||||
"value": "keep-alive"
|
||||
},
|
||||
{
|
||||
"name": "content-length",
|
||||
"value": "1742"
|
||||
},
|
||||
{
|
||||
"name": "content-security-policy",
|
||||
"value": "report-uri https://q.stripe.com/csp-report?p=v1%2Fpayment_intents%2F%3Aintent%2Fconfirm; block-all-mixed-content; default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; img-src 'self'; script-src 'self' 'report-sample'; style-src 'self'"
|
||||
},
|
||||
{
|
||||
"name": "content-type",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"name": "date",
|
||||
"value": "Wed, 13 Dec 2023 14:22:50 GMT"
|
||||
},
|
||||
{
|
||||
"name": "idempotency-key",
|
||||
"value": "stripe-node-retry-8b87b28d-8707-4364-8ce7-5af147a6e72f"
|
||||
},
|
||||
{
|
||||
"name": "original-request",
|
||||
"value": "req_aWoz3EifwjIon3"
|
||||
},
|
||||
{
|
||||
"name": "request-id",
|
||||
"value": "req_aWoz3EifwjIon3"
|
||||
},
|
||||
{
|
||||
"name": "server",
|
||||
"value": "nginx"
|
||||
},
|
||||
{
|
||||
"name": "strict-transport-security",
|
||||
"value": "max-age=63072000; includeSubDomains; preload"
|
||||
},
|
||||
{
|
||||
"name": "stripe-should-retry",
|
||||
"value": "false"
|
||||
},
|
||||
{
|
||||
"name": "stripe-version",
|
||||
"value": "2023-10-16"
|
||||
},
|
||||
{
|
||||
"name": "vary",
|
||||
"value": "Origin"
|
||||
},
|
||||
{
|
||||
"name": "x-stripe-routing-context-priority-tier",
|
||||
"value": "api-testmode"
|
||||
}
|
||||
],
|
||||
"headersSize": 1117,
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"redirectURL": "",
|
||||
"status": 200,
|
||||
"statusText": "OK"
|
||||
},
|
||||
"startedDateTime": "2023-12-13T14:22:48.963Z",
|
||||
"time": 1103,
|
||||
"timings": {
|
||||
"blocked": -1,
|
||||
"connect": -1,
|
||||
"dns": -1,
|
||||
"receive": 0,
|
||||
"send": 0,
|
||||
"ssl": -1,
|
||||
"wait": 1103
|
||||
}
|
||||
},
|
||||
{
|
||||
"_id": "bc9872fe36bbd10b29aad31e6b458124",
|
||||
"_order": 0,
|
||||
"cache": {},
|
||||
"request": {
|
||||
"bodySize": 0,
|
||||
"cookies": [],
|
||||
"headers": [
|
||||
{
|
||||
"name": "accept",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"name": "content-length",
|
||||
"value": "0"
|
||||
},
|
||||
{
|
||||
"name": "content-type",
|
||||
"value": "application/x-www-form-urlencoded"
|
||||
},
|
||||
{
|
||||
"name": "idempotency-key",
|
||||
"value": "stripe-node-retry-32523be4-2ede-4ca7-9a79-edcc08f30ddd"
|
||||
},
|
||||
{
|
||||
"name": "stripe-version",
|
||||
"value": "2023-10-16"
|
||||
},
|
||||
{
|
||||
"name": "user-agent",
|
||||
"value": "Stripe/v1 NodeBindings/14.8.0"
|
||||
}
|
||||
],
|
||||
"headersSize": 847,
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"method": "POST",
|
||||
"queryString": [],
|
||||
"url": "https://api.stripe.com/v1/payment_intents/pi_3OMtKmEosEcNBN5m1mmpnxTV/cancel"
|
||||
},
|
||||
"response": {
|
||||
"bodySize": 1736,
|
||||
"content": {
|
||||
"mimeType": "application/json",
|
||||
"size": 1736,
|
||||
"text": {
|
||||
"amount": 22299,
|
||||
"amount_capturable": 0,
|
||||
"amount_details": {
|
||||
"tip": {}
|
||||
},
|
||||
"amount_received": 0,
|
||||
"application": null,
|
||||
"application_fee_amount": null,
|
||||
"automatic_payment_methods": {
|
||||
"allow_redirects": "never",
|
||||
"enabled": true
|
||||
},
|
||||
"canceled_at": 1702477370,
|
||||
"cancellation_reason": null,
|
||||
"capture_method": "manual",
|
||||
"client_secret": "pi_FAKE_CLIENT_SECRET",
|
||||
"confirmation_method": "automatic",
|
||||
"created": 1702477368,
|
||||
"currency": "pln",
|
||||
"customer": null,
|
||||
"description": null,
|
||||
"id": "pi_3OMtKmEosEcNBN5m1mmpnxTV",
|
||||
"invoice": null,
|
||||
"last_payment_error": null,
|
||||
"latest_charge": "ch_3OMtKmEosEcNBN5m1yHmroRF",
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
"channelId": "1",
|
||||
"checkoutId": "c29tZS1jaGVja291dC1pZA==",
|
||||
"transactionId": "555555"
|
||||
},
|
||||
"next_action": null,
|
||||
"object": "payment_intent",
|
||||
"on_behalf_of": null,
|
||||
"payment_method": "pm_1OMtKnEosEcNBN5m4w8rFMPJ",
|
||||
"payment_method_configuration_details": {
|
||||
"id": "pmc_1LVZxMEosEcNBN5manO2iTW7",
|
||||
"parent": null
|
||||
},
|
||||
"payment_method_options": {
|
||||
"card": {
|
||||
"installments": null,
|
||||
"mandate_options": null,
|
||||
"network": null,
|
||||
"request_three_d_secure": "automatic"
|
||||
},
|
||||
"link": {
|
||||
"persistent_token": null
|
||||
},
|
||||
"paypal": {
|
||||
"preferred_locale": null,
|
||||
"reference": null
|
||||
}
|
||||
},
|
||||
"payment_method_types": [
|
||||
"card",
|
||||
"link",
|
||||
"paypal"
|
||||
],
|
||||
"processing": null,
|
||||
"receipt_email": null,
|
||||
"review": null,
|
||||
"setup_future_usage": null,
|
||||
"shipping": null,
|
||||
"source": null,
|
||||
"statement_descriptor": null,
|
||||
"statement_descriptor_suffix": null,
|
||||
"status": "canceled",
|
||||
"transfer_data": null,
|
||||
"transfer_group": null
|
||||
}
|
||||
},
|
||||
"cookies": [],
|
||||
"headers": [
|
||||
{
|
||||
"name": "access-control-allow-credentials",
|
||||
"value": "true"
|
||||
},
|
||||
{
|
||||
"name": "access-control-allow-methods",
|
||||
"value": "GET,HEAD,PUT,PATCH,POST,DELETE"
|
||||
},
|
||||
{
|
||||
"name": "access-control-allow-origin",
|
||||
"value": "*"
|
||||
},
|
||||
{
|
||||
"name": "access-control-expose-headers",
|
||||
"value": "Request-Id, Stripe-Manage-Version, Stripe-Should-Retry, X-Stripe-External-Auth-Required, X-Stripe-Privileged-Session-Required"
|
||||
},
|
||||
{
|
||||
"name": "access-control-max-age",
|
||||
"value": "300"
|
||||
},
|
||||
{
|
||||
"name": "cache-control",
|
||||
"value": "no-cache, no-store"
|
||||
},
|
||||
{
|
||||
"name": "connection",
|
||||
"value": "keep-alive"
|
||||
},
|
||||
{
|
||||
"name": "content-length",
|
||||
"value": "1736"
|
||||
},
|
||||
{
|
||||
"name": "content-security-policy",
|
||||
"value": "report-uri https://q.stripe.com/csp-report?p=v1%2Fpayment_intents%2F%3Aintent%2Fcancel; block-all-mixed-content; default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; img-src 'self'; script-src 'self' 'report-sample'; style-src 'self'"
|
||||
},
|
||||
{
|
||||
"name": "content-type",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"name": "date",
|
||||
"value": "Wed, 13 Dec 2023 14:22:50 GMT"
|
||||
},
|
||||
{
|
||||
"name": "idempotency-key",
|
||||
"value": "stripe-node-retry-32523be4-2ede-4ca7-9a79-edcc08f30ddd"
|
||||
},
|
||||
{
|
||||
"name": "original-request",
|
||||
"value": "req_XP88lcemezJh5t"
|
||||
},
|
||||
{
|
||||
"name": "request-id",
|
||||
"value": "req_XP88lcemezJh5t"
|
||||
},
|
||||
{
|
||||
"name": "server",
|
||||
"value": "nginx"
|
||||
},
|
||||
{
|
||||
"name": "strict-transport-security",
|
||||
"value": "max-age=63072000; includeSubDomains; preload"
|
||||
},
|
||||
{
|
||||
"name": "stripe-should-retry",
|
||||
"value": "false"
|
||||
},
|
||||
{
|
||||
"name": "stripe-version",
|
||||
"value": "2023-10-16"
|
||||
},
|
||||
{
|
||||
"name": "vary",
|
||||
"value": "Origin"
|
||||
},
|
||||
{
|
||||
"name": "x-stripe-routing-context-priority-tier",
|
||||
"value": "api-testmode"
|
||||
}
|
||||
],
|
||||
"headersSize": 1116,
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"redirectURL": "",
|
||||
"status": 200,
|
||||
"statusText": "OK"
|
||||
},
|
||||
"startedDateTime": "2023-12-13T14:22:50.069Z",
|
||||
"time": 665,
|
||||
"timings": {
|
||||
"blocked": -1,
|
||||
"connect": -1,
|
||||
"dns": -1,
|
||||
"receive": 0,
|
||||
"send": 0,
|
||||
"ssl": -1,
|
||||
"wait": 665
|
||||
}
|
||||
}
|
||||
],
|
||||
"pages": [],
|
||||
"version": "1.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,896 @@
|
||||
{
|
||||
"log": {
|
||||
"_recordingName": "TransactionCancelationRequestedWebhookHandler/'Discover' 'pm_card_discover'/should cancel pre-authorized card",
|
||||
"creator": {
|
||||
"comment": "persister:fs",
|
||||
"name": "Polly.JS",
|
||||
"version": "6.0.5"
|
||||
},
|
||||
"entries": [
|
||||
{
|
||||
"_id": "e832e0e9ad4807ac377f5e27c0fd1344",
|
||||
"_order": 0,
|
||||
"cache": {},
|
||||
"request": {
|
||||
"bodySize": 239,
|
||||
"cookies": [],
|
||||
"headers": [
|
||||
{
|
||||
"name": "accept",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"name": "content-length",
|
||||
"value": "239"
|
||||
},
|
||||
{
|
||||
"name": "content-type",
|
||||
"value": "application/x-www-form-urlencoded"
|
||||
},
|
||||
{
|
||||
"name": "idempotency-key",
|
||||
"value": "stripe-node-retry-35ed3a71-22bd-48ad-bd49-05594aff0737"
|
||||
},
|
||||
{
|
||||
"name": "stripe-version",
|
||||
"value": "2023-10-16"
|
||||
},
|
||||
{
|
||||
"name": "user-agent",
|
||||
"value": "Stripe/v1 NodeBindings/14.8.0"
|
||||
}
|
||||
],
|
||||
"headersSize": 814,
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"method": "POST",
|
||||
"postData": {
|
||||
"mimeType": "application/x-www-form-urlencoded",
|
||||
"params": [],
|
||||
"text": "automatic_payment_methods[enabled]=true&automatic_payment_methods[allow_redirects]=never&amount=22299¤cy=PLN&capture_method=manual&metadata[transactionId]=555555&metadata[channelId]=1&metadata[checkoutId]=c29tZS1jaGVja291dC1pZA%3D%3D"
|
||||
},
|
||||
"queryString": [],
|
||||
"url": "https://api.stripe.com/v1/payment_intents"
|
||||
},
|
||||
"response": {
|
||||
"bodySize": 1695,
|
||||
"content": {
|
||||
"mimeType": "application/json",
|
||||
"size": 1695,
|
||||
"text": {
|
||||
"amount": 22299,
|
||||
"amount_capturable": 0,
|
||||
"amount_details": {
|
||||
"tip": {}
|
||||
},
|
||||
"amount_received": 0,
|
||||
"application": null,
|
||||
"application_fee_amount": null,
|
||||
"automatic_payment_methods": {
|
||||
"allow_redirects": "never",
|
||||
"enabled": true
|
||||
},
|
||||
"canceled_at": null,
|
||||
"cancellation_reason": null,
|
||||
"capture_method": "manual",
|
||||
"client_secret": "pi_FAKE_CLIENT_SECRET",
|
||||
"confirmation_method": "automatic",
|
||||
"created": 1702477366,
|
||||
"currency": "pln",
|
||||
"customer": null,
|
||||
"description": null,
|
||||
"id": "pi_3OMtKkEosEcNBN5m08NksTmO",
|
||||
"invoice": null,
|
||||
"last_payment_error": null,
|
||||
"latest_charge": null,
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
"channelId": "1",
|
||||
"checkoutId": "c29tZS1jaGVja291dC1pZA==",
|
||||
"transactionId": "555555"
|
||||
},
|
||||
"next_action": null,
|
||||
"object": "payment_intent",
|
||||
"on_behalf_of": null,
|
||||
"payment_method": null,
|
||||
"payment_method_configuration_details": {
|
||||
"id": "pmc_1LVZxMEosEcNBN5manO2iTW7",
|
||||
"parent": null
|
||||
},
|
||||
"payment_method_options": {
|
||||
"card": {
|
||||
"installments": null,
|
||||
"mandate_options": null,
|
||||
"network": null,
|
||||
"request_three_d_secure": "automatic"
|
||||
},
|
||||
"link": {
|
||||
"persistent_token": null
|
||||
},
|
||||
"paypal": {
|
||||
"preferred_locale": null,
|
||||
"reference": null
|
||||
}
|
||||
},
|
||||
"payment_method_types": [
|
||||
"card",
|
||||
"link",
|
||||
"paypal"
|
||||
],
|
||||
"processing": null,
|
||||
"receipt_email": null,
|
||||
"review": null,
|
||||
"setup_future_usage": null,
|
||||
"shipping": null,
|
||||
"source": null,
|
||||
"statement_descriptor": null,
|
||||
"statement_descriptor_suffix": null,
|
||||
"status": "requires_payment_method",
|
||||
"transfer_data": null,
|
||||
"transfer_group": null
|
||||
}
|
||||
},
|
||||
"cookies": [],
|
||||
"headers": [
|
||||
{
|
||||
"name": "access-control-allow-credentials",
|
||||
"value": "true"
|
||||
},
|
||||
{
|
||||
"name": "access-control-allow-methods",
|
||||
"value": "GET,HEAD,PUT,PATCH,POST,DELETE"
|
||||
},
|
||||
{
|
||||
"name": "access-control-allow-origin",
|
||||
"value": "*"
|
||||
},
|
||||
{
|
||||
"name": "access-control-expose-headers",
|
||||
"value": "Request-Id, Stripe-Manage-Version, Stripe-Should-Retry, X-Stripe-External-Auth-Required, X-Stripe-Privileged-Session-Required"
|
||||
},
|
||||
{
|
||||
"name": "access-control-max-age",
|
||||
"value": "300"
|
||||
},
|
||||
{
|
||||
"name": "cache-control",
|
||||
"value": "no-cache, no-store"
|
||||
},
|
||||
{
|
||||
"name": "connection",
|
||||
"value": "keep-alive"
|
||||
},
|
||||
{
|
||||
"name": "content-length",
|
||||
"value": "1695"
|
||||
},
|
||||
{
|
||||
"name": "content-security-policy",
|
||||
"value": "report-uri https://q.stripe.com/csp-report?p=v1%2Fpayment_intents; block-all-mixed-content; default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; img-src 'self'; script-src 'self' 'report-sample'; style-src 'self'"
|
||||
},
|
||||
{
|
||||
"name": "content-type",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"name": "date",
|
||||
"value": "Wed, 13 Dec 2023 14:22:46 GMT"
|
||||
},
|
||||
{
|
||||
"name": "idempotency-key",
|
||||
"value": "stripe-node-retry-35ed3a71-22bd-48ad-bd49-05594aff0737"
|
||||
},
|
||||
{
|
||||
"name": "original-request",
|
||||
"value": "req_hvPjj2AfbcgagD"
|
||||
},
|
||||
{
|
||||
"name": "request-id",
|
||||
"value": "req_hvPjj2AfbcgagD"
|
||||
},
|
||||
{
|
||||
"name": "server",
|
||||
"value": "nginx"
|
||||
},
|
||||
{
|
||||
"name": "strict-transport-security",
|
||||
"value": "max-age=63072000; includeSubDomains; preload"
|
||||
},
|
||||
{
|
||||
"name": "stripe-should-retry",
|
||||
"value": "false"
|
||||
},
|
||||
{
|
||||
"name": "stripe-version",
|
||||
"value": "2023-10-16"
|
||||
},
|
||||
{
|
||||
"name": "vary",
|
||||
"value": "Origin"
|
||||
},
|
||||
{
|
||||
"name": "x-stripe-routing-context-priority-tier",
|
||||
"value": "api-testmode"
|
||||
}
|
||||
],
|
||||
"headersSize": 1095,
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"redirectURL": "",
|
||||
"status": 200,
|
||||
"statusText": "OK"
|
||||
},
|
||||
"startedDateTime": "2023-12-13T14:22:45.832Z",
|
||||
"time": 877,
|
||||
"timings": {
|
||||
"blocked": -1,
|
||||
"connect": -1,
|
||||
"dns": -1,
|
||||
"receive": 0,
|
||||
"send": 0,
|
||||
"ssl": -1,
|
||||
"wait": 877
|
||||
}
|
||||
},
|
||||
{
|
||||
"_id": "8c75c844b74ed6c3188416158e88dc8f",
|
||||
"_order": 0,
|
||||
"cache": {},
|
||||
"request": {
|
||||
"bodySize": 31,
|
||||
"cookies": [],
|
||||
"headers": [
|
||||
{
|
||||
"name": "accept",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"name": "content-length",
|
||||
"value": "31"
|
||||
},
|
||||
{
|
||||
"name": "content-type",
|
||||
"value": "application/x-www-form-urlencoded"
|
||||
},
|
||||
{
|
||||
"name": "idempotency-key",
|
||||
"value": "stripe-node-retry-1f0221dc-e61a-44b0-ab87-ea8a4c786a84"
|
||||
},
|
||||
{
|
||||
"name": "stripe-version",
|
||||
"value": "2023-10-16"
|
||||
},
|
||||
{
|
||||
"name": "user-agent",
|
||||
"value": "Stripe/v1 NodeBindings/14.8.0"
|
||||
}
|
||||
],
|
||||
"headersSize": 849,
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"method": "POST",
|
||||
"postData": {
|
||||
"mimeType": "application/x-www-form-urlencoded",
|
||||
"params": [],
|
||||
"text": "payment_method=pm_card_discover"
|
||||
},
|
||||
"queryString": [],
|
||||
"url": "https://api.stripe.com/v1/payment_intents/pi_3OMtKkEosEcNBN5m08NksTmO/confirm"
|
||||
},
|
||||
"response": {
|
||||
"bodySize": 1742,
|
||||
"content": {
|
||||
"mimeType": "application/json",
|
||||
"size": 1742,
|
||||
"text": {
|
||||
"amount": 22299,
|
||||
"amount_capturable": 22299,
|
||||
"amount_details": {
|
||||
"tip": {}
|
||||
},
|
||||
"amount_received": 0,
|
||||
"application": null,
|
||||
"application_fee_amount": null,
|
||||
"automatic_payment_methods": {
|
||||
"allow_redirects": "never",
|
||||
"enabled": true
|
||||
},
|
||||
"canceled_at": null,
|
||||
"cancellation_reason": null,
|
||||
"capture_method": "manual",
|
||||
"client_secret": "pi_FAKE_CLIENT_SECRET",
|
||||
"confirmation_method": "automatic",
|
||||
"created": 1702477366,
|
||||
"currency": "pln",
|
||||
"customer": null,
|
||||
"description": null,
|
||||
"id": "pi_3OMtKkEosEcNBN5m08NksTmO",
|
||||
"invoice": null,
|
||||
"last_payment_error": null,
|
||||
"latest_charge": "ch_3OMtKkEosEcNBN5m0NHZf7BJ",
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
"channelId": "1",
|
||||
"checkoutId": "c29tZS1jaGVja291dC1pZA==",
|
||||
"transactionId": "555555"
|
||||
},
|
||||
"next_action": null,
|
||||
"object": "payment_intent",
|
||||
"on_behalf_of": null,
|
||||
"payment_method": "pm_1OMtKlEosEcNBN5mPXstSBX8",
|
||||
"payment_method_configuration_details": {
|
||||
"id": "pmc_1LVZxMEosEcNBN5manO2iTW7",
|
||||
"parent": null
|
||||
},
|
||||
"payment_method_options": {
|
||||
"card": {
|
||||
"installments": null,
|
||||
"mandate_options": null,
|
||||
"network": null,
|
||||
"request_three_d_secure": "automatic"
|
||||
},
|
||||
"link": {
|
||||
"persistent_token": null
|
||||
},
|
||||
"paypal": {
|
||||
"preferred_locale": null,
|
||||
"reference": null
|
||||
}
|
||||
},
|
||||
"payment_method_types": [
|
||||
"card",
|
||||
"link",
|
||||
"paypal"
|
||||
],
|
||||
"processing": null,
|
||||
"receipt_email": null,
|
||||
"review": null,
|
||||
"setup_future_usage": null,
|
||||
"shipping": null,
|
||||
"source": null,
|
||||
"statement_descriptor": null,
|
||||
"statement_descriptor_suffix": null,
|
||||
"status": "requires_capture",
|
||||
"transfer_data": null,
|
||||
"transfer_group": null
|
||||
}
|
||||
},
|
||||
"cookies": [],
|
||||
"headers": [
|
||||
{
|
||||
"name": "access-control-allow-credentials",
|
||||
"value": "true"
|
||||
},
|
||||
{
|
||||
"name": "access-control-allow-methods",
|
||||
"value": "GET,HEAD,PUT,PATCH,POST,DELETE"
|
||||
},
|
||||
{
|
||||
"name": "access-control-allow-origin",
|
||||
"value": "*"
|
||||
},
|
||||
{
|
||||
"name": "access-control-expose-headers",
|
||||
"value": "Request-Id, Stripe-Manage-Version, Stripe-Should-Retry, X-Stripe-External-Auth-Required, X-Stripe-Privileged-Session-Required"
|
||||
},
|
||||
{
|
||||
"name": "access-control-max-age",
|
||||
"value": "300"
|
||||
},
|
||||
{
|
||||
"name": "cache-control",
|
||||
"value": "no-cache, no-store"
|
||||
},
|
||||
{
|
||||
"name": "connection",
|
||||
"value": "keep-alive"
|
||||
},
|
||||
{
|
||||
"name": "content-length",
|
||||
"value": "1742"
|
||||
},
|
||||
{
|
||||
"name": "content-security-policy",
|
||||
"value": "report-uri https://q.stripe.com/csp-report?p=v1%2Fpayment_intents%2F%3Aintent%2Fconfirm; block-all-mixed-content; default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; img-src 'self'; script-src 'self' 'report-sample'; style-src 'self'"
|
||||
},
|
||||
{
|
||||
"name": "content-type",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"name": "date",
|
||||
"value": "Wed, 13 Dec 2023 14:22:47 GMT"
|
||||
},
|
||||
{
|
||||
"name": "idempotency-key",
|
||||
"value": "stripe-node-retry-1f0221dc-e61a-44b0-ab87-ea8a4c786a84"
|
||||
},
|
||||
{
|
||||
"name": "original-request",
|
||||
"value": "req_eRZpZNvZqJN9L0"
|
||||
},
|
||||
{
|
||||
"name": "request-id",
|
||||
"value": "req_eRZpZNvZqJN9L0"
|
||||
},
|
||||
{
|
||||
"name": "server",
|
||||
"value": "nginx"
|
||||
},
|
||||
{
|
||||
"name": "strict-transport-security",
|
||||
"value": "max-age=63072000; includeSubDomains; preload"
|
||||
},
|
||||
{
|
||||
"name": "stripe-should-retry",
|
||||
"value": "false"
|
||||
},
|
||||
{
|
||||
"name": "stripe-version",
|
||||
"value": "2023-10-16"
|
||||
},
|
||||
{
|
||||
"name": "vary",
|
||||
"value": "Origin"
|
||||
},
|
||||
{
|
||||
"name": "x-stripe-routing-context-priority-tier",
|
||||
"value": "api-testmode"
|
||||
}
|
||||
],
|
||||
"headersSize": 1117,
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"redirectURL": "",
|
||||
"status": 200,
|
||||
"statusText": "OK"
|
||||
},
|
||||
"startedDateTime": "2023-12-13T14:22:46.711Z",
|
||||
"time": 1107,
|
||||
"timings": {
|
||||
"blocked": -1,
|
||||
"connect": -1,
|
||||
"dns": -1,
|
||||
"receive": 0,
|
||||
"send": 0,
|
||||
"ssl": -1,
|
||||
"wait": 1107
|
||||
}
|
||||
},
|
||||
{
|
||||
"_id": "84dbf2cb146ac5b4138902928d22b488",
|
||||
"_order": 0,
|
||||
"cache": {},
|
||||
"request": {
|
||||
"bodySize": 0,
|
||||
"cookies": [],
|
||||
"headers": [
|
||||
{
|
||||
"name": "accept",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"name": "content-length",
|
||||
"value": "0"
|
||||
},
|
||||
{
|
||||
"name": "content-type",
|
||||
"value": "application/x-www-form-urlencoded"
|
||||
},
|
||||
{
|
||||
"name": "idempotency-key",
|
||||
"value": "stripe-node-retry-bb510413-59a0-45e4-98d0-d7ac00a02b53"
|
||||
},
|
||||
{
|
||||
"name": "stripe-version",
|
||||
"value": "2023-10-16"
|
||||
},
|
||||
{
|
||||
"name": "user-agent",
|
||||
"value": "Stripe/v1 NodeBindings/14.8.0"
|
||||
}
|
||||
],
|
||||
"headersSize": 847,
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"method": "POST",
|
||||
"queryString": [],
|
||||
"url": "https://api.stripe.com/v1/payment_intents/pi_3OMtKjEosEcNBN5m0IMpKdZI/cancel"
|
||||
},
|
||||
"response": {
|
||||
"bodySize": 1736,
|
||||
"content": {
|
||||
"mimeType": "application/json",
|
||||
"size": 1736,
|
||||
"text": {
|
||||
"amount": 22299,
|
||||
"amount_capturable": 0,
|
||||
"amount_details": {
|
||||
"tip": {}
|
||||
},
|
||||
"amount_received": 0,
|
||||
"application": null,
|
||||
"application_fee_amount": null,
|
||||
"automatic_payment_methods": {
|
||||
"allow_redirects": "never",
|
||||
"enabled": true
|
||||
},
|
||||
"canceled_at": 1702477367,
|
||||
"cancellation_reason": null,
|
||||
"capture_method": "manual",
|
||||
"client_secret": "pi_FAKE_CLIENT_SECRET",
|
||||
"confirmation_method": "automatic",
|
||||
"created": 1702477365,
|
||||
"currency": "pln",
|
||||
"customer": null,
|
||||
"description": null,
|
||||
"id": "pi_3OMtKjEosEcNBN5m0IMpKdZI",
|
||||
"invoice": null,
|
||||
"last_payment_error": null,
|
||||
"latest_charge": "ch_3OMtKjEosEcNBN5m0JHUOrqY",
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
"channelId": "1",
|
||||
"checkoutId": "c29tZS1jaGVja291dC1pZA==",
|
||||
"transactionId": "555555"
|
||||
},
|
||||
"next_action": null,
|
||||
"object": "payment_intent",
|
||||
"on_behalf_of": null,
|
||||
"payment_method": "pm_1OMtKkEosEcNBN5mSqNhoa5a",
|
||||
"payment_method_configuration_details": {
|
||||
"id": "pmc_1LVZxMEosEcNBN5manO2iTW7",
|
||||
"parent": null
|
||||
},
|
||||
"payment_method_options": {
|
||||
"card": {
|
||||
"installments": null,
|
||||
"mandate_options": null,
|
||||
"network": null,
|
||||
"request_three_d_secure": "automatic"
|
||||
},
|
||||
"link": {
|
||||
"persistent_token": null
|
||||
},
|
||||
"paypal": {
|
||||
"preferred_locale": null,
|
||||
"reference": null
|
||||
}
|
||||
},
|
||||
"payment_method_types": [
|
||||
"card",
|
||||
"link",
|
||||
"paypal"
|
||||
],
|
||||
"processing": null,
|
||||
"receipt_email": null,
|
||||
"review": null,
|
||||
"setup_future_usage": null,
|
||||
"shipping": null,
|
||||
"source": null,
|
||||
"statement_descriptor": null,
|
||||
"statement_descriptor_suffix": null,
|
||||
"status": "canceled",
|
||||
"transfer_data": null,
|
||||
"transfer_group": null
|
||||
}
|
||||
},
|
||||
"cookies": [],
|
||||
"headers": [
|
||||
{
|
||||
"name": "access-control-allow-credentials",
|
||||
"value": "true"
|
||||
},
|
||||
{
|
||||
"name": "access-control-allow-methods",
|
||||
"value": "GET,HEAD,PUT,PATCH,POST,DELETE"
|
||||
},
|
||||
{
|
||||
"name": "access-control-allow-origin",
|
||||
"value": "*"
|
||||
},
|
||||
{
|
||||
"name": "access-control-expose-headers",
|
||||
"value": "Request-Id, Stripe-Manage-Version, Stripe-Should-Retry, X-Stripe-External-Auth-Required, X-Stripe-Privileged-Session-Required"
|
||||
},
|
||||
{
|
||||
"name": "access-control-max-age",
|
||||
"value": "300"
|
||||
},
|
||||
{
|
||||
"name": "cache-control",
|
||||
"value": "no-cache, no-store"
|
||||
},
|
||||
{
|
||||
"name": "connection",
|
||||
"value": "keep-alive"
|
||||
},
|
||||
{
|
||||
"name": "content-length",
|
||||
"value": "1736"
|
||||
},
|
||||
{
|
||||
"name": "content-security-policy",
|
||||
"value": "report-uri https://q.stripe.com/csp-report?p=v1%2Fpayment_intents%2F%3Aintent%2Fcancel; block-all-mixed-content; default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; img-src 'self'; script-src 'self' 'report-sample'; style-src 'self'"
|
||||
},
|
||||
{
|
||||
"name": "content-type",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"name": "date",
|
||||
"value": "Wed, 13 Dec 2023 14:22:48 GMT"
|
||||
},
|
||||
{
|
||||
"name": "idempotency-key",
|
||||
"value": "stripe-node-retry-bb510413-59a0-45e4-98d0-d7ac00a02b53"
|
||||
},
|
||||
{
|
||||
"name": "original-request",
|
||||
"value": "req_ds3W276l8ATpQF"
|
||||
},
|
||||
{
|
||||
"name": "request-id",
|
||||
"value": "req_ds3W276l8ATpQF"
|
||||
},
|
||||
{
|
||||
"name": "server",
|
||||
"value": "nginx"
|
||||
},
|
||||
{
|
||||
"name": "strict-transport-security",
|
||||
"value": "max-age=63072000; includeSubDomains; preload"
|
||||
},
|
||||
{
|
||||
"name": "stripe-should-retry",
|
||||
"value": "false"
|
||||
},
|
||||
{
|
||||
"name": "stripe-version",
|
||||
"value": "2023-10-16"
|
||||
},
|
||||
{
|
||||
"name": "vary",
|
||||
"value": "Origin"
|
||||
},
|
||||
{
|
||||
"name": "x-stripe-routing-context-priority-tier",
|
||||
"value": "api-testmode"
|
||||
}
|
||||
],
|
||||
"headersSize": 1116,
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"redirectURL": "",
|
||||
"status": 200,
|
||||
"statusText": "OK"
|
||||
},
|
||||
"startedDateTime": "2023-12-13T14:22:47.213Z",
|
||||
"time": 706,
|
||||
"timings": {
|
||||
"blocked": -1,
|
||||
"connect": -1,
|
||||
"dns": -1,
|
||||
"receive": 0,
|
||||
"send": 0,
|
||||
"ssl": -1,
|
||||
"wait": 706
|
||||
}
|
||||
},
|
||||
{
|
||||
"_id": "4b15b4e92e937ac7ac8458b03131eb41",
|
||||
"_order": 0,
|
||||
"cache": {},
|
||||
"request": {
|
||||
"bodySize": 0,
|
||||
"cookies": [],
|
||||
"headers": [
|
||||
{
|
||||
"name": "accept",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"name": "content-length",
|
||||
"value": "0"
|
||||
},
|
||||
{
|
||||
"name": "content-type",
|
||||
"value": "application/x-www-form-urlencoded"
|
||||
},
|
||||
{
|
||||
"name": "idempotency-key",
|
||||
"value": "stripe-node-retry-f52c36d2-3d7b-4483-b205-8439d1d4cfb2"
|
||||
},
|
||||
{
|
||||
"name": "stripe-version",
|
||||
"value": "2023-10-16"
|
||||
},
|
||||
{
|
||||
"name": "user-agent",
|
||||
"value": "Stripe/v1 NodeBindings/14.8.0"
|
||||
}
|
||||
],
|
||||
"headersSize": 847,
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"method": "POST",
|
||||
"queryString": [],
|
||||
"url": "https://api.stripe.com/v1/payment_intents/pi_3OMtKkEosEcNBN5m08NksTmO/cancel"
|
||||
},
|
||||
"response": {
|
||||
"bodySize": 1736,
|
||||
"content": {
|
||||
"mimeType": "application/json",
|
||||
"size": 1736,
|
||||
"text": {
|
||||
"amount": 22299,
|
||||
"amount_capturable": 0,
|
||||
"amount_details": {
|
||||
"tip": {}
|
||||
},
|
||||
"amount_received": 0,
|
||||
"application": null,
|
||||
"application_fee_amount": null,
|
||||
"automatic_payment_methods": {
|
||||
"allow_redirects": "never",
|
||||
"enabled": true
|
||||
},
|
||||
"canceled_at": 1702477368,
|
||||
"cancellation_reason": null,
|
||||
"capture_method": "manual",
|
||||
"client_secret": "pi_FAKE_CLIENT_SECRET",
|
||||
"confirmation_method": "automatic",
|
||||
"created": 1702477366,
|
||||
"currency": "pln",
|
||||
"customer": null,
|
||||
"description": null,
|
||||
"id": "pi_3OMtKkEosEcNBN5m08NksTmO",
|
||||
"invoice": null,
|
||||
"last_payment_error": null,
|
||||
"latest_charge": "ch_3OMtKkEosEcNBN5m0NHZf7BJ",
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
"channelId": "1",
|
||||
"checkoutId": "c29tZS1jaGVja291dC1pZA==",
|
||||
"transactionId": "555555"
|
||||
},
|
||||
"next_action": null,
|
||||
"object": "payment_intent",
|
||||
"on_behalf_of": null,
|
||||
"payment_method": "pm_1OMtKlEosEcNBN5mPXstSBX8",
|
||||
"payment_method_configuration_details": {
|
||||
"id": "pmc_1LVZxMEosEcNBN5manO2iTW7",
|
||||
"parent": null
|
||||
},
|
||||
"payment_method_options": {
|
||||
"card": {
|
||||
"installments": null,
|
||||
"mandate_options": null,
|
||||
"network": null,
|
||||
"request_three_d_secure": "automatic"
|
||||
},
|
||||
"link": {
|
||||
"persistent_token": null
|
||||
},
|
||||
"paypal": {
|
||||
"preferred_locale": null,
|
||||
"reference": null
|
||||
}
|
||||
},
|
||||
"payment_method_types": [
|
||||
"card",
|
||||
"link",
|
||||
"paypal"
|
||||
],
|
||||
"processing": null,
|
||||
"receipt_email": null,
|
||||
"review": null,
|
||||
"setup_future_usage": null,
|
||||
"shipping": null,
|
||||
"source": null,
|
||||
"statement_descriptor": null,
|
||||
"statement_descriptor_suffix": null,
|
||||
"status": "canceled",
|
||||
"transfer_data": null,
|
||||
"transfer_group": null
|
||||
}
|
||||
},
|
||||
"cookies": [],
|
||||
"headers": [
|
||||
{
|
||||
"name": "access-control-allow-credentials",
|
||||
"value": "true"
|
||||
},
|
||||
{
|
||||
"name": "access-control-allow-methods",
|
||||
"value": "GET,HEAD,PUT,PATCH,POST,DELETE"
|
||||
},
|
||||
{
|
||||
"name": "access-control-allow-origin",
|
||||
"value": "*"
|
||||
},
|
||||
{
|
||||
"name": "access-control-expose-headers",
|
||||
"value": "Request-Id, Stripe-Manage-Version, Stripe-Should-Retry, X-Stripe-External-Auth-Required, X-Stripe-Privileged-Session-Required"
|
||||
},
|
||||
{
|
||||
"name": "access-control-max-age",
|
||||
"value": "300"
|
||||
},
|
||||
{
|
||||
"name": "cache-control",
|
||||
"value": "no-cache, no-store"
|
||||
},
|
||||
{
|
||||
"name": "connection",
|
||||
"value": "keep-alive"
|
||||
},
|
||||
{
|
||||
"name": "content-length",
|
||||
"value": "1736"
|
||||
},
|
||||
{
|
||||
"name": "content-security-policy",
|
||||
"value": "report-uri https://q.stripe.com/csp-report?p=v1%2Fpayment_intents%2F%3Aintent%2Fcancel; block-all-mixed-content; default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; img-src 'self'; script-src 'self' 'report-sample'; style-src 'self'"
|
||||
},
|
||||
{
|
||||
"name": "content-type",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"name": "date",
|
||||
"value": "Wed, 13 Dec 2023 14:22:48 GMT"
|
||||
},
|
||||
{
|
||||
"name": "idempotency-key",
|
||||
"value": "stripe-node-retry-f52c36d2-3d7b-4483-b205-8439d1d4cfb2"
|
||||
},
|
||||
{
|
||||
"name": "original-request",
|
||||
"value": "req_IRm4JG2mvONwvn"
|
||||
},
|
||||
{
|
||||
"name": "request-id",
|
||||
"value": "req_IRm4JG2mvONwvn"
|
||||
},
|
||||
{
|
||||
"name": "server",
|
||||
"value": "nginx"
|
||||
},
|
||||
{
|
||||
"name": "strict-transport-security",
|
||||
"value": "max-age=63072000; includeSubDomains; preload"
|
||||
},
|
||||
{
|
||||
"name": "stripe-should-retry",
|
||||
"value": "false"
|
||||
},
|
||||
{
|
||||
"name": "stripe-version",
|
||||
"value": "2023-10-16"
|
||||
},
|
||||
{
|
||||
"name": "vary",
|
||||
"value": "Origin"
|
||||
},
|
||||
{
|
||||
"name": "x-stripe-routing-context-priority-tier",
|
||||
"value": "api-testmode"
|
||||
}
|
||||
],
|
||||
"headersSize": 1116,
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"redirectURL": "",
|
||||
"status": 200,
|
||||
"statusText": "OK"
|
||||
},
|
||||
"startedDateTime": "2023-12-13T14:22:47.821Z",
|
||||
"time": 673,
|
||||
"timings": {
|
||||
"blocked": -1,
|
||||
"connect": -1,
|
||||
"dns": -1,
|
||||
"receive": 0,
|
||||
"send": 0,
|
||||
"ssl": -1,
|
||||
"wait": 673
|
||||
}
|
||||
}
|
||||
],
|
||||
"pages": [],
|
||||
"version": "1.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,678 @@
|
||||
{
|
||||
"log": {
|
||||
"_recordingName": "TransactionCancelationRequestedWebhookHandler/'Mastercard (debit)' 'pm_card_mastercard_debit'/should cancel pre-authorized card",
|
||||
"creator": {
|
||||
"comment": "persister:fs",
|
||||
"name": "Polly.JS",
|
||||
"version": "6.0.5"
|
||||
},
|
||||
"entries": [
|
||||
{
|
||||
"_id": "e832e0e9ad4807ac377f5e27c0fd1344",
|
||||
"_order": 0,
|
||||
"cache": {},
|
||||
"request": {
|
||||
"bodySize": 239,
|
||||
"cookies": [],
|
||||
"headers": [
|
||||
{
|
||||
"name": "accept",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"name": "content-length",
|
||||
"value": "239"
|
||||
},
|
||||
{
|
||||
"name": "content-type",
|
||||
"value": "application/x-www-form-urlencoded"
|
||||
},
|
||||
{
|
||||
"name": "idempotency-key",
|
||||
"value": "stripe-node-retry-b1d1912b-1d36-4d72-a9f8-bf94eadf6db0"
|
||||
},
|
||||
{
|
||||
"name": "stripe-version",
|
||||
"value": "2023-10-16"
|
||||
},
|
||||
{
|
||||
"name": "user-agent",
|
||||
"value": "Stripe/v1 NodeBindings/14.8.0"
|
||||
}
|
||||
],
|
||||
"headersSize": 814,
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"method": "POST",
|
||||
"postData": {
|
||||
"mimeType": "application/x-www-form-urlencoded",
|
||||
"params": [],
|
||||
"text": "automatic_payment_methods[enabled]=true&automatic_payment_methods[allow_redirects]=never&amount=22299¤cy=PLN&capture_method=manual&metadata[transactionId]=555555&metadata[channelId]=1&metadata[checkoutId]=c29tZS1jaGVja291dC1pZA%3D%3D"
|
||||
},
|
||||
"queryString": [],
|
||||
"url": "https://api.stripe.com/v1/payment_intents"
|
||||
},
|
||||
"response": {
|
||||
"bodySize": 1695,
|
||||
"content": {
|
||||
"mimeType": "application/json",
|
||||
"size": 1695,
|
||||
"text": {
|
||||
"amount": 22299,
|
||||
"amount_capturable": 0,
|
||||
"amount_details": {
|
||||
"tip": {}
|
||||
},
|
||||
"amount_received": 0,
|
||||
"application": null,
|
||||
"application_fee_amount": null,
|
||||
"automatic_payment_methods": {
|
||||
"allow_redirects": "never",
|
||||
"enabled": true
|
||||
},
|
||||
"canceled_at": null,
|
||||
"cancellation_reason": null,
|
||||
"capture_method": "manual",
|
||||
"client_secret": "pi_FAKE_CLIENT_SECRET",
|
||||
"confirmation_method": "automatic",
|
||||
"created": 1702477934,
|
||||
"currency": "pln",
|
||||
"customer": null,
|
||||
"description": null,
|
||||
"id": "pi_3OMtTuEosEcNBN5m0R6NgfQL",
|
||||
"invoice": null,
|
||||
"last_payment_error": null,
|
||||
"latest_charge": null,
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
"channelId": "1",
|
||||
"checkoutId": "c29tZS1jaGVja291dC1pZA==",
|
||||
"transactionId": "555555"
|
||||
},
|
||||
"next_action": null,
|
||||
"object": "payment_intent",
|
||||
"on_behalf_of": null,
|
||||
"payment_method": null,
|
||||
"payment_method_configuration_details": {
|
||||
"id": "pmc_1LVZxMEosEcNBN5manO2iTW7",
|
||||
"parent": null
|
||||
},
|
||||
"payment_method_options": {
|
||||
"card": {
|
||||
"installments": null,
|
||||
"mandate_options": null,
|
||||
"network": null,
|
||||
"request_three_d_secure": "automatic"
|
||||
},
|
||||
"link": {
|
||||
"persistent_token": null
|
||||
},
|
||||
"paypal": {
|
||||
"preferred_locale": null,
|
||||
"reference": null
|
||||
}
|
||||
},
|
||||
"payment_method_types": [
|
||||
"card",
|
||||
"link",
|
||||
"paypal"
|
||||
],
|
||||
"processing": null,
|
||||
"receipt_email": null,
|
||||
"review": null,
|
||||
"setup_future_usage": null,
|
||||
"shipping": null,
|
||||
"source": null,
|
||||
"statement_descriptor": null,
|
||||
"statement_descriptor_suffix": null,
|
||||
"status": "requires_payment_method",
|
||||
"transfer_data": null,
|
||||
"transfer_group": null
|
||||
}
|
||||
},
|
||||
"cookies": [],
|
||||
"headers": [
|
||||
{
|
||||
"name": "access-control-allow-credentials",
|
||||
"value": "true"
|
||||
},
|
||||
{
|
||||
"name": "access-control-allow-methods",
|
||||
"value": "GET,HEAD,PUT,PATCH,POST,DELETE"
|
||||
},
|
||||
{
|
||||
"name": "access-control-allow-origin",
|
||||
"value": "*"
|
||||
},
|
||||
{
|
||||
"name": "access-control-expose-headers",
|
||||
"value": "Request-Id, Stripe-Manage-Version, Stripe-Should-Retry, X-Stripe-External-Auth-Required, X-Stripe-Privileged-Session-Required"
|
||||
},
|
||||
{
|
||||
"name": "access-control-max-age",
|
||||
"value": "300"
|
||||
},
|
||||
{
|
||||
"name": "cache-control",
|
||||
"value": "no-cache, no-store"
|
||||
},
|
||||
{
|
||||
"name": "connection",
|
||||
"value": "keep-alive"
|
||||
},
|
||||
{
|
||||
"name": "content-length",
|
||||
"value": "1695"
|
||||
},
|
||||
{
|
||||
"name": "content-security-policy",
|
||||
"value": "report-uri https://q.stripe.com/csp-report?p=v1%2Fpayment_intents; block-all-mixed-content; default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; img-src 'self'; script-src 'self' 'report-sample'; style-src 'self'"
|
||||
},
|
||||
{
|
||||
"name": "content-type",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"name": "date",
|
||||
"value": "Wed, 13 Dec 2023 14:32:14 GMT"
|
||||
},
|
||||
{
|
||||
"name": "idempotency-key",
|
||||
"value": "stripe-node-retry-b1d1912b-1d36-4d72-a9f8-bf94eadf6db0"
|
||||
},
|
||||
{
|
||||
"name": "original-request",
|
||||
"value": "req_32vul3I68NVzWe"
|
||||
},
|
||||
{
|
||||
"name": "request-id",
|
||||
"value": "req_32vul3I68NVzWe"
|
||||
},
|
||||
{
|
||||
"name": "server",
|
||||
"value": "nginx"
|
||||
},
|
||||
{
|
||||
"name": "strict-transport-security",
|
||||
"value": "max-age=63072000; includeSubDomains; preload"
|
||||
},
|
||||
{
|
||||
"name": "stripe-should-retry",
|
||||
"value": "false"
|
||||
},
|
||||
{
|
||||
"name": "stripe-version",
|
||||
"value": "2023-10-16"
|
||||
},
|
||||
{
|
||||
"name": "vary",
|
||||
"value": "Origin"
|
||||
},
|
||||
{
|
||||
"name": "x-stripe-routing-context-priority-tier",
|
||||
"value": "api-testmode"
|
||||
}
|
||||
],
|
||||
"headersSize": 1095,
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"redirectURL": "",
|
||||
"status": 200,
|
||||
"statusText": "OK"
|
||||
},
|
||||
"startedDateTime": "2023-12-13T14:32:13.625Z",
|
||||
"time": 508,
|
||||
"timings": {
|
||||
"blocked": -1,
|
||||
"connect": -1,
|
||||
"dns": -1,
|
||||
"receive": 0,
|
||||
"send": 0,
|
||||
"ssl": -1,
|
||||
"wait": 508
|
||||
}
|
||||
},
|
||||
{
|
||||
"_id": "283610a10f2a990fabc6dce8f0bd6ba2",
|
||||
"_order": 0,
|
||||
"cache": {},
|
||||
"request": {
|
||||
"bodySize": 39,
|
||||
"cookies": [],
|
||||
"headers": [
|
||||
{
|
||||
"name": "accept",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"name": "content-length",
|
||||
"value": "39"
|
||||
},
|
||||
{
|
||||
"name": "content-type",
|
||||
"value": "application/x-www-form-urlencoded"
|
||||
},
|
||||
{
|
||||
"name": "idempotency-key",
|
||||
"value": "stripe-node-retry-b9c216c3-7122-4cad-afac-425d8dbe14ec"
|
||||
},
|
||||
{
|
||||
"name": "stripe-version",
|
||||
"value": "2023-10-16"
|
||||
},
|
||||
{
|
||||
"name": "user-agent",
|
||||
"value": "Stripe/v1 NodeBindings/14.8.0"
|
||||
}
|
||||
],
|
||||
"headersSize": 849,
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"method": "POST",
|
||||
"postData": {
|
||||
"mimeType": "application/x-www-form-urlencoded",
|
||||
"params": [],
|
||||
"text": "payment_method=pm_card_mastercard_debit"
|
||||
},
|
||||
"queryString": [],
|
||||
"url": "https://api.stripe.com/v1/payment_intents/pi_3OMtTuEosEcNBN5m0R6NgfQL/confirm"
|
||||
},
|
||||
"response": {
|
||||
"bodySize": 1742,
|
||||
"content": {
|
||||
"mimeType": "application/json",
|
||||
"size": 1742,
|
||||
"text": {
|
||||
"amount": 22299,
|
||||
"amount_capturable": 22299,
|
||||
"amount_details": {
|
||||
"tip": {}
|
||||
},
|
||||
"amount_received": 0,
|
||||
"application": null,
|
||||
"application_fee_amount": null,
|
||||
"automatic_payment_methods": {
|
||||
"allow_redirects": "never",
|
||||
"enabled": true
|
||||
},
|
||||
"canceled_at": null,
|
||||
"cancellation_reason": null,
|
||||
"capture_method": "manual",
|
||||
"client_secret": "pi_FAKE_CLIENT_SECRET",
|
||||
"confirmation_method": "automatic",
|
||||
"created": 1702477934,
|
||||
"currency": "pln",
|
||||
"customer": null,
|
||||
"description": null,
|
||||
"id": "pi_3OMtTuEosEcNBN5m0R6NgfQL",
|
||||
"invoice": null,
|
||||
"last_payment_error": null,
|
||||
"latest_charge": "ch_3OMtTuEosEcNBN5m0jKs570F",
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
"channelId": "1",
|
||||
"checkoutId": "c29tZS1jaGVja291dC1pZA==",
|
||||
"transactionId": "555555"
|
||||
},
|
||||
"next_action": null,
|
||||
"object": "payment_intent",
|
||||
"on_behalf_of": null,
|
||||
"payment_method": "pm_1OMtTuEosEcNBN5mmko3ToGt",
|
||||
"payment_method_configuration_details": {
|
||||
"id": "pmc_1LVZxMEosEcNBN5manO2iTW7",
|
||||
"parent": null
|
||||
},
|
||||
"payment_method_options": {
|
||||
"card": {
|
||||
"installments": null,
|
||||
"mandate_options": null,
|
||||
"network": null,
|
||||
"request_three_d_secure": "automatic"
|
||||
},
|
||||
"link": {
|
||||
"persistent_token": null
|
||||
},
|
||||
"paypal": {
|
||||
"preferred_locale": null,
|
||||
"reference": null
|
||||
}
|
||||
},
|
||||
"payment_method_types": [
|
||||
"card",
|
||||
"link",
|
||||
"paypal"
|
||||
],
|
||||
"processing": null,
|
||||
"receipt_email": null,
|
||||
"review": null,
|
||||
"setup_future_usage": null,
|
||||
"shipping": null,
|
||||
"source": null,
|
||||
"statement_descriptor": null,
|
||||
"statement_descriptor_suffix": null,
|
||||
"status": "requires_capture",
|
||||
"transfer_data": null,
|
||||
"transfer_group": null
|
||||
}
|
||||
},
|
||||
"cookies": [],
|
||||
"headers": [
|
||||
{
|
||||
"name": "access-control-allow-credentials",
|
||||
"value": "true"
|
||||
},
|
||||
{
|
||||
"name": "access-control-allow-methods",
|
||||
"value": "GET,HEAD,PUT,PATCH,POST,DELETE"
|
||||
},
|
||||
{
|
||||
"name": "access-control-allow-origin",
|
||||
"value": "*"
|
||||
},
|
||||
{
|
||||
"name": "access-control-expose-headers",
|
||||
"value": "Request-Id, Stripe-Manage-Version, Stripe-Should-Retry, X-Stripe-External-Auth-Required, X-Stripe-Privileged-Session-Required"
|
||||
},
|
||||
{
|
||||
"name": "access-control-max-age",
|
||||
"value": "300"
|
||||
},
|
||||
{
|
||||
"name": "cache-control",
|
||||
"value": "no-cache, no-store"
|
||||
},
|
||||
{
|
||||
"name": "connection",
|
||||
"value": "keep-alive"
|
||||
},
|
||||
{
|
||||
"name": "content-length",
|
||||
"value": "1742"
|
||||
},
|
||||
{
|
||||
"name": "content-security-policy",
|
||||
"value": "report-uri https://q.stripe.com/csp-report?p=v1%2Fpayment_intents%2F%3Aintent%2Fconfirm; block-all-mixed-content; default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; img-src 'self'; script-src 'self' 'report-sample'; style-src 'self'"
|
||||
},
|
||||
{
|
||||
"name": "content-type",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"name": "date",
|
||||
"value": "Wed, 13 Dec 2023 14:32:15 GMT"
|
||||
},
|
||||
{
|
||||
"name": "idempotency-key",
|
||||
"value": "stripe-node-retry-b9c216c3-7122-4cad-afac-425d8dbe14ec"
|
||||
},
|
||||
{
|
||||
"name": "original-request",
|
||||
"value": "req_22VFjJ6X1sX6R8"
|
||||
},
|
||||
{
|
||||
"name": "request-id",
|
||||
"value": "req_22VFjJ6X1sX6R8"
|
||||
},
|
||||
{
|
||||
"name": "server",
|
||||
"value": "nginx"
|
||||
},
|
||||
{
|
||||
"name": "strict-transport-security",
|
||||
"value": "max-age=63072000; includeSubDomains; preload"
|
||||
},
|
||||
{
|
||||
"name": "stripe-should-retry",
|
||||
"value": "false"
|
||||
},
|
||||
{
|
||||
"name": "stripe-version",
|
||||
"value": "2023-10-16"
|
||||
},
|
||||
{
|
||||
"name": "vary",
|
||||
"value": "Origin"
|
||||
},
|
||||
{
|
||||
"name": "x-stripe-routing-context-priority-tier",
|
||||
"value": "api-testmode"
|
||||
}
|
||||
],
|
||||
"headersSize": 1117,
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"redirectURL": "",
|
||||
"status": 200,
|
||||
"statusText": "OK"
|
||||
},
|
||||
"startedDateTime": "2023-12-13T14:32:14.135Z",
|
||||
"time": 1139,
|
||||
"timings": {
|
||||
"blocked": -1,
|
||||
"connect": -1,
|
||||
"dns": -1,
|
||||
"receive": 0,
|
||||
"send": 0,
|
||||
"ssl": -1,
|
||||
"wait": 1139
|
||||
}
|
||||
},
|
||||
{
|
||||
"_id": "c0a977b51c9b970b10a572d94fccc743",
|
||||
"_order": 0,
|
||||
"cache": {},
|
||||
"request": {
|
||||
"bodySize": 0,
|
||||
"cookies": [],
|
||||
"headers": [
|
||||
{
|
||||
"name": "accept",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"name": "content-length",
|
||||
"value": "0"
|
||||
},
|
||||
{
|
||||
"name": "content-type",
|
||||
"value": "application/x-www-form-urlencoded"
|
||||
},
|
||||
{
|
||||
"name": "idempotency-key",
|
||||
"value": "stripe-node-retry-2868a258-4e2e-48d5-9ade-10c8b49bc471"
|
||||
},
|
||||
{
|
||||
"name": "stripe-version",
|
||||
"value": "2023-10-16"
|
||||
},
|
||||
{
|
||||
"name": "user-agent",
|
||||
"value": "Stripe/v1 NodeBindings/14.8.0"
|
||||
}
|
||||
],
|
||||
"headersSize": 847,
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"method": "POST",
|
||||
"queryString": [],
|
||||
"url": "https://api.stripe.com/v1/payment_intents/pi_3OMtTuEosEcNBN5m0R6NgfQL/cancel"
|
||||
},
|
||||
"response": {
|
||||
"bodySize": 1736,
|
||||
"content": {
|
||||
"mimeType": "application/json",
|
||||
"size": 1736,
|
||||
"text": {
|
||||
"amount": 22299,
|
||||
"amount_capturable": 0,
|
||||
"amount_details": {
|
||||
"tip": {}
|
||||
},
|
||||
"amount_received": 0,
|
||||
"application": null,
|
||||
"application_fee_amount": null,
|
||||
"automatic_payment_methods": {
|
||||
"allow_redirects": "never",
|
||||
"enabled": true
|
||||
},
|
||||
"canceled_at": 1702477935,
|
||||
"cancellation_reason": null,
|
||||
"capture_method": "manual",
|
||||
"client_secret": "pi_FAKE_CLIENT_SECRET",
|
||||
"confirmation_method": "automatic",
|
||||
"created": 1702477934,
|
||||
"currency": "pln",
|
||||
"customer": null,
|
||||
"description": null,
|
||||
"id": "pi_3OMtTuEosEcNBN5m0R6NgfQL",
|
||||
"invoice": null,
|
||||
"last_payment_error": null,
|
||||
"latest_charge": "ch_3OMtTuEosEcNBN5m0jKs570F",
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
"channelId": "1",
|
||||
"checkoutId": "c29tZS1jaGVja291dC1pZA==",
|
||||
"transactionId": "555555"
|
||||
},
|
||||
"next_action": null,
|
||||
"object": "payment_intent",
|
||||
"on_behalf_of": null,
|
||||
"payment_method": "pm_1OMtTuEosEcNBN5mmko3ToGt",
|
||||
"payment_method_configuration_details": {
|
||||
"id": "pmc_1LVZxMEosEcNBN5manO2iTW7",
|
||||
"parent": null
|
||||
},
|
||||
"payment_method_options": {
|
||||
"card": {
|
||||
"installments": null,
|
||||
"mandate_options": null,
|
||||
"network": null,
|
||||
"request_three_d_secure": "automatic"
|
||||
},
|
||||
"link": {
|
||||
"persistent_token": null
|
||||
},
|
||||
"paypal": {
|
||||
"preferred_locale": null,
|
||||
"reference": null
|
||||
}
|
||||
},
|
||||
"payment_method_types": [
|
||||
"card",
|
||||
"link",
|
||||
"paypal"
|
||||
],
|
||||
"processing": null,
|
||||
"receipt_email": null,
|
||||
"review": null,
|
||||
"setup_future_usage": null,
|
||||
"shipping": null,
|
||||
"source": null,
|
||||
"statement_descriptor": null,
|
||||
"statement_descriptor_suffix": null,
|
||||
"status": "canceled",
|
||||
"transfer_data": null,
|
||||
"transfer_group": null
|
||||
}
|
||||
},
|
||||
"cookies": [],
|
||||
"headers": [
|
||||
{
|
||||
"name": "access-control-allow-credentials",
|
||||
"value": "true"
|
||||
},
|
||||
{
|
||||
"name": "access-control-allow-methods",
|
||||
"value": "GET,HEAD,PUT,PATCH,POST,DELETE"
|
||||
},
|
||||
{
|
||||
"name": "access-control-allow-origin",
|
||||
"value": "*"
|
||||
},
|
||||
{
|
||||
"name": "access-control-expose-headers",
|
||||
"value": "Request-Id, Stripe-Manage-Version, Stripe-Should-Retry, X-Stripe-External-Auth-Required, X-Stripe-Privileged-Session-Required"
|
||||
},
|
||||
{
|
||||
"name": "access-control-max-age",
|
||||
"value": "300"
|
||||
},
|
||||
{
|
||||
"name": "cache-control",
|
||||
"value": "no-cache, no-store"
|
||||
},
|
||||
{
|
||||
"name": "connection",
|
||||
"value": "keep-alive"
|
||||
},
|
||||
{
|
||||
"name": "content-length",
|
||||
"value": "1736"
|
||||
},
|
||||
{
|
||||
"name": "content-security-policy",
|
||||
"value": "report-uri https://q.stripe.com/csp-report?p=v1%2Fpayment_intents%2F%3Aintent%2Fcancel; block-all-mixed-content; default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; img-src 'self'; script-src 'self' 'report-sample'; style-src 'self'"
|
||||
},
|
||||
{
|
||||
"name": "content-type",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"name": "date",
|
||||
"value": "Wed, 13 Dec 2023 14:32:16 GMT"
|
||||
},
|
||||
{
|
||||
"name": "idempotency-key",
|
||||
"value": "stripe-node-retry-2868a258-4e2e-48d5-9ade-10c8b49bc471"
|
||||
},
|
||||
{
|
||||
"name": "original-request",
|
||||
"value": "req_lpoQ0xy1S1D66y"
|
||||
},
|
||||
{
|
||||
"name": "request-id",
|
||||
"value": "req_lpoQ0xy1S1D66y"
|
||||
},
|
||||
{
|
||||
"name": "server",
|
||||
"value": "nginx"
|
||||
},
|
||||
{
|
||||
"name": "strict-transport-security",
|
||||
"value": "max-age=63072000; includeSubDomains; preload"
|
||||
},
|
||||
{
|
||||
"name": "stripe-should-retry",
|
||||
"value": "false"
|
||||
},
|
||||
{
|
||||
"name": "stripe-version",
|
||||
"value": "2023-10-16"
|
||||
},
|
||||
{
|
||||
"name": "vary",
|
||||
"value": "Origin"
|
||||
},
|
||||
{
|
||||
"name": "x-stripe-routing-context-priority-tier",
|
||||
"value": "api-testmode"
|
||||
}
|
||||
],
|
||||
"headersSize": 1116,
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"redirectURL": "",
|
||||
"status": 200,
|
||||
"statusText": "OK"
|
||||
},
|
||||
"startedDateTime": "2023-12-13T14:32:15.276Z",
|
||||
"time": 756,
|
||||
"timings": {
|
||||
"blocked": -1,
|
||||
"connect": -1,
|
||||
"dns": -1,
|
||||
"receive": 0,
|
||||
"send": 0,
|
||||
"ssl": -1,
|
||||
"wait": 756
|
||||
}
|
||||
}
|
||||
],
|
||||
"pages": [],
|
||||
"version": "1.2"
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user