This commit is contained in:
jackbeeby
2024-12-05 18:20:27 +11:00
parent b8de0556ec
commit dc5eea1ad0
288 changed files with 101937 additions and 0 deletions

View 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) }),
});
};

View 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
View 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,
};
};

View 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
View 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,
};
}
}

View 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;

View 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",
}
`);
});
});
});

View 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
View 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
View 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
View 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);

View 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
View 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,
},
});

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,2 @@
import nextEnv from "@next/env";
nextEnv.loadEnvConfig(".");

View File

@@ -0,0 +1,10 @@
query Migration_01_FetchWebhookIds {
app {
webhooks {
id
syncEvents {
eventType
}
}
}
}

View 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
}
}
}

View 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");
}
}

View 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,
});
});
});

View 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);
}
}

View 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;
};

View 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;
};

View 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();
});
});

View 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;
};

View 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;
};

View File

@@ -0,0 +1,3 @@
import { type Permission } from "@saleor/app-sdk/types";
export const REQUIRED_SALEOR_PERMISSIONS: Permission[] = ["MANAGE_APPS", "MANAGE_SETTINGS"];

View 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",
};

View File

@@ -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);
};

View File

@@ -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);
});
});
});

View 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();
};

View 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>;

View 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"];
};

View 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,
);
});
});

View 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");
};

View 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>;

View File

@@ -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" });
});
});

View 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;
};

View File

@@ -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,
);
};

View File

@@ -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);
}),
}),
});

View File

@@ -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;
};

View 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);
};

View 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;
};

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View 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);
});
});

View 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,
};

View 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");
});
});
});

View 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,
});
}

View 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,
},
});
});

View 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;

View 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,
});

View 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>;

View 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
View 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,
});
}
};

View 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>
);

View 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";

View 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 },
]);

View 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>
);
};

View File

@@ -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",
}),
},
],
});

View 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`,
});

View 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}
/>
);
};

View 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>
);

View 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",
);

View 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} />;
}

View 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);
}}
/>
);
}

View 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);
}}
/>
);
}

View 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);
}}
/>
);
}

View 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",
);

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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");

View File

@@ -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>
);
};

View File

@@ -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",
);

View File

@@ -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>
</>
);
};

View 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>;
};

View 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,
});

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View 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>
);
};

View 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);

View File

@@ -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>
);
};

View 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>
);
};

View 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",
}),
},
],
});

View 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");
});
});
});

View 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;
});

View File

@@ -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&currency=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"
}
}

View File

@@ -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&currency=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"
}
}

View File

@@ -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&currency=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"
}
}

View File

@@ -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&currency=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