wdc
This commit is contained in:
66
src/pages/_app.tsx
Normal file
66
src/pages/_app.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import "../styles/global.css";
|
||||
import "@saleor/macaw-ui/next/style";
|
||||
|
||||
import { AppBridgeProvider, useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
import { RoutePropagator } from "@saleor/app-sdk/app-bridge/next";
|
||||
import { ThemeProvider } from "@saleor/macaw-ui/next";
|
||||
import { type AppProps } from "next/app";
|
||||
|
||||
import { Provider } from "urql";
|
||||
import { type ReactNode } from "react";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; // note: it's imported only in dev mode
|
||||
import Head from "next/head";
|
||||
import { ThemeSynchronizer } from "../modules/ui/theme-synchronizer";
|
||||
import { NoSSRWrapper } from "../modules/ui/no-ssr-wrapper";
|
||||
|
||||
import { appBridgeInstance } from "@/app-bridge-instance";
|
||||
import { ErrorModal } from "@/modules/ui/organisms/GlobalErrorModal/modal";
|
||||
import { trpcClient } from "@/modules/trpc/trpc-client";
|
||||
import { createClient } from "@/lib/create-graphq-client";
|
||||
|
||||
const UrqlProvider = ({ children }: { children: ReactNode }) => {
|
||||
const { appBridgeState } = useAppBridge();
|
||||
if (!appBridgeState?.saleorApiUrl) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const client = createClient(appBridgeState?.saleorApiUrl, async () =>
|
||||
appBridgeState?.token ? { token: appBridgeState.token } : null,
|
||||
);
|
||||
return <Provider value={client}>{children}</Provider>;
|
||||
};
|
||||
|
||||
function NextApp({ Component, pageProps }: AppProps) {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Saleor App Payment Stripe</title>
|
||||
<link rel="icon" href="/favicon-32x32.png" type="image/png" />
|
||||
<meta name="theme-color" content="#635BFF" />
|
||||
<link rel="apple-touch-icon" sizes="48x48" href="/icons/icon-48x48.png" />
|
||||
<link rel="apple-touch-icon" sizes="72x72" href="/icons/icon-72x72.png" />
|
||||
<link rel="apple-touch-icon" sizes="96x96" href="/icons/icon-96x96.png" />
|
||||
<link rel="apple-touch-icon" sizes="144x144" href="/icons/icon-144x144.png" />
|
||||
<link rel="apple-touch-icon" sizes="192x192" href="/icons/icon-192x192.png" />
|
||||
<link rel="apple-touch-icon" sizes="256x256" href="/icons/icon-256x256.png" />
|
||||
<link rel="apple-touch-icon" sizes="384x384" href="/icons/icon-384x384.png" />
|
||||
<link rel="apple-touch-icon" sizes="512x512" href="/icons/icon-512x512.png" />
|
||||
</Head>
|
||||
<NoSSRWrapper>
|
||||
<AppBridgeProvider appBridgeInstance={appBridgeInstance}>
|
||||
<UrqlProvider>
|
||||
<ThemeProvider>
|
||||
<ThemeSynchronizer />
|
||||
<RoutePropagator />
|
||||
<Component {...pageProps} />
|
||||
<ErrorModal />
|
||||
<ReactQueryDevtools position="top-right" />
|
||||
</ThemeProvider>
|
||||
</UrqlProvider>
|
||||
</AppBridgeProvider>
|
||||
</NoSSRWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default trpcClient.withTRPC(NextApp);
|
||||
39
src/pages/_error.js
Normal file
39
src/pages/_error.js
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* NOTE: This requires `@sentry/nextjs` version 7.3.0 or higher.
|
||||
*
|
||||
* NOTE: If using this with `next` version 12.2.0 or lower, uncomment the
|
||||
* penultimate line in `CustomErrorComponent`.
|
||||
*
|
||||
* This page is loaded by Nextjs:
|
||||
* - on the server, when data-fetching methods throw or reject
|
||||
* - on the client, when `getInitialProps` throws or rejects
|
||||
* - on the client, when a React lifecycle method throws or rejects, and it's
|
||||
* caught by the built-in Nextjs error boundary
|
||||
*
|
||||
* See:
|
||||
* - https://nextjs.org/docs/basic-features/data-fetching/overview
|
||||
* - https://nextjs.org/docs/api-reference/data-fetching/get-initial-props
|
||||
* - https://reactjs.org/docs/error-boundaries.html
|
||||
*/
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import NextErrorComponent from "next/error";
|
||||
|
||||
const CustomErrorComponent = (props) => {
|
||||
// If you're using a Nextjs version prior to 12.2.1, uncomment this to
|
||||
// compensate for https://github.com/vercel/next.js/issues/8592
|
||||
// Sentry.captureUnderscoreErrorException(props);
|
||||
|
||||
return <NextErrorComponent statusCode={props.statusCode} />;
|
||||
};
|
||||
|
||||
CustomErrorComponent.getInitialProps = async (contextData) => {
|
||||
// In case this is running in a serverless function, await this in order to give Sentry
|
||||
// time to send the error before the lambda exits
|
||||
await Sentry.captureUnderscoreErrorException(contextData);
|
||||
|
||||
// This will contain the status code of the response
|
||||
return NextErrorComponent.getInitialProps(contextData);
|
||||
};
|
||||
|
||||
export default CustomErrorComponent;
|
||||
43
src/pages/api/manifest.ts
Normal file
43
src/pages/api/manifest.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { createManifestHandler } from "@saleor/app-sdk/handlers/next";
|
||||
import { type AppManifest } from "@saleor/app-sdk/types";
|
||||
|
||||
import packageJson from "../../../package.json";
|
||||
import { paymentGatewayInitializeSessionSyncWebhook } from "./webhooks/saleor/payment-gateway-initialize-session";
|
||||
import { transactionInitializeSessionSyncWebhook } from "./webhooks/saleor/transaction-initialize-session";
|
||||
import { transactionCancelationRequestedSyncWebhook } from "./webhooks/saleor/transaction-cancelation-requested";
|
||||
import { transactionChargeRequestedSyncWebhook } from "./webhooks/saleor/transaction-charge-requested";
|
||||
import { transactionProcessSessionSyncWebhook } from "./webhooks/saleor/transaction-process-session";
|
||||
import { transactionRefundRequestedSyncWebhook } from "./webhooks/saleor/transaction-refund-requested";
|
||||
|
||||
export default createManifestHandler({
|
||||
async manifestFactory(context) {
|
||||
const manifest: AppManifest = {
|
||||
id: "app.saleor.stripe",
|
||||
name: "Stripe",
|
||||
about: packageJson.description,
|
||||
tokenTargetUrl: `${context.appBaseUrl}/api/register`,
|
||||
appUrl: `${context.appBaseUrl}`,
|
||||
permissions: ["HANDLE_PAYMENTS"],
|
||||
version: packageJson.version,
|
||||
requiredSaleorVersion: ">=3.14.0",
|
||||
homepageUrl: "https://github.com/saleor/saleor-app-payment-stripe",
|
||||
supportUrl: "https://github.com/saleor/saleor-app-payment-stripe/issues",
|
||||
brand: {
|
||||
logo: {
|
||||
default: `${context.appBaseUrl}/logo.png`,
|
||||
},
|
||||
},
|
||||
webhooks: [
|
||||
paymentGatewayInitializeSessionSyncWebhook.getWebhookManifest(context.appBaseUrl),
|
||||
transactionInitializeSessionSyncWebhook.getWebhookManifest(context.appBaseUrl),
|
||||
transactionProcessSessionSyncWebhook.getWebhookManifest(context.appBaseUrl),
|
||||
transactionCancelationRequestedSyncWebhook.getWebhookManifest(context.appBaseUrl),
|
||||
transactionChargeRequestedSyncWebhook.getWebhookManifest(context.appBaseUrl),
|
||||
transactionRefundRequestedSyncWebhook.getWebhookManifest(context.appBaseUrl),
|
||||
],
|
||||
extensions: [],
|
||||
};
|
||||
|
||||
return manifest;
|
||||
},
|
||||
});
|
||||
25
src/pages/api/register.ts
Normal file
25
src/pages/api/register.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { createAppRegisterHandler } from "@saleor/app-sdk/handlers/next";
|
||||
|
||||
import { saleorApp } from "../../saleor-app";
|
||||
import { env } from "@/lib/env.mjs";
|
||||
|
||||
const allowedUrlsPattern = env.ALLOWED_DOMAIN_PATTERN;
|
||||
|
||||
/**
|
||||
* Required endpoint, called by Saleor to install app.
|
||||
* It will exchange tokens with app, so saleorApp.apl will contain token
|
||||
*/
|
||||
export default createAppRegisterHandler({
|
||||
apl: saleorApp.apl,
|
||||
allowedSaleorUrls: [
|
||||
(url) => {
|
||||
if (allowedUrlsPattern) {
|
||||
const regex = new RegExp(allowedUrlsPattern);
|
||||
|
||||
return regex.test(url);
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
],
|
||||
});
|
||||
26
src/pages/api/trpc/[trpc].ts
Normal file
26
src/pages/api/trpc/[trpc].ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import * as trpcNext from "@trpc/server/adapters/next";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { appRouter } from "../../../modules/trpc/trpc-app-router";
|
||||
import { createTrpcContext } from "../../../modules/trpc/trpc-context";
|
||||
import { logger, redactError } from "@/lib/logger";
|
||||
import { BaseTrpcError, FieldError } from "@/errors";
|
||||
import { isDevelopment } from "@/lib/isEnv";
|
||||
|
||||
export default trpcNext.createNextApiHandler({
|
||||
router: appRouter,
|
||||
createContext: createTrpcContext,
|
||||
onError: ({ path, error, type, ctx, input }) => {
|
||||
const cause = error?.cause;
|
||||
if (cause instanceof BaseTrpcError || cause instanceof FieldError) {
|
||||
// don't log expected errors
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDevelopment()) {
|
||||
// eslint-disable-next-line @saleor/saleor-app/logger-leak
|
||||
return logger.error({ input, path, error, type, ctx }, "TRPC failed");
|
||||
}
|
||||
logger.error({ path, error: redactError(error), type }, "TRPC failed");
|
||||
Sentry.captureException(error);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { SaleorSyncWebhook } from "@saleor/app-sdk/handlers/next";
|
||||
import { type PageConfig } from "next";
|
||||
import { saleorApp } from "@/saleor-app";
|
||||
import {
|
||||
UntypedPaymentGatewayInitializeSessionDocument,
|
||||
type PaymentGatewayInitializeSessionEventFragment,
|
||||
} from "generated/graphql";
|
||||
import { PaymentGatewayInitializeSessionWebhookHandler } from "@/modules/webhooks/payment-gateway-initialize-session";
|
||||
import { getSyncWebhookHandler } from "@/backend-lib/api-route-utils";
|
||||
import ValidatePaymentGatewayInitializeSessionResponse from "@/schemas/PaymentGatewayInitializeSession/PaymentGatewayInitializeSessionResponse.mjs";
|
||||
|
||||
export const config: PageConfig = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const paymentGatewayInitializeSessionSyncWebhook =
|
||||
new SaleorSyncWebhook<PaymentGatewayInitializeSessionEventFragment>({
|
||||
name: "PaymentGatewayInitializeSession",
|
||||
apl: saleorApp.apl,
|
||||
event: "PAYMENT_GATEWAY_INITIALIZE_SESSION",
|
||||
query: UntypedPaymentGatewayInitializeSessionDocument,
|
||||
webhookPath: "/api/webhooks/saleor/payment-gateway-initialize-session",
|
||||
});
|
||||
|
||||
export default paymentGatewayInitializeSessionSyncWebhook.createHandler(
|
||||
getSyncWebhookHandler(
|
||||
"paymentGatewayInitializeSessionSyncWebhook",
|
||||
PaymentGatewayInitializeSessionWebhookHandler,
|
||||
ValidatePaymentGatewayInitializeSessionResponse,
|
||||
(payload, errorResponse) => {
|
||||
return {
|
||||
message: errorResponse.message,
|
||||
data: {
|
||||
errors: errorResponse.errors,
|
||||
paymentMethodsResponse: {},
|
||||
publishableKey: "",
|
||||
environment: "TEST",
|
||||
},
|
||||
} as const;
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,44 @@
|
||||
import { uuidv7 } from "uuidv7";
|
||||
|
||||
import { SaleorSyncWebhook } from "@saleor/app-sdk/handlers/next";
|
||||
import { type PageConfig } from "next";
|
||||
import { saleorApp } from "@/saleor-app";
|
||||
import {
|
||||
UntypedTransactionCancelationRequestedDocument,
|
||||
type TransactionCancelationRequestedEventFragment,
|
||||
TransactionEventTypeEnum,
|
||||
} from "generated/graphql";
|
||||
import { getSyncWebhookHandler } from "@/backend-lib/api-route-utils";
|
||||
import { TransactionCancelationRequestedWebhookHandler } from "@/modules/webhooks/transaction-cancelation-requested";
|
||||
import ValidateTransactionCancelationRequestedResponse from "@/schemas/TransactionCancelationRequested/TransactionCancelationRequestedResponse.mjs";
|
||||
|
||||
export const config: PageConfig = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const transactionCancelationRequestedSyncWebhook =
|
||||
new SaleorSyncWebhook<TransactionCancelationRequestedEventFragment>({
|
||||
name: "TransactionCancelationRequested",
|
||||
apl: saleorApp.apl,
|
||||
event: "TRANSACTION_CANCELATION_REQUESTED",
|
||||
query: UntypedTransactionCancelationRequestedDocument,
|
||||
webhookPath: "/api/webhooks/saleor/transaction-cancelation-requested",
|
||||
});
|
||||
|
||||
export default transactionCancelationRequestedSyncWebhook.createHandler(
|
||||
getSyncWebhookHandler(
|
||||
"transactionCancelationRequestedSyncWebhook",
|
||||
TransactionCancelationRequestedWebhookHandler,
|
||||
ValidateTransactionCancelationRequestedResponse,
|
||||
(_payload, errorResponse) => {
|
||||
return {
|
||||
message: errorResponse.message,
|
||||
result: TransactionEventTypeEnum.CancelFailure,
|
||||
// @todo consider making pspReference optional https://github.com/saleor/saleor/issues/12490
|
||||
pspReference: uuidv7(),
|
||||
} as const;
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,43 @@
|
||||
import { SaleorSyncWebhook } from "@saleor/app-sdk/handlers/next";
|
||||
import { type PageConfig } from "next";
|
||||
import { uuidv7 } from "uuidv7";
|
||||
import { saleorApp } from "@/saleor-app";
|
||||
import {
|
||||
UntypedTransactionChargeRequestedDocument,
|
||||
type TransactionChargeRequestedEventFragment,
|
||||
TransactionEventTypeEnum,
|
||||
} from "generated/graphql";
|
||||
import { getSyncWebhookHandler } from "@/backend-lib/api-route-utils";
|
||||
import { TransactionChargeRequestedWebhookHandler } from "@/modules/webhooks/transaction-charge-requested";
|
||||
import ValidateTransactionChargeRequestedResponse from "@/schemas/TransactionChargeRequested/TransactionChargeRequestedResponse.mjs";
|
||||
|
||||
export const config: PageConfig = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const transactionChargeRequestedSyncWebhook =
|
||||
new SaleorSyncWebhook<TransactionChargeRequestedEventFragment>({
|
||||
name: "TransactionChargeRequested",
|
||||
apl: saleorApp.apl,
|
||||
event: "TRANSACTION_CHARGE_REQUESTED",
|
||||
query: UntypedTransactionChargeRequestedDocument,
|
||||
webhookPath: "/api/webhooks/saleor/transaction-charge-requested",
|
||||
});
|
||||
|
||||
export default transactionChargeRequestedSyncWebhook.createHandler(
|
||||
getSyncWebhookHandler(
|
||||
"transactionChargeRequestedSyncWebhook",
|
||||
TransactionChargeRequestedWebhookHandler,
|
||||
ValidateTransactionChargeRequestedResponse,
|
||||
(_payload, errorResponse) => {
|
||||
return {
|
||||
message: errorResponse.message,
|
||||
result: TransactionEventTypeEnum.ChargeFailure,
|
||||
// @todo consider making pspReference optional https://github.com/saleor/saleor/issues/12490
|
||||
pspReference: uuidv7(),
|
||||
} as const;
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,49 @@
|
||||
import { SaleorSyncWebhook } from "@saleor/app-sdk/handlers/next";
|
||||
import { type PageConfig } from "next";
|
||||
import { uuidv7 } from "uuidv7";
|
||||
import { saleorApp } from "@/saleor-app";
|
||||
import {
|
||||
UntypedTransactionInitializeSessionDocument,
|
||||
type TransactionInitializeSessionEventFragment,
|
||||
TransactionFlowStrategyEnum,
|
||||
TransactionEventTypeEnum,
|
||||
} from "generated/graphql";
|
||||
import { TransactionInitializeSessionWebhookHandler } from "@/modules/webhooks/transaction-initialize-session";
|
||||
import { getSyncWebhookHandler } from "@/backend-lib/api-route-utils";
|
||||
import ValidateTransactionInitializeSessionResponse from "@/schemas/TransactionInitializeSession/TransactionInitializeSessionResponse.mjs";
|
||||
|
||||
export const config: PageConfig = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const transactionInitializeSessionSyncWebhook =
|
||||
new SaleorSyncWebhook<TransactionInitializeSessionEventFragment>({
|
||||
name: "TransactionInitializeSession",
|
||||
apl: saleorApp.apl,
|
||||
event: "TRANSACTION_INITIALIZE_SESSION",
|
||||
query: UntypedTransactionInitializeSessionDocument,
|
||||
webhookPath: "/api/webhooks/saleor/transaction-initialize-session",
|
||||
});
|
||||
|
||||
export default transactionInitializeSessionSyncWebhook.createHandler(
|
||||
getSyncWebhookHandler(
|
||||
"transactionInitializeSessionSyncWebhook",
|
||||
TransactionInitializeSessionWebhookHandler,
|
||||
ValidateTransactionInitializeSessionResponse,
|
||||
(payload, errorResponse) => {
|
||||
return {
|
||||
amount: 0,
|
||||
result:
|
||||
payload.action.actionType === TransactionFlowStrategyEnum.Authorization
|
||||
? TransactionEventTypeEnum.AuthorizationFailure
|
||||
: TransactionEventTypeEnum.ChargeFailure,
|
||||
message: errorResponse.message,
|
||||
data: { errors: errorResponse.errors, paymentIntent: {}, publishableKey: "" },
|
||||
// @todo consider making pspReference optional https://github.com/saleor/saleor/issues/12490
|
||||
pspReference: uuidv7(),
|
||||
} as const;
|
||||
},
|
||||
),
|
||||
);
|
||||
49
src/pages/api/webhooks/saleor/transaction-process-session.ts
Normal file
49
src/pages/api/webhooks/saleor/transaction-process-session.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { SaleorSyncWebhook } from "@saleor/app-sdk/handlers/next";
|
||||
import { type PageConfig } from "next";
|
||||
import { uuidv7 } from "uuidv7";
|
||||
import { saleorApp } from "@/saleor-app";
|
||||
import {
|
||||
UntypedTransactionProcessSessionDocument,
|
||||
type TransactionProcessSessionEventFragment,
|
||||
TransactionFlowStrategyEnum,
|
||||
TransactionEventTypeEnum,
|
||||
} from "generated/graphql";
|
||||
import { TransactionProcessSessionWebhookHandler } from "@/modules/webhooks/transaction-process-session";
|
||||
import { getSyncWebhookHandler } from "@/backend-lib/api-route-utils";
|
||||
import ValidateTransactionProcessSessionResponse from "@/schemas/TransactionProcessSession/TransactionProcessSessionResponse.mjs";
|
||||
|
||||
export const config: PageConfig = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const transactionProcessSessionSyncWebhook =
|
||||
new SaleorSyncWebhook<TransactionProcessSessionEventFragment>({
|
||||
name: "TransactionProcessSession",
|
||||
apl: saleorApp.apl,
|
||||
event: "TRANSACTION_PROCESS_SESSION",
|
||||
query: UntypedTransactionProcessSessionDocument,
|
||||
webhookPath: "/api/webhooks/saleor/transaction-process-session",
|
||||
});
|
||||
|
||||
export default transactionProcessSessionSyncWebhook.createHandler(
|
||||
getSyncWebhookHandler(
|
||||
"transactionProcessSessionSyncWebhook",
|
||||
TransactionProcessSessionWebhookHandler,
|
||||
ValidateTransactionProcessSessionResponse,
|
||||
(payload, errorResponse) => {
|
||||
return {
|
||||
amount: 0,
|
||||
result:
|
||||
payload.action.actionType === TransactionFlowStrategyEnum.Authorization
|
||||
? TransactionEventTypeEnum.AuthorizationFailure
|
||||
: TransactionEventTypeEnum.ChargeFailure,
|
||||
message: errorResponse.message,
|
||||
data: { errors: errorResponse.errors, paymentIntent: {}, publishableKey: "" },
|
||||
// @todo consider making pspReference optional https://github.com/saleor/saleor/issues/12490
|
||||
pspReference: uuidv7(),
|
||||
} as const;
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,43 @@
|
||||
import { SaleorSyncWebhook } from "@saleor/app-sdk/handlers/next";
|
||||
import { type PageConfig } from "next";
|
||||
import { uuidv7 } from "uuidv7";
|
||||
import {
|
||||
UntypedTransactionRefundRequestedDocument,
|
||||
type TransactionRefundRequestedEventFragment,
|
||||
TransactionEventTypeEnum,
|
||||
} from "generated/graphql";
|
||||
import { saleorApp } from "@/saleor-app";
|
||||
import { getSyncWebhookHandler } from "@/backend-lib/api-route-utils";
|
||||
import { TransactionRefundRequestedWebhookHandler } from "@/modules/webhooks/transaction-refund-requested";
|
||||
import ValidateTransactionRefundRequestedResponse from "@/schemas/TransactionRefundRequesed/TransactionRefundRequestedResponse.mjs";
|
||||
|
||||
export const config: PageConfig = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const transactionRefundRequestedSyncWebhook =
|
||||
new SaleorSyncWebhook<TransactionRefundRequestedEventFragment>({
|
||||
name: "TransactionRefundRequested",
|
||||
apl: saleorApp.apl,
|
||||
event: "TRANSACTION_REFUND_REQUESTED",
|
||||
query: UntypedTransactionRefundRequestedDocument,
|
||||
webhookPath: "/api/webhooks/saleor/transaction-refund-requested",
|
||||
});
|
||||
|
||||
export default transactionRefundRequestedSyncWebhook.createHandler(
|
||||
getSyncWebhookHandler(
|
||||
"transactionRefundRequested",
|
||||
TransactionRefundRequestedWebhookHandler,
|
||||
ValidateTransactionRefundRequestedResponse,
|
||||
(_payload, errorResponse) => {
|
||||
return {
|
||||
message: errorResponse.message,
|
||||
result: TransactionEventTypeEnum.RefundFailure,
|
||||
// @todo consider making pspReference optional https://github.com/saleor/saleor/issues/12490
|
||||
pspReference: uuidv7(),
|
||||
} as const;
|
||||
},
|
||||
),
|
||||
);
|
||||
58
src/pages/api/webhooks/stripe/index.ts
Normal file
58
src/pages/api/webhooks/stripe/index.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { type NextApiRequest, type NextApiResponse } from "next";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { createLogger, redactError } from "@/lib/logger";
|
||||
import { BaseError, MissingSaleorApiUrlError, MissingAuthDataError } from "@/errors";
|
||||
import {
|
||||
MissingSignatureError,
|
||||
UnexpectedTransactionEventReportError,
|
||||
} from "@/modules/webhooks/stripe-webhook.errors";
|
||||
import { stripeWebhookHandler } from "@/modules/webhooks/stripe-webhook";
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default async function StripeWebhookHandler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
): Promise<void> {
|
||||
if (req.method !== "POST") {
|
||||
res.setHeader("Allow", "POST");
|
||||
res.status(405).end("Method Not Allowed");
|
||||
return;
|
||||
}
|
||||
|
||||
const logger = createLogger({}, { msgPrefix: "[StripeWebhookHandler] " });
|
||||
logger.info("Handler was called");
|
||||
|
||||
try {
|
||||
await stripeWebhookHandler(req);
|
||||
} catch (err) {
|
||||
if (err instanceof BaseError) {
|
||||
Sentry.captureException(err, { extra: { errors: err.errors } });
|
||||
} else {
|
||||
Sentry.captureException(err);
|
||||
}
|
||||
logger.error(redactError(err), "stripeWebhookHandler failed");
|
||||
|
||||
if (err instanceof MissingSaleorApiUrlError) {
|
||||
return res.status(400).json(MissingSaleorApiUrlError.serialize(err));
|
||||
}
|
||||
if (err instanceof MissingAuthDataError) {
|
||||
return res.status(412).json(MissingAuthDataError.serialize(err));
|
||||
}
|
||||
if (err instanceof MissingSignatureError) {
|
||||
return res.status(400).json(MissingSignatureError.serialize(err));
|
||||
}
|
||||
if (err instanceof UnexpectedTransactionEventReportError) {
|
||||
return res.status(500).json(UnexpectedTransactionEventReportError.serialize(err));
|
||||
}
|
||||
return res.status(500).json(BaseError.serialize(err));
|
||||
}
|
||||
|
||||
logger.info("StripeWebhookHandler finished OK");
|
||||
res.status(204).end();
|
||||
return;
|
||||
}
|
||||
138
src/pages/config.tsx
Normal file
138
src/pages/config.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { useAppBridge, withAuthorization } from "@saleor/app-sdk/app-bridge";
|
||||
import { useState } from "react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Box, Button, Text } from "@saleor/macaw-ui/next";
|
||||
import { type NextPage } from "next";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { checkTokenPermissions } from "../modules/jwt/check-token-offline";
|
||||
import {
|
||||
type PaymentAppFormConfigEntry,
|
||||
paymentAppCombinedFormSchema,
|
||||
paymentAppConfigEntrySchema,
|
||||
} from "../modules/payment-app-configuration/config-entry";
|
||||
import { FetchError, useFetch, usePost } from "../lib/use-fetch";
|
||||
import { AppLayout } from "@/modules/ui/templates/AppLayout";
|
||||
import { FormInput } from "@/modules/ui/atoms/macaw-ui/FormInput";
|
||||
|
||||
const actionId = "payment-form";
|
||||
|
||||
const ConfigPage: NextPage = () => {
|
||||
const { appBridgeState, appBridge } = useAppBridge();
|
||||
const { token } = appBridgeState ?? {};
|
||||
|
||||
const hasPermissions = checkTokenPermissions(token, ["MANAGE_APPS", "MANAGE_SETTINGS"]);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const formMethods = useForm<PaymentAppFormConfigEntry>({
|
||||
resolver: zodResolver(paymentAppCombinedFormSchema),
|
||||
defaultValues: {
|
||||
secretKey: "",
|
||||
configurationName: "",
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
reset,
|
||||
setError,
|
||||
formState: { isSubmitting },
|
||||
resetField,
|
||||
} = formMethods;
|
||||
|
||||
useFetch("/api/config", {
|
||||
schema: paymentAppConfigEntrySchema,
|
||||
onFinished: () => setIsLoading(false),
|
||||
onSuccess: (data) => {
|
||||
reset(data);
|
||||
},
|
||||
onError: async (err) => {
|
||||
const message = err instanceof FetchError ? err.body : err.message;
|
||||
await appBridge?.dispatch({
|
||||
type: "notification",
|
||||
payload: {
|
||||
title: "Form error",
|
||||
text: "Error while fetching initial form data",
|
||||
status: "error",
|
||||
actionId,
|
||||
apiMessage: message,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const postForm = usePost("/api/config", {
|
||||
schema: z.unknown(),
|
||||
onSuccess: async () => {
|
||||
await appBridge?.dispatch({
|
||||
type: "notification",
|
||||
payload: {
|
||||
title: "Form saved",
|
||||
text: "App configuration was saved successfully",
|
||||
status: "success",
|
||||
actionId: "payment-form",
|
||||
},
|
||||
});
|
||||
},
|
||||
onError: async (err) => {
|
||||
const apiMessage = err instanceof FetchError ? err.body : err.name;
|
||||
await appBridge?.dispatch({
|
||||
type: "notification",
|
||||
payload: {
|
||||
title: "Form error",
|
||||
text: err.message,
|
||||
status: "error",
|
||||
actionId,
|
||||
apiMessage,
|
||||
},
|
||||
});
|
||||
setError("root", { message: err.message });
|
||||
},
|
||||
});
|
||||
|
||||
if (!hasPermissions) {
|
||||
return (
|
||||
<AppLayout title="">
|
||||
<Text variant="hero">{"You don't have permissions to configure this app"}</Text>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppLayout title="">
|
||||
<Box display="flex" flexDirection="column" gap={8}>
|
||||
<FormProvider {...formMethods}>
|
||||
<form
|
||||
method="POST"
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
onSubmit={handleSubmit((data) => postForm(data))}
|
||||
>
|
||||
<Text variant="heading">Payment Provider settings</Text>
|
||||
|
||||
<Box display="flex" gap={6} alignItems="flex-end">
|
||||
<FormInput control={control} label="API_KEY" name="secretKey" disabled={isLoading} />
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
type="button"
|
||||
onClick={() => resetField("secretKey")}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<div>
|
||||
<Button type="submit" disabled={isLoading || isSubmitting}>
|
||||
{isLoading ? "Loading" : isSubmitting ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</Box>
|
||||
</AppLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default withAuthorization()(ConfigPage);
|
||||
26
src/pages/configurations/add.tsx
Normal file
26
src/pages/configurations/add.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Text } from "@saleor/macaw-ui/next";
|
||||
import { withAuthorization } from "@saleor/app-sdk/app-bridge";
|
||||
import { AppLayout } from "@/modules/ui/templates/AppLayout";
|
||||
import { StripeConfigurationForm } from "@/modules/ui/organisms/AddStripeConfigurationForm/AddStripeConfigurationForm";
|
||||
|
||||
const AddConfigurationPage = () => {
|
||||
return (
|
||||
<AppLayout
|
||||
title="Stripe > Add configuration"
|
||||
description={
|
||||
<>
|
||||
<Text as="p" variant="body" size="medium">
|
||||
Create new Stripe configuration.
|
||||
</Text>
|
||||
<Text as="p" variant="body" size="medium">
|
||||
Stripe Webhooks will be created automatically.
|
||||
</Text>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<StripeConfigurationForm configurationId={undefined} />
|
||||
</AppLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default withAuthorization()(AddConfigurationPage);
|
||||
33
src/pages/configurations/edit/[configurationId].tsx
Normal file
33
src/pages/configurations/edit/[configurationId].tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Text } from "@saleor/macaw-ui/next";
|
||||
import { withAuthorization } from "@saleor/app-sdk/app-bridge";
|
||||
import { useRouter } from "next/router";
|
||||
import { AppLayout } from "@/modules/ui/templates/AppLayout";
|
||||
import { StripeConfigurationForm } from "@/modules/ui/organisms/AddStripeConfigurationForm/AddStripeConfigurationForm";
|
||||
|
||||
const EditConfigurationPage = () => {
|
||||
const router = useRouter();
|
||||
if (typeof router.query.configurationId !== "string" || !router.query.configurationId) {
|
||||
// TODO: Add loading
|
||||
return <div />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AppLayout
|
||||
title="Stripe > Edit configuration"
|
||||
description={
|
||||
<>
|
||||
<Text as="p" variant="body" size="medium">
|
||||
Edit Stripe configuration.
|
||||
</Text>
|
||||
<Text as="p" variant="body" size="medium">
|
||||
Note: Stripe Webhooks will be created automatically.
|
||||
</Text>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<StripeConfigurationForm configurationId={router.query.configurationId} />
|
||||
</AppLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default withAuthorization()(EditConfigurationPage);
|
||||
86
src/pages/configurations/list.tsx
Normal file
86
src/pages/configurations/list.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useAppBridge, withAuthorization } from "@saleor/app-sdk/app-bridge";
|
||||
import { Box, Text } from "@saleor/macaw-ui/next";
|
||||
import { AppLayout, AppLayoutRow } from "@/modules/ui/templates/AppLayout";
|
||||
import { trpcClient } from "@/modules/trpc/trpc-client";
|
||||
import { getErrorHandler } from "@/modules/trpc/utils";
|
||||
import { useFetchChannelsQuery } from "generated/graphql";
|
||||
import { StripeConfigurationsList } from "@/modules/ui/organisms/StripeConfigurationList/StripeConfigurationList";
|
||||
import { ChannelToConfigurationList } from "@/modules/ui/organisms/ChannelToConfigurationList/ChannelToConfigurationList";
|
||||
import { Skeleton } from "@/modules/ui/atoms/Skeleton/Skeleton";
|
||||
|
||||
function ListConfigurationPage() {
|
||||
const { appBridge } = useAppBridge();
|
||||
const [allConfigurations, channelMappings] = trpcClient.useQueries((t) => [
|
||||
t.paymentAppConfigurationRouter.paymentConfig.getAll(undefined, {
|
||||
onError: getErrorHandler({
|
||||
appBridge,
|
||||
actionId: "list-all-configurations",
|
||||
message: "Error while fetching the list of configurations",
|
||||
title: "API Error",
|
||||
}),
|
||||
}),
|
||||
t.paymentAppConfigurationRouter.mapping.getAll(undefined, {
|
||||
onError: getErrorHandler({
|
||||
appBridge,
|
||||
actionId: "channel-mappings-get-all",
|
||||
message: "Error while fetching the channel mappings",
|
||||
title: "API Error",
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
|
||||
const [channels] = useFetchChannelsQuery();
|
||||
|
||||
const hasAnyConfigs = allConfigurations.data && allConfigurations.data.length > 0;
|
||||
const hasAnyMappings = Object.values(channelMappings.data || {}).filter(Boolean).length > 0;
|
||||
|
||||
return (
|
||||
<AppLayout title="Stripe">
|
||||
<AppLayoutRow
|
||||
title="Stripe Configurations"
|
||||
description="Create Stripe configurations that can be later assigned to Saleor channels."
|
||||
disabled={channelMappings.isLoading}
|
||||
>
|
||||
{allConfigurations.isLoading ? (
|
||||
<Skeleton height={40} />
|
||||
) : (
|
||||
<StripeConfigurationsList configurations={allConfigurations.data || []} />
|
||||
)}
|
||||
</AppLayoutRow>
|
||||
<AppLayoutRow
|
||||
disabled={!hasAnyConfigs || channelMappings.isLoading}
|
||||
title="Saleor channel mappings"
|
||||
description={
|
||||
<Box>
|
||||
<Text as="p" variant="body" size="medium">
|
||||
Assign Stripe configurations to Saleor channels.
|
||||
</Text>
|
||||
{!channelMappings.isLoading && !hasAnyMappings && (
|
||||
<Box marginTop={6}>
|
||||
<Text as="p" variant="body" size="medium" color="textCriticalDefault">
|
||||
No channels have configurations assigned.
|
||||
</Text>
|
||||
<Text as="p" variant="body" size="medium" color="textCriticalDefault">
|
||||
This means payments are not processed by Stripe.
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
{channelMappings.isLoading ? (
|
||||
<Skeleton height={40} />
|
||||
) : (
|
||||
<ChannelToConfigurationList
|
||||
disabled={!hasAnyConfigs || channelMappings.isLoading}
|
||||
configurations={allConfigurations.data || []}
|
||||
channelMappings={channelMappings.data || {}}
|
||||
channels={channels.data?.channels || []}
|
||||
/>
|
||||
)}
|
||||
</AppLayoutRow>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default withAuthorization()(ListConfigurationPage);
|
||||
136
src/pages/index.tsx
Normal file
136
src/pages/index.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { type NextPage } from "next";
|
||||
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
import { Box, Button, Text } from "@saleor/macaw-ui/next";
|
||||
import { useEffect, useState } from "react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { useRouter } from "next/router";
|
||||
import { FormInput } from "@/modules/ui/atoms/macaw-ui/FormInput";
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
saleorUrl: z.string().url(),
|
||||
})
|
||||
.required();
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
|
||||
const AddToSaleorForm = () => {
|
||||
const formMethods = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
saleorUrl: "",
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
formState: { isSubmitting, errors },
|
||||
} = formMethods;
|
||||
|
||||
return (
|
||||
<FormProvider {...formMethods}>
|
||||
<form
|
||||
method="post"
|
||||
noValidate
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
onSubmit={handleSubmit((values) => {
|
||||
const manifestUrl = new URL("/api/manifest", window.location.origin).toString();
|
||||
const redirectUrl = new URL(
|
||||
`/dashboard/apps/install?manifestUrl=${manifestUrl}`,
|
||||
values.saleorUrl,
|
||||
).toString();
|
||||
|
||||
window.open(redirectUrl, "_blank");
|
||||
})}
|
||||
>
|
||||
<Box display="flex" flexDirection="column" gap={2} marginTop={10}>
|
||||
<FormInput
|
||||
inputMode="url"
|
||||
label="Saleor URL"
|
||||
required
|
||||
name="saleorUrl"
|
||||
size="medium"
|
||||
placeholder="https://…"
|
||||
error={!!errors.saleorUrl}
|
||||
helperText={errors.saleorUrl?.message || " "}
|
||||
control={control}
|
||||
/>
|
||||
<Button type="submit" size="large" disabled={isSubmitting}>
|
||||
Add to Saleor
|
||||
</Button>
|
||||
</Box>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const CopyManifest = () => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const unsetCopied = () => {
|
||||
setCopied(false);
|
||||
};
|
||||
|
||||
if (copied) {
|
||||
setTimeout(unsetCopied, 1750);
|
||||
}
|
||||
}, [copied]);
|
||||
|
||||
const handleClick = async () => {
|
||||
await navigator.clipboard.writeText(window.location.origin + "/api/manifest");
|
||||
setCopied(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button variant="secondary" onClick={() => void handleClick()}>
|
||||
{copied ? "Copied" : "Copy app manifest URL"}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const IndexPage: NextPage = () => {
|
||||
const { appBridgeState } = useAppBridge();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (appBridgeState?.ready && mounted) {
|
||||
void router.replace("/configurations/list");
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box display="flex" flexDirection="column" gap={2} __maxWidth="45rem">
|
||||
<Text as="h1" size="large" variant="hero">
|
||||
Welcome to Payment App Stripe 💰
|
||||
</Text>
|
||||
<Text as="p" size="large" variant="title">
|
||||
Simplify your payment process and offer a seamless online shopping experience with Stripe
|
||||
payment integration for Saleor.
|
||||
</Text>
|
||||
|
||||
{!appBridgeState?.ready && (
|
||||
<div>
|
||||
<Text as="p" size="large" variant="bodyStrong">
|
||||
Install this app in your Saleor Dashboard to proceed!
|
||||
</Text>
|
||||
{mounted && <AddToSaleorForm />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CopyManifest />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default IndexPage;
|
||||
Reference in New Issue
Block a user