diff --git a/apps/dummy-payment/.env.example b/apps/dummy-payment/.env.example new file mode 100644 index 000000000..f90a1fd9d --- /dev/null +++ b/apps/dummy-payment/.env.example @@ -0,0 +1,20 @@ +# Local development variables. When developped locally with Saleor inside docker, these can be set to: +# +# APP_IFRAME_BASE_URL = http://localhost:3000, so Dashboard on host can access iframe +# APP_API_BASE_URL=http://host.docker.internal:3000 - so Saleor can reach App running on host, from the container. +# +# If developped with tunnels, set this empty, it will fallback to address the app is reached from (default port 3000). +APP_IFRAME_BASE_URL= +APP_API_BASE_URL= + + +# Dynamodb apl + +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_REGION= +DYNAMODB_MAIN_TABLE_NAME= + +# optional +# DYNAMODB_MAIN_TABLE_TIMEOUT_MS= +# DYNAMODB_MAIN_TABLE_CONNECTION_TIMEOUT_MS= diff --git a/apps/dummy-payment/.eslintrc b/apps/dummy-payment/.eslintrc new file mode 100644 index 000000000..6c63ebbf8 --- /dev/null +++ b/apps/dummy-payment/.eslintrc @@ -0,0 +1,3 @@ +{ + "extends": ["next", "prettier"] +} \ No newline at end of file diff --git a/apps/dummy-payment/.gitallowed b/apps/dummy-payment/.gitallowed new file mode 100644 index 000000000..c31706425 --- /dev/null +++ b/apps/dummy-payment/.gitallowed @@ -0,0 +1 @@ +AGENTS.md diff --git a/apps/dummy-payment/.gitignore b/apps/dummy-payment/.gitignore new file mode 100644 index 000000000..1132fdbfd --- /dev/null +++ b/apps/dummy-payment/.gitignore @@ -0,0 +1,48 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# local env files +.env*.local +.envfile +.saleor-app-auth.json + +# vercel +.vercel + +# typescript +*.tsbuildinfo + +.auth_token + +#editor +.vscode +.idea + +# Sentry +.sentryclirc + +.env \ No newline at end of file diff --git a/apps/dummy-payment/.graphqlrc.yml b/apps/dummy-payment/.graphqlrc.yml new file mode 100644 index 000000000..ee7c7afff --- /dev/null +++ b/apps/dummy-payment/.graphqlrc.yml @@ -0,0 +1,20 @@ +schema: graphql/schema.graphql +documents: [graphql/**/*.graphql, src/**/*.ts, src/**/*.tsx] +extensions: + codegen: + overwrite: true + generates: + generated/graphql.ts: + config: + dedupeFragments: true + plugins: + - typescript + - typescript-operations + - urql-introspection + - typescript-urql: + documentVariablePrefix: "Untyped" + fragmentVariablePrefix: "Untyped" + - typed-document-node + generated/schema.graphql: + plugins: + - schema-ast diff --git a/apps/dummy-payment/.npmrc b/apps/dummy-payment/.npmrc new file mode 100644 index 000000000..a78fdc07a --- /dev/null +++ b/apps/dummy-payment/.npmrc @@ -0,0 +1,3 @@ +strict-peer-dependencies=false +auto-install-peers=true +save-exact=true diff --git a/apps/dummy-payment/.prettierignore b/apps/dummy-payment/.prettierignore new file mode 100644 index 000000000..2013a7320 --- /dev/null +++ b/apps/dummy-payment/.prettierignore @@ -0,0 +1,5 @@ +.next +saleor/api.tsx +pnpm-lock.yaml +graphql/schema.graphql +generated diff --git a/apps/dummy-payment/.prettierrc b/apps/dummy-payment/.prettierrc new file mode 100644 index 000000000..59821061d --- /dev/null +++ b/apps/dummy-payment/.prettierrc @@ -0,0 +1,4 @@ +{ + "singleQuote": false, + "printWidth": 100 +} diff --git a/apps/dummy-payment/AGENTS.md b/apps/dummy-payment/AGENTS.md new file mode 100644 index 000000000..ccb4b6ac8 --- /dev/null +++ b/apps/dummy-payment/AGENTS.md @@ -0,0 +1,148 @@ +# Saleor Dummy Payment App + +This file provides guidance to coding agents when working with code in this repository. + +## Project Overview + +This is a Saleor Payment App that implements a dummy payment gateway for testing Saleor's Transactions API. It allows testing payment flows without a real payment provider. + +## Development Commands + +### Setup +```bash +pnpm install # Install dependencies +``` + +### Development +```bash +pnpm dev # Start dev server with codegen and Node.js inspector on port 3000 +pnpm build # Build for production (runs codegen first) +pnpm start # Start production server +``` + +### Code Quality +```bash +pnpm lint # Run ESLint +pnpm test # Run tests with Vitest +``` + +### GraphQL +```bash +pnpm generate # Generate TypeScript types from GraphQL schema and operations +pnpm fetch-schema # Fetch latest Saleor GraphQL schema (version in package.json) +``` + +GraphQL operations are defined in `graphql/` directory (mutations, queries, fragments, subscriptions). The codegen generates types in `generated/graphql.ts` using urql and typed-document-node. + +## Architecture + +### Framework & Stack +- **Next.js** with Pages Router (not App Router) +- **tRPC** for type-safe API routes (server & client communication) +- **urql** for GraphQL client with auth exchange +- **Saleor App SDK** for webhook handling and APL (Auth Persistence Layer) +- **Vitest** for testing with jsdom environment +- **OpenTelemetry** for observability (traces & logs) +- **Sentry** for error tracking + +### Key Architectural Components + +#### APL (Auth Persistence Layer) +Authentication data storage configured in `src/saleor-app.ts`. Supports: +- `FileAPL` (default) - stores auth in `.auth-data.json` +- `UpstashAPL` - for multi-tenant deployments +- `SaleorCloudAPL` - for Saleor Cloud deployments + +#### Webhook System +All webhooks are in `src/pages/api/webhooks/`. Each webhook: +- Uses `SaleorSyncWebhook` from `@saleor/app-sdk` +- Wrapped with `wrapWithLoggerContext` and `withOtel` for observability +- Has `bodyParser: false` in config for signature verification +- Validates incoming data with Zod schemas from `src/modules/validation/` + +Supported webhooks: +- `PAYMENT_GATEWAY_INITIALIZE_SESSION` +- `TRANSACTION_INITIALIZE_SESSION` +- `TRANSACTION_PROCESS_SESSION` +- `TRANSACTION_REFUND_REQUESTED` +- `TRANSACTION_CHARGE_REQUESTED` +- `TRANSACTION_CANCELATION_REQUESTED` + +#### tRPC Setup +- Server router in `src/server/routers/app-router.ts` +- Context defined in `src/pages/api/trpc/[trpc].ts` +- Client setup in `src/trpc-client.ts` +- Procedures can use `procedureWithGraphqlClient` middleware for Saleor API access + +#### GraphQL Client +Created via `createClient` in `src/lib/create-graphql-client.ts`: +- Uses urql with auth exchange +- Custom `Authorization-Bearer` header (note: not standard `Authorization: Bearer`) +- Auth token provided via APL + +#### URL Generation +`AppUrlGenerator` in `src/modules/url/app-url-generator.ts` handles: +- External URLs for webhooks (`APP_API_BASE_URL` env var) +- Iframe URLs for dashboard (`APP_IFRAME_BASE_URL` env var) +- Falls back to request host if env vars not set +- Env vars are used for local development with local Saleor instance, tunneling works without setting env vars + +#### Transaction Logic +- `transaction-actions.ts` - determines available actions based on event type +- `transaction-psp-finder.ts` - finds PSP reference from transaction events +- `transaction-refund-checker.ts` - validates refund requests +- Response schema validation in `src/modules/validation/sync-transaction.ts` + +#### Observability +- Logger with multiple transports (console, Sentry, Vercel) in `src/lib/logger/` +- OpenTelemetry setup in `src/lib/otel/` with traces and logs +- Request context propagation via `logger-context.ts` + +### Pages Structure +- `/` - Landing page +- `/app/` - Dashboard pages (must be opened in Saleor Dashboard iframe context): + - `/app/index.tsx` - Main app page + - `/app/configuration.tsx` - App configuration + - `/app/checkout.tsx` - Checkout testing UI + - `/app/transactions/` - Transaction list and details + +### Environment Variables +Optional for local development (defaults work without Docker): +- `APP_IFRAME_BASE_URL` - Base URL for iframe (e.g., `http://localhost:3000`) +- `APP_API_BASE_URL` - Base URL for webhooks (e.g., `http://host.docker.internal:3000` for Docker) +- `APL` - Auth persistence layer type: `file` (default), `upstash`, `saleor-cloud` +- APL-specific vars: `FILE_APL_PATH`, `UPSTASH_URL`, `UPSTASH_TOKEN`, `REST_APL_TOKEN`, `REST_APL_ENDPOINT` + +## Testing Payment Flows + +The app accepts `data` field in `transactionInitialize` and `transactionProcess` mutations to control behavior: + +```json +{ + "data": { + "event": { + "type": "CHARGE_SUCCESS", // See TransactionEventTypeEnum + "includePspReference": true + } + } +} +``` + +Valid event types: `CHARGE_SUCCESS`, `CHARGE_FAILURE`, `CHARGE_REQUEST`, `CHARGE_ACTION_REQUIRED`, `AUTHORIZATION_SUCCESS`, `AUTHORIZATION_FAILURE`, `AUTHORIZATION_REQUEST`, `AUTHORIZATION_ACTION_REQUIRED` + +Response includes: +- `pspReference` - UUID v7 (if `includePspReference: true`) +- `result` - mirrors the input `type` +- `actions` - available transaction actions (determined by event type) +- `externalUrl` - link to transaction details page in app +- `message` - success or error message + +For more details check Saleor GraphQL schema and docs: docs.saleor.io + +## Important Notes + +- All webhook handlers must have `bodyParser: false` for signature verification +- GraphQL schema version is pinned to Saleor 3.19 (see `package.json`) +- Node version: 18.17.0 - 20.x required +- Uses ES modules (`"type": "module"` in package.json) +- Transaction external URLs link back to app UI for status updates diff --git a/apps/dummy-payment/CLAUDE.md b/apps/dummy-payment/CLAUDE.md new file mode 100644 index 000000000..ccb4b6ac8 --- /dev/null +++ b/apps/dummy-payment/CLAUDE.md @@ -0,0 +1,148 @@ +# Saleor Dummy Payment App + +This file provides guidance to coding agents when working with code in this repository. + +## Project Overview + +This is a Saleor Payment App that implements a dummy payment gateway for testing Saleor's Transactions API. It allows testing payment flows without a real payment provider. + +## Development Commands + +### Setup +```bash +pnpm install # Install dependencies +``` + +### Development +```bash +pnpm dev # Start dev server with codegen and Node.js inspector on port 3000 +pnpm build # Build for production (runs codegen first) +pnpm start # Start production server +``` + +### Code Quality +```bash +pnpm lint # Run ESLint +pnpm test # Run tests with Vitest +``` + +### GraphQL +```bash +pnpm generate # Generate TypeScript types from GraphQL schema and operations +pnpm fetch-schema # Fetch latest Saleor GraphQL schema (version in package.json) +``` + +GraphQL operations are defined in `graphql/` directory (mutations, queries, fragments, subscriptions). The codegen generates types in `generated/graphql.ts` using urql and typed-document-node. + +## Architecture + +### Framework & Stack +- **Next.js** with Pages Router (not App Router) +- **tRPC** for type-safe API routes (server & client communication) +- **urql** for GraphQL client with auth exchange +- **Saleor App SDK** for webhook handling and APL (Auth Persistence Layer) +- **Vitest** for testing with jsdom environment +- **OpenTelemetry** for observability (traces & logs) +- **Sentry** for error tracking + +### Key Architectural Components + +#### APL (Auth Persistence Layer) +Authentication data storage configured in `src/saleor-app.ts`. Supports: +- `FileAPL` (default) - stores auth in `.auth-data.json` +- `UpstashAPL` - for multi-tenant deployments +- `SaleorCloudAPL` - for Saleor Cloud deployments + +#### Webhook System +All webhooks are in `src/pages/api/webhooks/`. Each webhook: +- Uses `SaleorSyncWebhook` from `@saleor/app-sdk` +- Wrapped with `wrapWithLoggerContext` and `withOtel` for observability +- Has `bodyParser: false` in config for signature verification +- Validates incoming data with Zod schemas from `src/modules/validation/` + +Supported webhooks: +- `PAYMENT_GATEWAY_INITIALIZE_SESSION` +- `TRANSACTION_INITIALIZE_SESSION` +- `TRANSACTION_PROCESS_SESSION` +- `TRANSACTION_REFUND_REQUESTED` +- `TRANSACTION_CHARGE_REQUESTED` +- `TRANSACTION_CANCELATION_REQUESTED` + +#### tRPC Setup +- Server router in `src/server/routers/app-router.ts` +- Context defined in `src/pages/api/trpc/[trpc].ts` +- Client setup in `src/trpc-client.ts` +- Procedures can use `procedureWithGraphqlClient` middleware for Saleor API access + +#### GraphQL Client +Created via `createClient` in `src/lib/create-graphql-client.ts`: +- Uses urql with auth exchange +- Custom `Authorization-Bearer` header (note: not standard `Authorization: Bearer`) +- Auth token provided via APL + +#### URL Generation +`AppUrlGenerator` in `src/modules/url/app-url-generator.ts` handles: +- External URLs for webhooks (`APP_API_BASE_URL` env var) +- Iframe URLs for dashboard (`APP_IFRAME_BASE_URL` env var) +- Falls back to request host if env vars not set +- Env vars are used for local development with local Saleor instance, tunneling works without setting env vars + +#### Transaction Logic +- `transaction-actions.ts` - determines available actions based on event type +- `transaction-psp-finder.ts` - finds PSP reference from transaction events +- `transaction-refund-checker.ts` - validates refund requests +- Response schema validation in `src/modules/validation/sync-transaction.ts` + +#### Observability +- Logger with multiple transports (console, Sentry, Vercel) in `src/lib/logger/` +- OpenTelemetry setup in `src/lib/otel/` with traces and logs +- Request context propagation via `logger-context.ts` + +### Pages Structure +- `/` - Landing page +- `/app/` - Dashboard pages (must be opened in Saleor Dashboard iframe context): + - `/app/index.tsx` - Main app page + - `/app/configuration.tsx` - App configuration + - `/app/checkout.tsx` - Checkout testing UI + - `/app/transactions/` - Transaction list and details + +### Environment Variables +Optional for local development (defaults work without Docker): +- `APP_IFRAME_BASE_URL` - Base URL for iframe (e.g., `http://localhost:3000`) +- `APP_API_BASE_URL` - Base URL for webhooks (e.g., `http://host.docker.internal:3000` for Docker) +- `APL` - Auth persistence layer type: `file` (default), `upstash`, `saleor-cloud` +- APL-specific vars: `FILE_APL_PATH`, `UPSTASH_URL`, `UPSTASH_TOKEN`, `REST_APL_TOKEN`, `REST_APL_ENDPOINT` + +## Testing Payment Flows + +The app accepts `data` field in `transactionInitialize` and `transactionProcess` mutations to control behavior: + +```json +{ + "data": { + "event": { + "type": "CHARGE_SUCCESS", // See TransactionEventTypeEnum + "includePspReference": true + } + } +} +``` + +Valid event types: `CHARGE_SUCCESS`, `CHARGE_FAILURE`, `CHARGE_REQUEST`, `CHARGE_ACTION_REQUIRED`, `AUTHORIZATION_SUCCESS`, `AUTHORIZATION_FAILURE`, `AUTHORIZATION_REQUEST`, `AUTHORIZATION_ACTION_REQUIRED` + +Response includes: +- `pspReference` - UUID v7 (if `includePspReference: true`) +- `result` - mirrors the input `type` +- `actions` - available transaction actions (determined by event type) +- `externalUrl` - link to transaction details page in app +- `message` - success or error message + +For more details check Saleor GraphQL schema and docs: docs.saleor.io + +## Important Notes + +- All webhook handlers must have `bodyParser: false` for signature verification +- GraphQL schema version is pinned to Saleor 3.19 (see `package.json`) +- Node version: 18.17.0 - 20.x required +- Uses ES modules (`"type": "module"` in package.json) +- Transaction external URLs link back to app UI for status updates diff --git a/apps/dummy-payment/Dockerfile.dev b/apps/dummy-payment/Dockerfile.dev new file mode 100644 index 000000000..3d45ef01d --- /dev/null +++ b/apps/dummy-payment/Dockerfile.dev @@ -0,0 +1,14 @@ +FROM node:20.12-alpine +RUN apk update +RUN apk add --no-cache libc6-compat +WORKDIR /app + +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +RUN corepack enable + +COPY . . + +RUN pnpm install --frozen-lockfile + +CMD pnpm dev diff --git a/apps/dummy-payment/LICENSE b/apps/dummy-payment/LICENSE new file mode 100644 index 000000000..2e5850cf8 --- /dev/null +++ b/apps/dummy-payment/LICENSE @@ -0,0 +1,49 @@ +BSD 3-Clause License + +Copyright (c) 2020-2022, Saleor Commerce +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +------- + +Unless stated otherwise, artwork included in this distribution is licensed +under the Creative Commons Attribution 4.0 International License. + +You can learn more about the permitted use by visiting +https://creativecommons.org/licenses/by/4.0/ + +------- + +Lucide License + +ISC License + +Copyright (c) for portions of Lucide are held by Cole Bemis 2013-2022 as part of Feather (MIT). All other copyright (c) for Lucide are held by Lucide Contributors 2022. + +Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/apps/dummy-payment/docs/1_checkout.jpeg b/apps/dummy-payment/docs/1_checkout.jpeg new file mode 100644 index 000000000..2bc73bea4 Binary files /dev/null and b/apps/dummy-payment/docs/1_checkout.jpeg differ diff --git a/apps/dummy-payment/docs/2_event_reporter.jpeg b/apps/dummy-payment/docs/2_event_reporter.jpeg new file mode 100644 index 000000000..a07ed7106 Binary files /dev/null and b/apps/dummy-payment/docs/2_event_reporter.jpeg differ diff --git a/apps/dummy-payment/generated/app-webhooks-types/.gitkeep b/apps/dummy-payment/generated/app-webhooks-types/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/apps/dummy-payment/generated/app-webhooks-types/payment-gateway-initialize-session.ts b/apps/dummy-payment/generated/app-webhooks-types/payment-gateway-initialize-session.ts new file mode 100644 index 000000000..00e6b5176 --- /dev/null +++ b/apps/dummy-payment/generated/app-webhooks-types/payment-gateway-initialize-session.ts @@ -0,0 +1,20 @@ +/* eslint-disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +export type JsonValue = + | string + | number + | boolean + | { + [k: string]: unknown; + } + | unknown[] + | null; + +export interface PaymentGatewayInitializeSession { + data: JsonValue; +} diff --git a/apps/dummy-payment/generated/app-webhooks-types/transaction-initialize-session.ts b/apps/dummy-payment/generated/app-webhooks-types/transaction-initialize-session.ts new file mode 100644 index 000000000..79e04f27a --- /dev/null +++ b/apps/dummy-payment/generated/app-webhooks-types/transaction-initialize-session.ts @@ -0,0 +1,217 @@ +/* eslint-disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +export type TransactionInitializeSession = + | TransactionSessionSuccess + | TransactionSessionFailure + | TransactionSessionActionRequired; +/** + * PSP reference received from payment provider. + */ +export type Pspreference = string; +/** + * Decimal amount of the processed action + */ +export type Amount = number | string; +/** + * Time of the action in ISO 8601 format + */ +export type Time = string; +/** + * External url with action details + */ +export type Externalurl = string; +/** + * Message related to the action. The maximum length is 512 characters; any text exceeding this limit will be truncated + */ +export type Message = string; +export type Actions = ("CHARGE" | "REFUND" | "CANCEL")[] | null; +/** + * Result of the action + */ +export type Result = "AUTHORIZATION_SUCCESS" | "CHARGE_SUCCESS" | "AUTHORIZATION_REQUEST" | "CHARGE_REQUEST"; +/** + * The JSON data that will be returned to storefront + */ +export type JsonValue = + | string + | number + | boolean + | { + [k: string]: unknown; + } + | unknown[] + | null; +/** + * Details of the payment method used for the transaction. + */ +export type Paymentmethoddetails = (OtherPaymentMethodDetails | CardPaymentMethodDetails) | null; +/** + * Type of the payment method used for the transaction. + */ +export type Type = "OTHER"; +/** + * Name of the payment method used for the transaction. + */ +export type Name = string; +/** + * Type of the payment method used for the transaction. + */ +export type Type1 = "CARD"; +/** + * Name of the payment method used for the transaction. + */ +export type Name1 = string; +/** + * Brand of the card used for the transaction. + */ +export type Brand = string | null; +/** + * First digits of the card used for the transaction. + */ +export type Firstdigits = string | null; +/** + * Last digits of the card used for the transaction. + */ +export type Lastdigits = string | null; +/** + * Expiration month of the card used for the transaction. + */ +export type Expmonth = number | null; +/** + * Expiration year of the card used for the transaction. + */ +export type Expyear = number | null; +/** + * PSP reference received from payment provider. + */ +export type Pspreference1 = string; +/** + * Decimal amount of the processed action + */ +export type Amount1 = number | string; +/** + * Time of the action in ISO 8601 format + */ +export type Time1 = string; +/** + * External url with action details + */ +export type Externalurl1 = string; +/** + * Message related to the action. The maximum length is 512 characters; any text exceeding this limit will be truncated + */ +export type Message1 = string; +export type Actions1 = ("CHARGE" | "REFUND" | "CANCEL")[] | null; +/** + * Result of the action + */ +export type Result1 = "AUTHORIZATION_FAILURE" | "CHARGE_FAILURE"; +/** + * The JSON data that will be returned to storefront + */ +export type JsonValue1 = + | string + | number + | boolean + | { + [k: string]: unknown; + } + | unknown[] + | null; +/** + * Details of the payment method used for the transaction. + */ +export type Paymentmethoddetails1 = (OtherPaymentMethodDetails | CardPaymentMethodDetails) | null; +/** + * PSP reference received from payment provider. + */ +export type Pspreference2 = string; +/** + * Decimal amount of the processed action + */ +export type Amount2 = number | string; +/** + * Time of the action in ISO 8601 format + */ +export type Time2 = string; +/** + * External url with action details + */ +export type Externalurl2 = string; +/** + * Message related to the action. The maximum length is 512 characters; any text exceeding this limit will be truncated + */ +export type Message2 = string; +export type Actions2 = ("CHARGE" | "REFUND" | "CANCEL")[] | null; +/** + * Result of the action + */ +export type Result2 = "AUTHORIZATION_ACTION_REQUIRED" | "CHARGE_ACTION_REQUIRED"; +/** + * The JSON data that will be returned to storefront + */ +export type JsonValue2 = + | string + | number + | boolean + | { + [k: string]: unknown; + } + | unknown[] + | null; +/** + * Details of the payment method used for the transaction. + */ +export type Paymentmethoddetails2 = (OtherPaymentMethodDetails | CardPaymentMethodDetails) | null; + +export interface TransactionSessionSuccess { + pspReference: Pspreference; + amount?: Amount; + time?: Time; + externalUrl?: Externalurl; + message?: Message; + actions?: Actions; + result: Result; + data?: JsonValue; + paymentMethodDetails?: Paymentmethoddetails; +} +export interface OtherPaymentMethodDetails { + type: Type; + name: Name; +} +export interface CardPaymentMethodDetails { + type: Type1; + name: Name1; + brand?: Brand; + firstDigits?: Firstdigits; + lastDigits?: Lastdigits; + expMonth?: Expmonth; + expYear?: Expyear; +} +export interface TransactionSessionFailure { + pspReference?: Pspreference1; + amount?: Amount1; + time?: Time1; + externalUrl?: Externalurl1; + message?: Message1; + actions?: Actions1; + result: Result1; + data?: JsonValue1; + paymentMethodDetails?: Paymentmethoddetails1; +} +export interface TransactionSessionActionRequired { + pspReference?: Pspreference2; + amount?: Amount2; + time?: Time2; + externalUrl?: Externalurl2; + message?: Message2; + actions?: Actions2; + result: Result2; + data?: JsonValue2; + paymentMethodDetails?: Paymentmethoddetails2; +} diff --git a/apps/dummy-payment/generated/app-webhooks-types/transaction-process-session.ts b/apps/dummy-payment/generated/app-webhooks-types/transaction-process-session.ts new file mode 100644 index 000000000..3ed218c54 --- /dev/null +++ b/apps/dummy-payment/generated/app-webhooks-types/transaction-process-session.ts @@ -0,0 +1,217 @@ +/* eslint-disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +export type TransactionProcessSession = + | TransactionSessionSuccess + | TransactionSessionFailure + | TransactionSessionActionRequired; +/** + * PSP reference received from payment provider. + */ +export type Pspreference = string; +/** + * Decimal amount of the processed action + */ +export type Amount = number | string; +/** + * Time of the action in ISO 8601 format + */ +export type Time = string; +/** + * External url with action details + */ +export type Externalurl = string; +/** + * Message related to the action. The maximum length is 512 characters; any text exceeding this limit will be truncated + */ +export type Message = string; +export type Actions = ("CHARGE" | "REFUND" | "CANCEL")[] | null; +/** + * Result of the action + */ +export type Result = "AUTHORIZATION_SUCCESS" | "CHARGE_SUCCESS" | "AUTHORIZATION_REQUEST" | "CHARGE_REQUEST"; +/** + * The JSON data that will be returned to storefront + */ +export type JsonValue = + | string + | number + | boolean + | { + [k: string]: unknown; + } + | unknown[] + | null; +/** + * Details of the payment method used for the transaction. + */ +export type Paymentmethoddetails = (OtherPaymentMethodDetails | CardPaymentMethodDetails) | null; +/** + * Type of the payment method used for the transaction. + */ +export type Type = "OTHER"; +/** + * Name of the payment method used for the transaction. + */ +export type Name = string; +/** + * Type of the payment method used for the transaction. + */ +export type Type1 = "CARD"; +/** + * Name of the payment method used for the transaction. + */ +export type Name1 = string; +/** + * Brand of the card used for the transaction. + */ +export type Brand = string | null; +/** + * First digits of the card used for the transaction. + */ +export type Firstdigits = string | null; +/** + * Last digits of the card used for the transaction. + */ +export type Lastdigits = string | null; +/** + * Expiration month of the card used for the transaction. + */ +export type Expmonth = number | null; +/** + * Expiration year of the card used for the transaction. + */ +export type Expyear = number | null; +/** + * PSP reference received from payment provider. + */ +export type Pspreference1 = string; +/** + * Decimal amount of the processed action + */ +export type Amount1 = number | string; +/** + * Time of the action in ISO 8601 format + */ +export type Time1 = string; +/** + * External url with action details + */ +export type Externalurl1 = string; +/** + * Message related to the action. The maximum length is 512 characters; any text exceeding this limit will be truncated + */ +export type Message1 = string; +export type Actions1 = ("CHARGE" | "REFUND" | "CANCEL")[] | null; +/** + * Result of the action + */ +export type Result1 = "AUTHORIZATION_FAILURE" | "CHARGE_FAILURE"; +/** + * The JSON data that will be returned to storefront + */ +export type JsonValue1 = + | string + | number + | boolean + | { + [k: string]: unknown; + } + | unknown[] + | null; +/** + * Details of the payment method used for the transaction. + */ +export type Paymentmethoddetails1 = (OtherPaymentMethodDetails | CardPaymentMethodDetails) | null; +/** + * PSP reference received from payment provider. + */ +export type Pspreference2 = string; +/** + * Decimal amount of the processed action + */ +export type Amount2 = number | string; +/** + * Time of the action in ISO 8601 format + */ +export type Time2 = string; +/** + * External url with action details + */ +export type Externalurl2 = string; +/** + * Message related to the action. The maximum length is 512 characters; any text exceeding this limit will be truncated + */ +export type Message2 = string; +export type Actions2 = ("CHARGE" | "REFUND" | "CANCEL")[] | null; +/** + * Result of the action + */ +export type Result2 = "AUTHORIZATION_ACTION_REQUIRED" | "CHARGE_ACTION_REQUIRED"; +/** + * The JSON data that will be returned to storefront + */ +export type JsonValue2 = + | string + | number + | boolean + | { + [k: string]: unknown; + } + | unknown[] + | null; +/** + * Details of the payment method used for the transaction. + */ +export type Paymentmethoddetails2 = (OtherPaymentMethodDetails | CardPaymentMethodDetails) | null; + +export interface TransactionSessionSuccess { + pspReference: Pspreference; + amount?: Amount; + time?: Time; + externalUrl?: Externalurl; + message?: Message; + actions?: Actions; + result: Result; + data?: JsonValue; + paymentMethodDetails?: Paymentmethoddetails; +} +export interface OtherPaymentMethodDetails { + type: Type; + name: Name; +} +export interface CardPaymentMethodDetails { + type: Type1; + name: Name1; + brand?: Brand; + firstDigits?: Firstdigits; + lastDigits?: Lastdigits; + expMonth?: Expmonth; + expYear?: Expyear; +} +export interface TransactionSessionFailure { + pspReference?: Pspreference1; + amount?: Amount1; + time?: Time1; + externalUrl?: Externalurl1; + message?: Message1; + actions?: Actions1; + result: Result1; + data?: JsonValue1; + paymentMethodDetails?: Paymentmethoddetails1; +} +export interface TransactionSessionActionRequired { + pspReference?: Pspreference2; + amount?: Amount2; + time?: Time2; + externalUrl?: Externalurl2; + message?: Message2; + actions?: Actions2; + result: Result2; + data?: JsonValue2; + paymentMethodDetails?: Paymentmethoddetails2; +} diff --git a/apps/dummy-payment/generated/app-webhooks-types/transaction-refund-requested.ts b/apps/dummy-payment/generated/app-webhooks-types/transaction-refund-requested.ts new file mode 100644 index 000000000..21c0b276f --- /dev/null +++ b/apps/dummy-payment/generated/app-webhooks-types/transaction-refund-requested.ts @@ -0,0 +1,89 @@ +/* eslint-disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +export type TransactionRefundRequested = + | TransactionRefundRequestedSyncSuccess + | TransactionRefundRequestedSyncFailure + | TransactionRefundRequestedAsync; +/** + * PSP reference received from payment provider. + */ +export type Pspreference = string; +/** + * Decimal amount of the processed action + */ +export type Amount = number | string; +/** + * Time of the action in ISO 8601 format + */ +export type Time = string; +/** + * External url with action details + */ +export type Externalurl = string; +/** + * Message related to the action. The maximum length is 512 characters; any text exceeding this limit will be truncated + */ +export type Message = string; +export type Actions = ("CHARGE" | "REFUND" | "CANCEL")[] | null; +/** + * Result of the action + */ +export type Result = "REFUND_SUCCESS"; +/** + * PSP reference received from payment provider. + */ +export type Pspreference1 = string; +/** + * Decimal amount of the processed action + */ +export type Amount1 = number | string; +/** + * Time of the action in ISO 8601 format + */ +export type Time1 = string; +/** + * External url with action details + */ +export type Externalurl1 = string; +/** + * Message related to the action. The maximum length is 512 characters; any text exceeding this limit will be truncated + */ +export type Message1 = string; +export type Actions1 = ("CHARGE" | "REFUND" | "CANCEL")[] | null; +/** + * Result of the action + */ +export type Result1 = "REFUND_FAILURE"; +/** + * PSP reference received from payment provider. + */ +export type Pspreference2 = string; +export type Actions2 = ("CHARGE" | "REFUND" | "CANCEL")[] | null; + +export interface TransactionRefundRequestedSyncSuccess { + pspReference: Pspreference; + amount?: Amount; + time?: Time; + externalUrl?: Externalurl; + message?: Message; + actions?: Actions; + result: Result; +} +export interface TransactionRefundRequestedSyncFailure { + pspReference?: Pspreference1; + amount?: Amount1; + time?: Time1; + externalUrl?: Externalurl1; + message?: Message1; + actions?: Actions1; + result: Result1; +} +export interface TransactionRefundRequestedAsync { + pspReference: Pspreference2; + actions?: Actions2; +} diff --git a/apps/dummy-payment/graphql/fragments/.gitkeep b/apps/dummy-payment/graphql/fragments/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/apps/dummy-payment/graphql/fragments/BasicWebhookMetadata.graphql b/apps/dummy-payment/graphql/fragments/BasicWebhookMetadata.graphql new file mode 100644 index 000000000..d875381f5 --- /dev/null +++ b/apps/dummy-payment/graphql/fragments/BasicWebhookMetadata.graphql @@ -0,0 +1,4 @@ +fragment BasicWebhookMetadata on Event { + issuedAt + version +} diff --git a/apps/dummy-payment/graphql/fragments/Money.graphql b/apps/dummy-payment/graphql/fragments/Money.graphql new file mode 100644 index 000000000..c99722145 --- /dev/null +++ b/apps/dummy-payment/graphql/fragments/Money.graphql @@ -0,0 +1,4 @@ +fragment Money on Money { + amount + currency +} diff --git a/apps/dummy-payment/graphql/fragments/PaymentGatewayInitializeSessionEvent.graphql b/apps/dummy-payment/graphql/fragments/PaymentGatewayInitializeSessionEvent.graphql new file mode 100644 index 000000000..13038b90d --- /dev/null +++ b/apps/dummy-payment/graphql/fragments/PaymentGatewayInitializeSessionEvent.graphql @@ -0,0 +1,14 @@ +fragment PaymentGatewayInitializeSessionEvent on PaymentGatewayInitializeSession { + ...BasicWebhookMetadata + __typename + recipient { + ...PaymentGatewayRecipient + } + data + amount + issuingPrincipal { + ... on Node { + id + } + } +} diff --git a/apps/dummy-payment/graphql/fragments/PaymentGatewayRecipient.graphql b/apps/dummy-payment/graphql/fragments/PaymentGatewayRecipient.graphql new file mode 100644 index 000000000..548a15caa --- /dev/null +++ b/apps/dummy-payment/graphql/fragments/PaymentGatewayRecipient.graphql @@ -0,0 +1,11 @@ +fragment PaymentGatewayRecipient on App { + id + privateMetadata { + key + value + } + metadata { + key + value + } +} diff --git a/apps/dummy-payment/graphql/fragments/SyncWebhookTransaction.graphql b/apps/dummy-payment/graphql/fragments/SyncWebhookTransaction.graphql new file mode 100644 index 000000000..9034e62ec --- /dev/null +++ b/apps/dummy-payment/graphql/fragments/SyncWebhookTransaction.graphql @@ -0,0 +1,8 @@ +fragment SyncWebhookTransaction on TransactionItem { + id + token + pspReference + events { + pspReference + } +} diff --git a/apps/dummy-payment/graphql/fragments/Transaction.graphql b/apps/dummy-payment/graphql/fragments/Transaction.graphql new file mode 100644 index 000000000..33cf3bbe6 --- /dev/null +++ b/apps/dummy-payment/graphql/fragments/Transaction.graphql @@ -0,0 +1,44 @@ +fragment Transaction on TransactionItem { + id + pspReference + createdAt + message + name + authorizedAmount { + ...Money + } + authorizePendingAmount { + ...Money + } + refundedAmount { + ...Money + } + refundPendingAmount { + ...Money + } + canceledAmount { + ...Money + } + cancelPendingAmount { + ...Money + } + chargedAmount { + ...Money + } + chargePendingAmount { + ...Money + } + events { + id + createdAt + pspReference + message + amount { + ...Money + } + type + } + order { + id + } +} diff --git a/apps/dummy-payment/graphql/fragments/TransactionCancelRequestedEvent.graphql b/apps/dummy-payment/graphql/fragments/TransactionCancelRequestedEvent.graphql new file mode 100644 index 000000000..bca8a9a39 --- /dev/null +++ b/apps/dummy-payment/graphql/fragments/TransactionCancelRequestedEvent.graphql @@ -0,0 +1,13 @@ +fragment TransactionCancelRequestedEvent on TransactionCancelationRequested { + ...BasicWebhookMetadata + __typename + recipient { + ...PaymentGatewayRecipient + } + transaction { + ...SyncWebhookTransaction + authorizedAmount { + ...Money + } + } +} diff --git a/apps/dummy-payment/graphql/fragments/TransactionChargeRequestedEvent.graphql b/apps/dummy-payment/graphql/fragments/TransactionChargeRequestedEvent.graphql new file mode 100644 index 000000000..d6122694e --- /dev/null +++ b/apps/dummy-payment/graphql/fragments/TransactionChargeRequestedEvent.graphql @@ -0,0 +1,17 @@ +fragment TransactionChargeRequestedEvent on TransactionChargeRequested { + ...BasicWebhookMetadata + __typename + recipient { + ...PaymentGatewayRecipient + } + action { + amount + actionType + } + transaction { + ...SyncWebhookTransaction + authorizedAmount { + ...Money + } + } +} diff --git a/apps/dummy-payment/graphql/fragments/TransactionInitializeSessionEvent.graphql b/apps/dummy-payment/graphql/fragments/TransactionInitializeSessionEvent.graphql new file mode 100644 index 000000000..32334a946 --- /dev/null +++ b/apps/dummy-payment/graphql/fragments/TransactionInitializeSessionEvent.graphql @@ -0,0 +1,23 @@ +fragment TransactionInitializeSessionEvent on TransactionInitializeSession { + ...BasicWebhookMetadata + __typename + recipient { + ...PaymentGatewayRecipient + } + idempotencyKey + data + merchantReference + action { + amount + currency + actionType + } + issuingPrincipal { + ... on Node { + id + } + } + transaction { + ...SyncWebhookTransaction + } +} diff --git a/apps/dummy-payment/graphql/fragments/TransactionProcessSessionEvent.graphql b/apps/dummy-payment/graphql/fragments/TransactionProcessSessionEvent.graphql new file mode 100644 index 000000000..4d1a600cb --- /dev/null +++ b/apps/dummy-payment/graphql/fragments/TransactionProcessSessionEvent.graphql @@ -0,0 +1,17 @@ +fragment TransactionProcessSessionEvent on TransactionProcessSession { + ...BasicWebhookMetadata + __typename + recipient { + ...PaymentGatewayRecipient + } + data + merchantReference + action { + amount + currency + actionType + } + transaction { + ...SyncWebhookTransaction + } +} diff --git a/apps/dummy-payment/graphql/fragments/TransactionRefundRequestedEvent.graphql b/apps/dummy-payment/graphql/fragments/TransactionRefundRequestedEvent.graphql new file mode 100644 index 000000000..e416b8b46 --- /dev/null +++ b/apps/dummy-payment/graphql/fragments/TransactionRefundRequestedEvent.graphql @@ -0,0 +1,17 @@ +fragment TransactionRefundRequestedEvent on TransactionRefundRequested { + ...BasicWebhookMetadata + __typename + recipient { + ...PaymentGatewayRecipient + } + action { + amount + actionType + } + transaction { + ...SyncWebhookTransaction + chargedAmount { + ...Money + } + } +} diff --git a/apps/dummy-payment/graphql/mutations/.gitkeep b/apps/dummy-payment/graphql/mutations/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/apps/dummy-payment/graphql/mutations/CompleteCheckout.graphql b/apps/dummy-payment/graphql/mutations/CompleteCheckout.graphql new file mode 100644 index 000000000..e6e5fa850 --- /dev/null +++ b/apps/dummy-payment/graphql/mutations/CompleteCheckout.graphql @@ -0,0 +1,13 @@ +mutation CompleteCheckout($id: ID!) { + checkoutComplete(id: $id) { + errors { + field + message + } + order { + id + number + paymentStatus + } + } +} diff --git a/apps/dummy-payment/graphql/mutations/CreateCheckout.graphql b/apps/dummy-payment/graphql/mutations/CreateCheckout.graphql new file mode 100644 index 000000000..7b4225c34 --- /dev/null +++ b/apps/dummy-payment/graphql/mutations/CreateCheckout.graphql @@ -0,0 +1,48 @@ +mutation CreateCheckout($channelSlug: String!, $variants: [CheckoutLineInput!]!) { + checkoutCreate( + input: { + channel: $channelSlug + lines: $variants + languageCode: EN_US + email: "demo@saleor.io" + billingAddress: { + firstName: "John" + lastName: "Doe" + streetAddress1: "813 Howard Street" + city: "Oswego" + countryArea: "NY" + postalCode: "13126" + country: US + } + shippingAddress: { + firstName: "John" + lastName: "Doe" + streetAddress1: "813 Howard Street" + city: "Oswego" + countryArea: "NY" + postalCode: "13126" + country: US + } + } + ) { + errors { + field + message + } + checkout { + id + availablePaymentGateways { + id + name + } + shippingMethods { + id + name + price { + currency + amount + } + } + } + } +} diff --git a/apps/dummy-payment/graphql/mutations/InitializeTransaction.graphql b/apps/dummy-payment/graphql/mutations/InitializeTransaction.graphql new file mode 100644 index 000000000..ac54c3b44 --- /dev/null +++ b/apps/dummy-payment/graphql/mutations/InitializeTransaction.graphql @@ -0,0 +1,28 @@ +mutation InitializeTransaction($id: ID!, $data: JSON!) { + transactionInitialize( + id: $id + paymentGateway: { id: "saleor.io.dummy-payment-app", data: $data } + ) { + errors { + code + field + message + } + transaction { + id + pspReference + } + transactionEvent { + id + pspReference + message + externalUrl + amount { + currency + amount + } + type + idempotencyKey + } + } +} diff --git a/apps/dummy-payment/graphql/mutations/TransactionEventReport.graphql b/apps/dummy-payment/graphql/mutations/TransactionEventReport.graphql new file mode 100644 index 000000000..0c7192211 --- /dev/null +++ b/apps/dummy-payment/graphql/mutations/TransactionEventReport.graphql @@ -0,0 +1,31 @@ +mutation TransactionEventReport( + $id: ID! + $amount: PositiveDecimal + $type: TransactionEventTypeEnum! + $pspReference: String! + $availableActions: [TransactionActionEnum!] + $message: String + $externalUrl: String +) { + transactionEventReport( + id: $id + amount: $amount + type: $type + message: $message + pspReference: $pspReference + availableActions: $availableActions + externalUrl: $externalUrl + ) { + alreadyProcessed + + transactionEvent { + id + } + + errors { + field + message + code + } + } +} diff --git a/apps/dummy-payment/graphql/mutations/UpdateDelivery.graphql b/apps/dummy-payment/graphql/mutations/UpdateDelivery.graphql new file mode 100644 index 000000000..3c69f00e4 --- /dev/null +++ b/apps/dummy-payment/graphql/mutations/UpdateDelivery.graphql @@ -0,0 +1,12 @@ +mutation UpdateDelivery($id: ID!, $methodId: ID!) { + checkoutDeliveryMethodUpdate(id: $id, deliveryMethodId: $methodId) { + checkout { + id + deliveryMethod { + ... on ShippingMethod { + id + } + } + } + } +} diff --git a/apps/dummy-payment/graphql/queries/.gitkeep b/apps/dummy-payment/graphql/queries/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/apps/dummy-payment/graphql/queries/ChannelsList.graphql b/apps/dummy-payment/graphql/queries/ChannelsList.graphql new file mode 100644 index 000000000..4588f1e5f --- /dev/null +++ b/apps/dummy-payment/graphql/queries/ChannelsList.graphql @@ -0,0 +1,7 @@ +query ChannelsList { + channels { + id + name + slug + } +} diff --git a/apps/dummy-payment/graphql/queries/FetchAppDetails.graphql b/apps/dummy-payment/graphql/queries/FetchAppDetails.graphql new file mode 100644 index 000000000..b4bad820a --- /dev/null +++ b/apps/dummy-payment/graphql/queries/FetchAppDetails.graphql @@ -0,0 +1,9 @@ +query FetchAppDetails { + app { + id + privateMetadata { + key + value + } + } +} diff --git a/apps/dummy-payment/graphql/queries/ProductList.graphql b/apps/dummy-payment/graphql/queries/ProductList.graphql new file mode 100644 index 000000000..7725b7781 --- /dev/null +++ b/apps/dummy-payment/graphql/queries/ProductList.graphql @@ -0,0 +1,34 @@ +query ProductList($channelSlug: String!) { + products( + first: 10 + channel: $channelSlug + where: { isAvailable: true, isPublished: true, giftCard: false, isVisibleInListing: true } + sortBy: { field: PRICE, direction: DESC } + ) { + edges { + node { + id + name + thumbnail(size: 2048) { + url + alt + } + category { + name + } + defaultVariant { + id + name + pricing { + price { + gross { + amount + currency + } + } + } + } + } + } + } +} diff --git a/apps/dummy-payment/graphql/queries/TransactionDetailsViaId.graphql b/apps/dummy-payment/graphql/queries/TransactionDetailsViaId.graphql new file mode 100644 index 000000000..b01b3f06a --- /dev/null +++ b/apps/dummy-payment/graphql/queries/TransactionDetailsViaId.graphql @@ -0,0 +1,5 @@ +query TransactionDetailsViaId($id: ID!) { + transaction(id: $id) { + ...Transaction + } +} diff --git a/apps/dummy-payment/graphql/queries/TransactionDetailsViaPsp.graphql b/apps/dummy-payment/graphql/queries/TransactionDetailsViaPsp.graphql new file mode 100644 index 000000000..98ed78336 --- /dev/null +++ b/apps/dummy-payment/graphql/queries/TransactionDetailsViaPsp.graphql @@ -0,0 +1,13 @@ +query TransactionDetailsViaPsp($pspReference: String!) { + orders(first: 1, filter: { search: $pspReference }) { + edges { + node { + id + number + transactions { + ...Transaction + } + } + } + } +} diff --git a/apps/dummy-payment/graphql/subscriptions/.gitkeep b/apps/dummy-payment/graphql/subscriptions/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/apps/dummy-payment/graphql/subscriptions/PaymentGatewayInitializeSession.graphql b/apps/dummy-payment/graphql/subscriptions/PaymentGatewayInitializeSession.graphql new file mode 100644 index 000000000..73467dc7e --- /dev/null +++ b/apps/dummy-payment/graphql/subscriptions/PaymentGatewayInitializeSession.graphql @@ -0,0 +1,5 @@ +subscription PaymentGatewayInitializeSession { + event { + ...PaymentGatewayInitializeSessionEvent + } +} diff --git a/apps/dummy-payment/graphql/subscriptions/TransactionCancelRequested.graphql b/apps/dummy-payment/graphql/subscriptions/TransactionCancelRequested.graphql new file mode 100644 index 000000000..91f3d6b7e --- /dev/null +++ b/apps/dummy-payment/graphql/subscriptions/TransactionCancelRequested.graphql @@ -0,0 +1,5 @@ +subscription TransactionCancelRequested { + event { + ...TransactionCancelRequestedEvent + } +} diff --git a/apps/dummy-payment/graphql/subscriptions/TransactionChargeRequested.graphql b/apps/dummy-payment/graphql/subscriptions/TransactionChargeRequested.graphql new file mode 100644 index 000000000..7314c2487 --- /dev/null +++ b/apps/dummy-payment/graphql/subscriptions/TransactionChargeRequested.graphql @@ -0,0 +1,5 @@ +subscription TransactionChargeRequested { + event { + ...TransactionChargeRequestedEvent + } +} diff --git a/apps/dummy-payment/graphql/subscriptions/TransactionInitializeSession.graphql b/apps/dummy-payment/graphql/subscriptions/TransactionInitializeSession.graphql new file mode 100644 index 000000000..75b6cfa62 --- /dev/null +++ b/apps/dummy-payment/graphql/subscriptions/TransactionInitializeSession.graphql @@ -0,0 +1,5 @@ +subscription TransactionInitializeSession { + event { + ...TransactionInitializeSessionEvent + } +} diff --git a/apps/dummy-payment/graphql/subscriptions/TransactionProcessSession.graphql b/apps/dummy-payment/graphql/subscriptions/TransactionProcessSession.graphql new file mode 100644 index 000000000..e865fa10e --- /dev/null +++ b/apps/dummy-payment/graphql/subscriptions/TransactionProcessSession.graphql @@ -0,0 +1,5 @@ +subscription TransactionProcessSession { + event { + ...TransactionProcessSessionEvent + } +} diff --git a/apps/dummy-payment/graphql/subscriptions/TransactionRefundRequested.graphql b/apps/dummy-payment/graphql/subscriptions/TransactionRefundRequested.graphql new file mode 100644 index 000000000..401184334 --- /dev/null +++ b/apps/dummy-payment/graphql/subscriptions/TransactionRefundRequested.graphql @@ -0,0 +1,5 @@ +subscription TransactionRefundRequested { + event { + ...TransactionRefundRequestedEvent + } +} diff --git a/apps/dummy-payment/next-env.d.ts b/apps/dummy-payment/next-env.d.ts new file mode 100644 index 000000000..4f11a03dc --- /dev/null +++ b/apps/dummy-payment/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/apps/dummy-payment/next.config.js b/apps/dummy-payment/next.config.js new file mode 100644 index 000000000..295717249 --- /dev/null +++ b/apps/dummy-payment/next.config.js @@ -0,0 +1,62 @@ +// @ts-check + +import withBundleAnalyzerConfig from "@next/bundle-analyzer"; +import { withSentryConfig } from "@sentry/nextjs"; + +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, + experimental: { + optimizePackageImports: [ + "@sentry/nextjs", + "@sentry/node", + "usehooks-ts", + "@saleor/app-sdk", + "@trpc/server", + "@trpc/client", + "@trpc/react-query", + "@trpc/next", + "jotai", + "@saleor/apps-shared", + ], + }, + /* + * Ignore opentelemetry warnings - https://github.com/open-telemetry/opentelemetry-js/issues/4173 + * Remove when https://github.com/open-telemetry/opentelemetry-js/pull/4660 is released + */ + /** @type {import('next').NextConfig['webpack']} */ + webpack: (config, { isServer }) => { + if (isServer) { + config.ignoreWarnings = [{ module: /opentelemetry/ }]; + } + return config; + }, +}; + +const configWithSentry = withSentryConfig( + nextConfig, + { + org: process.env.SENTRY_ORG, + project: process.env.SENTRY_PROJECT, + silent: true, + }, + { + hideSourceMaps: true, + widenClientFileUpload: true, + disableLogger: true, + transpileClientSDK: true, + tunnelRoute: "/monitoring", + }, +); + +const withBundleAnalyzer = withBundleAnalyzerConfig({ + enabled: process.env.ANALYZE_BUNDLE === "true", +}); + +const isSentryPropertiesInEnvironment = + process.env.SENTRY_AUTH_TOKEN && process.env.SENTRY_PROJECT && process.env.SENTRY_ORG; + +const config = isSentryPropertiesInEnvironment ? configWithSentry : nextConfig; + +// @ts-expect-error bundle analyzer requires NextConfig when Sentry is returning NextConfigFunction | NextConfigObject +export default withBundleAnalyzer(config); diff --git a/apps/dummy-payment/package.json b/apps/dummy-payment/package.json new file mode 100644 index 000000000..a7501eba0 --- /dev/null +++ b/apps/dummy-payment/package.json @@ -0,0 +1,97 @@ +{ + "name": "dummy-payment-app", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "pnpm generate && NODE_OPTIONS='--inspect' next dev", + "build": "pnpm generate && next build", + "start": "next start", + "lint": "next lint", + "fetch-schema": "curl https://raw.githubusercontent.com/saleor/saleor/${npm_package_saleor_schemaVersion}/saleor/graphql/schema.graphql > graphql/schema.graphql", + "generate": "npm run generate:schema && npm run generate:app-webhooks-types", + "generate:schema": "graphql-codegen", + "test": "vitest", + "generate:app-webhooks-types": "tsx ./scripts/generate-app-webhooks-types.ts --json-schema-version=3.22" + }, + "saleor": { + "schemaVersion": "3.22" + }, + "engines": { + "node": ">=24 <=26", + "pnpm": ">=10" + }, + "type": "module", + "dependencies": { + "@aws-sdk/client-dynamodb": "3.658.1", + "@aws-sdk/lib-dynamodb": "3.658.1", + "@aws-sdk/util-dynamodb": "3.658.1", + "@next/bundle-analyzer": "14.2.5", + "@opentelemetry/api": "1.7.0", + "@opentelemetry/api-logs": "0.46.0", + "@opentelemetry/core": "1.19.0", + "@opentelemetry/exporter-logs-otlp-http": "0.46.0", + "@opentelemetry/exporter-trace-otlp-http": "0.46.0", + "@opentelemetry/instrumentation-http": "0.46.0", + "@opentelemetry/instrumentation-winston": "0.33.1", + "@opentelemetry/resources": "1.18.1", + "@opentelemetry/sdk-logs": "0.45.1", + "@opentelemetry/sdk-node": "0.45.1", + "@opentelemetry/sdk-trace-base": "1.18.1", + "@opentelemetry/sdk-trace-node": "1.18.1", + "@opentelemetry/semantic-conventions": "1.18.1", + "@saleor/app-sdk": "1.7.1", + "@saleor/macaw-ui": "1.0.0", + "@sentry/nextjs": "7.117.0", + "@tanstack/react-query": "^4.35.3", + "@trpc/client": "10.45.3", + "@trpc/next": "10.45.3", + "@trpc/react-query": "^10.45.3", + "@trpc/server": "10.45.3", + "@urql/exchange-auth": "1.0.0", + "@vercel/oidc-aws-credentials-provider": "3.0.7", + "@vitejs/plugin-react": "4.2.1", + "dynamodb-toolbox": "2.6.4", + "graphql": "16.8.1", + "graphql-tag": "2.12.6", + "jsdom": "20.0.3", + "json-schema-to-typescript": "15.0.4", + "modern-errors": "7.0.1", + "modern-errors-serialize": "6.1.0", + "next": "14.1.2", + "react": "18.2.0", + "react-dom": "18.2.0", + "tslog": "4.9.3", + "urql": "4.0.2", + "usehooks-ts": "3.1.0", + "uuid": "10.0.0", + "vite": "5.2.10", + "vitest": "1.5.2", + "zod": "3.23.8" + }, + "packageManager": "pnpm@10.28.1", + "devDependencies": { + "@graphql-codegen/cli": "3.3.1", + "@graphql-codegen/introspection": "3.0.1", + "@graphql-codegen/schema-ast": "3.0.1", + "@graphql-codegen/typed-document-node": "4.0.1", + "@graphql-codegen/typescript": "3.0.4", + "@graphql-codegen/typescript-operations": "3.0.4", + "@graphql-codegen/typescript-urql": "3.7.3", + "@graphql-codegen/urql-introspection": "2.2.1", + "@graphql-typed-document-node/core": "3.2.0", + "@types/node": "24.12.2", + "@types/react": "18.2.6", + "@types/react-dom": "18.2.4", + "@types/uuid": "10.0.0", + "eslint": "8.31.0", + "eslint-config-next": "13.1.2", + "eslint-config-prettier": "8.6.0", + "prettier": "2.8.2", + "tsx": "4.21.0", + "typescript": "5.0.4" + }, + "lint-staged": { + "*.{js,ts,tsx}": "eslint --cache --fix", + "*.{js,ts,tsx,css,md,json}": "prettier --write" + } +} diff --git a/apps/dummy-payment/public/logo.png b/apps/dummy-payment/public/logo.png new file mode 100644 index 000000000..26ad057cf Binary files /dev/null and b/apps/dummy-payment/public/logo.png differ diff --git a/apps/dummy-payment/scripts/generate-app-webhooks-types.ts b/apps/dummy-payment/scripts/generate-app-webhooks-types.ts new file mode 100644 index 000000000..ffd3ca45b --- /dev/null +++ b/apps/dummy-payment/scripts/generate-app-webhooks-types.ts @@ -0,0 +1,62 @@ +/* eslint-disable no-console */ +import { writeFileSync } from "node:fs"; +import { parseArgs } from "node:util"; + +import { compile, type JSONSchema } from "json-schema-to-typescript"; + +const { + values: { "json-schema-version": jsonSchemaVersion }, +} = parseArgs({ + options: { + "json-schema-version": { + type: "string", + default: "main", + }, + }, +}); + +const schemaFileNames = [ + "PaymentGatewayInitializeSession", + "TransactionInitializeSession", + "TransactionRefundRequested", + "TransactionProcessSession", +]; + +const path = `https://raw.githubusercontent.com/saleor/saleor/${jsonSchemaVersion}/saleor/json_schemas/`; + +const convertToKebabCase = (fileName: string): string => { + return fileName + .replace(/([A-Z])/g, "-$1") + .toLowerCase() + .replace(/^-/, ""); +}; + +const schemaMapping = schemaFileNames.map((fileName) => ({ + fileName: convertToKebabCase(fileName), + url: `${path}${fileName}.json`, +})); + +async function generateAppWebhooksTypes() { + await Promise.all( + schemaMapping.map(async ({ fileName, url }) => { + const res = await fetch(url); + + const fetchedSchema = (await res.json()) as JSONSchema; + + const compiledTypes = await compile(fetchedSchema, fileName, { + additionalProperties: false, + }); + + writeFileSync(`./generated/app-webhooks-types/${fileName}.ts`, compiledTypes); + }), + ); +} + +try { + console.log("Fetching JSON schemas from Saleor GitHub repository..."); + generateAppWebhooksTypes(); + console.log("Successfully generated TypeScript files from JSON schemas."); +} catch (error) { + console.error(`Error generating webhook response types: ${error}`); + process.exit(1); +} diff --git a/apps/dummy-payment/sentry.client.config.ts b/apps/dummy-payment/sentry.client.config.ts new file mode 100644 index 000000000..a04bc5f38 --- /dev/null +++ b/apps/dummy-payment/sentry.client.config.ts @@ -0,0 +1,16 @@ +/* + * This file configures the initialization of Sentry on the browser. + * The config you add here will be used whenever a page is visited. + * https://docs.sentry.io/platforms/javascript/guides/nextjs/ + */ + +import * as Sentry from "@sentry/nextjs"; + +Sentry.init({ + dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, + enableTracing: false, + environment: process.env.ENV, + includeLocalVariables: true, + ignoreErrors: ["TRPCClientError"], + integrations: [], +}); diff --git a/apps/dummy-payment/sentry.edge.config.ts b/apps/dummy-payment/sentry.edge.config.ts new file mode 100644 index 000000000..789ed896e --- /dev/null +++ b/apps/dummy-payment/sentry.edge.config.ts @@ -0,0 +1,14 @@ +import * as Sentry from "@sentry/nextjs"; + +Sentry.init({ + dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, + enableTracing: false, + environment: process.env.ENV, + includeLocalVariables: true, + integrations: [ + Sentry.localVariablesIntegration({ + captureAllExceptions: true, + }), + Sentry.extraErrorDataIntegration(), + ], +}); diff --git a/apps/dummy-payment/sentry.server.config.ts b/apps/dummy-payment/sentry.server.config.ts new file mode 100644 index 000000000..789ed896e --- /dev/null +++ b/apps/dummy-payment/sentry.server.config.ts @@ -0,0 +1,14 @@ +import * as Sentry from "@sentry/nextjs"; + +Sentry.init({ + dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, + enableTracing: false, + environment: process.env.ENV, + includeLocalVariables: true, + integrations: [ + Sentry.localVariablesIntegration({ + captureAllExceptions: true, + }), + Sentry.extraErrorDataIntegration(), + ], +}); diff --git a/apps/dummy-payment/src/components/AppContent.tsx b/apps/dummy-payment/src/components/AppContent.tsx new file mode 100644 index 000000000..577288d6b --- /dev/null +++ b/apps/dummy-payment/src/components/AppContent.tsx @@ -0,0 +1,10 @@ +import { Box } from "@saleor/macaw-ui"; +import React from "react"; + +interface AppContentProps { + children: React.ReactNode; +} + +export const AppContent = ({ children }: AppContentProps) => { + return {children}; +}; diff --git a/apps/dummy-payment/src/components/Navigation.tsx b/apps/dummy-payment/src/components/Navigation.tsx new file mode 100644 index 000000000..a25bba358 --- /dev/null +++ b/apps/dummy-payment/src/components/Navigation.tsx @@ -0,0 +1,34 @@ +import { Box, ConfigurationIcon, HomeIcon, OrdersIcon, SellsIcon } from "@saleor/macaw-ui"; +import { NavigationTile } from "./NavigationTile"; + +export const ROUTES = { + dashboard: "/app", + checkout: "/app/checkout", + transactions: "/app/transactions", + configuration: "/app/configuration", +} as const; + +export const Navigation = () => { + return ( + + + + + Home + + + + Quick checkout + + + + Event reporter + + + + Configuration + + + + ); +}; diff --git a/apps/dummy-payment/src/components/NavigationTile.tsx b/apps/dummy-payment/src/components/NavigationTile.tsx new file mode 100644 index 000000000..df8ef15cd --- /dev/null +++ b/apps/dummy-payment/src/components/NavigationTile.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import { Text } from "@saleor/macaw-ui"; +import Link from "next/link"; +import { useRouter } from "next/router"; + +interface NavigationTileProps { + href: string; + children: React.ReactNode; +} + +const navbarPathMatch = (href: string, pathname: string) => { + if (pathname === href) { + return true; + } + + // pathname matches href + /[any param] + const regex = new RegExp(`^${href}/\\[.+\\]$`); + if (regex.test(pathname)) { + return true; + } + + return false; +}; + +export const NavigationTile = ({ href, children }: NavigationTileProps) => { + const router = useRouter(); + const { pathname } = router; + + const isActive = navbarPathMatch(href, pathname); + + return ( + + + {children} + + + ); +}; diff --git a/apps/dummy-payment/src/components/StatusChip.tsx b/apps/dummy-payment/src/components/StatusChip.tsx new file mode 100644 index 000000000..9002ae931 --- /dev/null +++ b/apps/dummy-payment/src/components/StatusChip.tsx @@ -0,0 +1,52 @@ +import { Chip } from "@saleor/macaw-ui"; +import { TransactionEventTypeEnum } from "../../generated/graphql"; + +interface StatusChipProps { + eventType: TransactionEventTypeEnum | null | undefined; +} + +export const StatusChip = ({ eventType }: StatusChipProps) => { + switch (eventType) { + case TransactionEventTypeEnum.ChargeRequest: + case TransactionEventTypeEnum.AuthorizationRequest: + case TransactionEventTypeEnum.CancelRequest: + case TransactionEventTypeEnum.RefundRequest: + return ( + + {eventType?.replace(/_/g, " ") ?? "UNKNOWN"} + + ); + case TransactionEventTypeEnum.ChargeActionRequired: + case TransactionEventTypeEnum.AuthorizationActionRequired: + return ( + + {eventType?.replace(/_/g, " ") ?? "UNKNOWN"} + + ); + case TransactionEventTypeEnum.ChargeFailure: + case TransactionEventTypeEnum.AuthorizationFailure: + case TransactionEventTypeEnum.CancelFailure: + case TransactionEventTypeEnum.RefundFailure: + return ( + + {eventType?.replace(/_/g, " ") ?? "UNKNOWN"} + + ); + case TransactionEventTypeEnum.ChargeSuccess: + case TransactionEventTypeEnum.AuthorizationSuccess: + case TransactionEventTypeEnum.CancelSuccess: + case TransactionEventTypeEnum.RefundSuccess: + return ( + + {eventType?.replace(/_/g, " ") ?? "UNKNOWN"} + + ); + // Chargeback, refund reverse, info, authorization adjustment + default: + return ( + + {eventType?.replace(/_/g, " ") ?? "UNKNOWN"} + + ); + } +}; diff --git a/apps/dummy-payment/src/db/dynamo-client.ts b/apps/dummy-payment/src/db/dynamo-client.ts new file mode 100644 index 000000000..ebec3dc2e --- /dev/null +++ b/apps/dummy-payment/src/db/dynamo-client.ts @@ -0,0 +1,65 @@ +import { DynamoDBClient, type DynamoDBClientConfig } from "@aws-sdk/client-dynamodb"; +import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; +import { awsCredentialsProvider } from "@vercel/oidc-aws-credentials-provider"; + +interface DynamoDBClientOptions { + /** + * Time in milliseconds after which the DynamoDB request fails. + * @required Must be set, otherwise request will run indefinitely. + */ + requestTimeout: number; + /** + * Maximum time in milliseconds for the connection phase (DNS/TCP/TLS). + * @required Must be set, otherwise connection can establish indefinitely. + */ + connectionTimeout: number; + /** + * Maximum retry attempts for retryable failures. + * @default 3 (AWS SDK default) + */ + maxAttempts?: number; +} + +export const createDynamoDBClient = (opts: DynamoDBClientOptions): DynamoDBClient => { + const accessKeyId = process.env.AWS_ACCESS_KEY_ID; + const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY; + const region = process.env.AWS_REGION; + + const roleConfigured = process.env.AWS_ROLE_ARN !== undefined; + const secretKeysConfigured = accessKeyId && secretAccessKey; + + let credentials: DynamoDBClientConfig["credentials"]; + + /** + * Take precedence for OIDC + */ + if (roleConfigured) { + credentials = awsCredentialsProvider({ + roleArn: process.env.AWS_ROLE_ARN as string, + }); + } else if (secretKeysConfigured) { + /** + * Accept access keys e.g. for local development + */ + credentials = { + accessKeyId, + secretAccessKey, + }; + } + + const client = new DynamoDBClient({ + requestHandler: { + requestTimeout: opts.requestTimeout, + connectionTimeout: opts.connectionTimeout, + }, + maxAttempts: opts.maxAttempts, + credentials, + region, + }); + + return client; +}; + +export const createDynamoDBDocumentClient = (client: DynamoDBClient): DynamoDBDocumentClient => { + return DynamoDBDocumentClient.from(client); +}; diff --git a/apps/dummy-payment/src/db/dynamo-main-table.ts b/apps/dummy-payment/src/db/dynamo-main-table.ts new file mode 100644 index 000000000..62f4719ae --- /dev/null +++ b/apps/dummy-payment/src/db/dynamo-main-table.ts @@ -0,0 +1,68 @@ +import { type DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; +import { Table } from "dynamodb-toolbox"; +import { createDynamoDBClient, createDynamoDBDocumentClient } from "@/db/dynamo-client"; + + +/** + * DynamoDB table for storing APL + */ +export class DynamoMainTable extends Table< + { name: "PK"; type: "string" }, + { + name: "SK"; + type: "string"; + } +> { + private constructor( + args: ConstructorParameters< + typeof Table< + { name: "PK"; type: "string" }, + { + name: "SK"; + type: "string"; + } + > + >[number], + ) { + super(args); + } + + static create({ + documentClient, + tableName, + }: { + documentClient: DynamoDBDocumentClient; + tableName: string; + }): DynamoMainTable { + return new DynamoMainTable({ + documentClient, + name: tableName, + partitionKey: { name: "PK", type: "string" }, + sortKey: { + name: "SK", + type: "string", + }, + }); + } + + static getPrimaryKey({ + saleorApiUrl, + appId, + }: { + saleorApiUrl: string; + appId: string; + }): `${string}#${string}` { + return `${saleorApiUrl}#${appId}` as const; + } +} + +const client = createDynamoDBClient({ + requestTimeout: parseInt((process.env.DYNAMODB_MAIN_TABLE_TIMEOUT_MS as string) ?? 2000, 10), + connectionTimeout: parseInt(process.env.DYNAMODB_MAIN_TABLE_CONNECTION_TIMEOUT_MS as string ?? 5000, 10), +}); +const documentClient = createDynamoDBDocumentClient(client); + +export const dynamoMainTable = DynamoMainTable.create({ + tableName: process.env.DYNAMODB_MAIN_TABLE_NAME as string, + documentClient, +}); diff --git a/apps/dummy-payment/src/errors.ts b/apps/dummy-payment/src/errors.ts new file mode 100644 index 000000000..959e22d62 --- /dev/null +++ b/apps/dummy-payment/src/errors.ts @@ -0,0 +1,8 @@ +import ModernError from "modern-errors"; +import ModernErrorsSerialize from "modern-errors-serialize"; + +export const BaseError = ModernError.subclass("BaseError", { + plugins: [ModernErrorsSerialize], +}); + +export const UnknownError = BaseError.subclass("UnknownError"); diff --git a/apps/dummy-payment/src/lib/create-graphql-client.ts b/apps/dummy-payment/src/lib/create-graphql-client.ts new file mode 100644 index 000000000..1448c8a3f --- /dev/null +++ b/apps/dummy-payment/src/lib/create-graphql-client.ts @@ -0,0 +1,48 @@ +import { 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["getAuth"]) => + urqlCreateClient({ + url, + exchanges: [ + dedupExchange, + cacheExchange, + authExchange({ + 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, + ], + }); diff --git a/apps/dummy-payment/src/lib/invariant.ts b/apps/dummy-payment/src/lib/invariant.ts new file mode 100644 index 000000000..8ac98f131 --- /dev/null +++ b/apps/dummy-payment/src/lib/invariant.ts @@ -0,0 +1,35 @@ +import { BaseError } from "../errors"; + +export type InvariantErrorProps = { expected: boolean }; + +export const InvariantError = BaseError.subclass("InvariantError", { + props: {} as InvariantErrorProps, +}); + +export function invariant( + condition: unknown, + message?: string, + opts?: { expected?: boolean } +): asserts condition { + const { expected = false } = opts ?? {}; + + if (!condition) { + const err = new InvariantError(`Invariant failed: ${message || ""}`, { + props: { expected } as InvariantErrorProps, + }); + // 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 */ diff --git a/apps/dummy-payment/src/lib/is-in-iframe.ts b/apps/dummy-payment/src/lib/is-in-iframe.ts new file mode 100644 index 000000000..e1f481c92 --- /dev/null +++ b/apps/dummy-payment/src/lib/is-in-iframe.ts @@ -0,0 +1,7 @@ +export function isInIframe() { + try { + return window.self !== window.top; + } catch (e) { + return true; + } +} diff --git a/apps/dummy-payment/src/lib/logger/create-logger.ts b/apps/dummy-payment/src/lib/logger/create-logger.ts new file mode 100644 index 000000000..bbe5b8415 --- /dev/null +++ b/apps/dummy-payment/src/lib/logger/create-logger.ts @@ -0,0 +1,22 @@ +import packageJson from "../../../package.json"; +import { attachLoggerConsoleTransport } from "./logger-console-transport"; +import { createLogger, logger } from "./logger"; +import { attachLoggerSentryTransport } from "./logger-sentry-transport"; +import { attachLoggerVercelRuntimeTransport } from "@/lib/logger/logger-vercel-transport"; +import { loggerContext } from "@/logger-context"; + +logger.settings.maskValuesOfKeys = ["metadata", "username", "password", "apiKey"]; + +if (process.env.NODE_ENV !== "production") { + attachLoggerConsoleTransport(logger); +} + +if (typeof window === "undefined") { + attachLoggerSentryTransport(logger); + + if (process.env.NODE_ENV === "production") { + attachLoggerVercelRuntimeTransport(logger, packageJson.version, loggerContext); + } +} + +export { createLogger, logger }; diff --git a/apps/dummy-payment/src/lib/logger/logger-console-transport.ts b/apps/dummy-payment/src/lib/logger/logger-console-transport.ts new file mode 100644 index 000000000..e907a98b1 --- /dev/null +++ b/apps/dummy-payment/src/lib/logger/logger-console-transport.ts @@ -0,0 +1,20 @@ +import { ILogObj, ILogObjMeta, Logger } from "tslog"; + +export const attachLoggerConsoleTransport = (logger: Logger) => { + logger.attachTransport((log) => { + const { + message, + attributes, + _meta: { date, name, parentNames }, + } = log as ILogObj & + ILogObjMeta & { + message: string; + attributes: Record; + }; + + const formattedName = `${(parentNames ?? []).join(":")}:${name}`; + const formattedDate = date.toISOString(); + + console.log(`\x1b[2m ${formattedDate} ${formattedName}\x1b[0m \t${message}`, attributes); + }); +}; diff --git a/apps/dummy-payment/src/lib/logger/logger-context.ts b/apps/dummy-payment/src/lib/logger/logger-context.ts new file mode 100644 index 000000000..1a8230109 --- /dev/null +++ b/apps/dummy-payment/src/lib/logger/logger-context.ts @@ -0,0 +1,56 @@ +import { SALEOR_API_URL_HEADER, SALEOR_EVENT_HEADER } from "@saleor/app-sdk/headers"; +import { AsyncLocalStorage } from "async_hooks"; +import { NextApiHandler, NextApiRequest, NextApiResponse } from "next"; + +export class LoggerContext { + private als = new AsyncLocalStorage>(); + private project_name = process.env.OTEL_SERVICE_NAME as string | undefined; + + getRawContext() { + const store = this.als.getStore(); + + if (!store) { + if (!process.env.CI && process.env.OTEL_ENABLED === "true") { + console.warn( + "You cant use LoggerContext outside of the wrapped scope. Will fallback to {}" + ); + } + + return {}; + } + + return store; + } + + async wrap(fn: (...args: unknown[]) => unknown, initialState = {}) { + return this.als.run( + { + ...initialState, + project_name: this.project_name, + }, + fn + ); + } + + set(key: string, value: string | number | Record | null) { + const store = this.getRawContext(); + + store[key] = value; + } +} + +export const wrapWithLoggerContext = (handler: NextApiHandler, loggerContext: LoggerContext) => { + return (req: NextApiRequest, res: NextApiResponse) => { + return loggerContext.wrap(() => { + const saleorApiUrl = req.headers[SALEOR_API_URL_HEADER] as string; + const saleorEvent = req.headers[SALEOR_EVENT_HEADER] as string; + const path = req.url as string; + + loggerContext.set("path", path); + loggerContext.set("saleorApiUrl", saleorApiUrl ?? null); + loggerContext.set("saleorEvent", saleorEvent ?? null); + + return handler(req, res); + }); + }; +}; diff --git a/apps/dummy-payment/src/lib/logger/logger-sentry-transport.ts b/apps/dummy-payment/src/lib/logger/logger-sentry-transport.ts new file mode 100644 index 000000000..ecbb0f9b7 --- /dev/null +++ b/apps/dummy-payment/src/lib/logger/logger-sentry-transport.ts @@ -0,0 +1,62 @@ +import { ILogObj, Logger } from "tslog"; +import * as Sentry from "@sentry/nextjs"; +import { SeverityLevel } from "@sentry/nextjs"; + +const loggerLevelToSentryLevel = (level: string): SeverityLevel => { + switch (level) { + case "fatal": + case "error": + return "error"; + case "warn": + return "warning"; + case "silly": + case "debug": + case "trace": + return "debug"; + case "info": + return "info"; + } + + return "debug"; +}; + +const levelToBreadcrumbType = (level: string) => { + switch (level) { + case "error": + case "fatal": + return "error"; + case "debug": + case "trace": + case "silly": + return "debug"; + case "info": + default: + return "default"; + } +}; + +export const attachLoggerSentryTransport = (logger: Logger) => { + logger.attachTransport((log) => { + const { message, attributes, _meta, ...inheritedAttributes } = log as ILogObj & { + message: string; + attributes: Record; + }; + + if (!message || !attributes) { + console.error("Logger is not configured properly. Sentry transport will not be attached."); + + return; + } + + logger.attachTransport((log) => { + Sentry?.addBreadcrumb?.({ + message: message, + type: levelToBreadcrumbType(log._meta.logLevelName), + level: loggerLevelToSentryLevel(log._meta.logLevelName), + // @ts-ignore - Sentry only allows number type, but ISOString is valid + timestamp: log._meta.date.toISOString(), + data: attributes, + }); + }); + }); +}; diff --git a/apps/dummy-payment/src/lib/logger/logger-vercel-transport.ts b/apps/dummy-payment/src/lib/logger/logger-vercel-transport.ts new file mode 100644 index 000000000..793f2fc89 --- /dev/null +++ b/apps/dummy-payment/src/lib/logger/logger-vercel-transport.ts @@ -0,0 +1,82 @@ +import { trace } from "@opentelemetry/api"; +import * as Sentry from "@sentry/nextjs"; +import { ILogObj, Logger } from "tslog"; + +import { BaseError, UnknownError } from "@/errors"; +import { LoggerContext } from "./logger-context"; + +const VercelMaximumLogSizeExceededError = BaseError.subclass("VercelMaximumLogSizeExceededError"); + +function isLogExceedingVercelLimit(inputString: string): boolean { + const byteLength = new TextEncoder().encode(inputString).length; + + return byteLength > 4096; // Vercel serverless function log limit - 4KB +} + +export const attachLoggerVercelRuntimeTransport = ( + logger: Logger, + appVersion: string, + loggerContext?: LoggerContext +) => { + logger.attachTransport((log) => { + try { + const { message, attributes, _meta } = log; + + const bodyMessage = log._meta.name ? `[${log._meta.name}] ${message}` : message; + + const stringifiedMessage = JSON.stringify({ + message: bodyMessage, + ...(loggerContext?.getRawContext() ?? {}), + ...attributes, + deployment: { + environment: process.env.ENV, + }, + otel: { + span_id: trace.getActiveSpan()?.spanContext().spanId, + trace_id: trace.getActiveSpan()?.spanContext().traceId, + timestamp: _meta.date.getTime(), + }, + "commit-sha": process.env.VERCEL_GIT_COMMIT_SHA, + service: { + name: process.env.OTEL_SERVICE_NAME, + version: appVersion, + }, + _meta: { + ..._meta, + // used to filter out log in log drain + source: "saleor-app", + }, + }); + + if (isLogExceedingVercelLimit(stringifiedMessage)) { + Sentry.captureException( + new VercelMaximumLogSizeExceededError("Log message is exceeding Vercel limit", { + props: { + logName: log._meta.name, + logMessage: bodyMessage, + }, + }) + ); + } + + // Prints Vercel log in proper level https://vercel.com/docs/observability/runtime-logs#level + if (_meta.logLevelName === "ERROR") { + console.error(stringifiedMessage); + return; + } + + if (_meta.logLevelName === "WARN") { + console.warn(stringifiedMessage); + return; + } + + console.log(stringifiedMessage); + } catch (error) { + Sentry.captureException( + new UnknownError("Error during attaching Vercel transport", { + cause: error, + }) + ); + } + }); +}; diff --git a/apps/dummy-payment/src/lib/logger/logger.ts b/apps/dummy-payment/src/lib/logger/logger.ts new file mode 100644 index 000000000..2e0c5f0b5 --- /dev/null +++ b/apps/dummy-payment/src/lib/logger/logger.ts @@ -0,0 +1,62 @@ +import { ILogObj, ILogObjMeta, Logger } from "tslog"; + +function isObject(item: unknown) { + return typeof item === "object" && !Array.isArray(item) && item !== null; +} + +function getMinLevel() { + switch (process.env.APP_LOG_LEVEL) { + case "silent": + return 0; + case "trace": + return 1; + case "debug": + return 2; + case "info": + return 3; + case "warn": + return 4; + case "error": + return 5; + case "fatal": + return 6; + default: + return 3; + } +} + +export const logger = new Logger({ + minLevel: getMinLevel(), + hideLogPositionForProduction: true, + /** + * Use custom console.log transport, because built-in API for pretty logger is limited + */ + type: "hidden", + overwrite: { + /** + * Format log. Use parent logger (createLogger) args and merge them with args from individual logs + */ + toLogObj(args, log) { + const message = args.find((arg) => typeof arg === "string"); + const attributesFromLog = (args.find(isObject) as Object) ?? {}; + const parentAttributes = log ?? {}; + + return { + ...log, + message, + attributes: { + ...parentAttributes, + ...attributesFromLog, + }, + }; + }, + }, +}); + +export const createLogger = (name: string, params?: Record) => + logger.getSubLogger( + { + name: name, + }, + params + ); diff --git a/apps/dummy-payment/src/lib/no-ssr-wrapper.tsx b/apps/dummy-payment/src/lib/no-ssr-wrapper.tsx new file mode 100644 index 000000000..f1954399c --- /dev/null +++ b/apps/dummy-payment/src/lib/no-ssr-wrapper.tsx @@ -0,0 +1,19 @@ +import React, { PropsWithChildren } from "react"; +import dynamic from "next/dynamic"; + +const Wrapper = (props: PropsWithChildren<{}>) => {props.children}; + +/** + * 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, +}); diff --git a/apps/dummy-payment/src/lib/otel/get-attributes-from-request.ts b/apps/dummy-payment/src/lib/otel/get-attributes-from-request.ts new file mode 100644 index 000000000..d3fc6b12d --- /dev/null +++ b/apps/dummy-payment/src/lib/otel/get-attributes-from-request.ts @@ -0,0 +1,33 @@ +import { NextApiRequest } from "next"; +import { SemanticAttributes } from "@opentelemetry/semantic-conventions"; +import { SALEOR_API_URL_HEADER } from "@saleor/app-sdk/headers"; + +const pruneEmptyKeys = (obj: Record): Record => { + const clonedObj = { ...obj }; + + Object.keys(clonedObj).forEach((key) => { + const value = obj[key]; + + if (value === undefined || value === null || value === "") { + obj[key] === undefined && delete obj[key]; + } + }); + + return clonedObj as Record; +}; + +export const getAttributesFromRequest = (request: NextApiRequest) => { + const attributes = { + [SemanticAttributes.FAAS_EXECUTION]: request.headers["x-vercel-proxy-signature-ts"] as string, + [SemanticAttributes.HTTP_USER_AGENT]: request.headers["user-agent"] as string, + [SemanticAttributes.HTTP_TARGET]: request.headers.referer as string, + [SemanticAttributes.NET_HOST_NAME]: request.headers.host as string, + [SemanticAttributes.HTTP_METHOD]: (request.method ?? "").toUpperCase(), + saleorApiUrl: request.headers[SALEOR_API_URL_HEADER] as string, + "url.path": request.url, + vercelRequestId: request.headers["x-vercel-id"], + requestId: request.headers["x-vercel-proxy-signature-ts"] as string, + } as const ; + + return pruneEmptyKeys(attributes); +}; diff --git a/apps/dummy-payment/src/lib/otel/instrumentation.ts b/apps/dummy-payment/src/lib/otel/instrumentation.ts new file mode 100644 index 000000000..916ab283c --- /dev/null +++ b/apps/dummy-payment/src/lib/otel/instrumentation.ts @@ -0,0 +1,82 @@ +import { DiagConsoleLogger, DiagLogLevel, SpanStatusCode, diag } from "@opentelemetry/api"; +import { W3CTraceContextPropagator } from "@opentelemetry/core"; +import { HttpInstrumentation } from "@opentelemetry/instrumentation-http"; +import { Resource } from "@opentelemetry/resources"; +import { NodeSDK } from "@opentelemetry/sdk-node"; +import { + SemanticAttributes, + SemanticResourceAttributes, +} from "@opentelemetry/semantic-conventions"; +import { type ClientRequest } from "node:http"; +import { otelLogsProcessor } from "./otel-logs-setup"; +import { batchSpanProcessor } from "./otel-traces-setup"; + +if (process.env.ENABLE_OTEL_RUNTIME_LOGS === "true") { + const getLogLevel = () => { + switch (process.env.OTEL_LOG_LEVEL) { + case "debug": + return DiagLogLevel.DEBUG; + case "error": + return DiagLogLevel.ERROR; + case "warn": + return DiagLogLevel.WARN; + case "verbose": + return DiagLogLevel.VERBOSE; + case "all": + return DiagLogLevel.ALL; + case "none": + return DiagLogLevel.NONE; + case "info": + default: + return DiagLogLevel.INFO; + } + }; + + diag.setLogger(new DiagConsoleLogger(), getLogLevel()); +} + +export const otelSdk = new NodeSDK({ + resource: new Resource({ + [SemanticResourceAttributes.SERVICE_NAME]: process.env.OTEL_SERVICE_NAME, + "commit-sha": process.env.VERCEL_GIT_COMMIT_SHA, + [SemanticResourceAttributes.DEPLOYMENT_ENVIRONMENT]: process.env.ENV, + }), + spanProcessor: batchSpanProcessor, + logRecordProcessor: otelLogsProcessor, + textMapPropagator: new W3CTraceContextPropagator(), + instrumentations: [ + new HttpInstrumentation({ + requireParentforIncomingSpans: true, + requireParentforOutgoingSpans: true, + /** + * HTTP spans are creates as entry spans/siblings, instead of children. + * TODO Fix this. + */ + applyCustomAttributesOnSpan: (span, req, response) => { + span.setAttribute(SemanticAttributes.HTTP_ROUTE, (req as ClientRequest)?.path); + span.setAttribute(SemanticAttributes.HTTP_HOST, (req as ClientRequest)?.host); + + if (response.statusCode) { + span.setAttribute(SemanticAttributes.HTTP_STATUS_CODE, response.statusCode); + } + + if (response.statusCode && response.statusCode >= 400) { + span.setStatus({ + code: SpanStatusCode.ERROR, + }); + } + + if (response.statusCode && response.statusCode >= 200 && response.statusCode < 400) { + span.setStatus({ + code: SpanStatusCode.OK, + }); + } + }, + + ignoreOutgoingUrls: [ + (url) => url.includes("ingest.sentry.io"), + (url) => url.includes("/v1/logs"), + ], + }), + ], +}); diff --git a/apps/dummy-payment/src/lib/otel/lib/observability-attributes.ts b/apps/dummy-payment/src/lib/otel/lib/observability-attributes.ts new file mode 100644 index 000000000..e4617bcc3 --- /dev/null +++ b/apps/dummy-payment/src/lib/otel/lib/observability-attributes.ts @@ -0,0 +1,14 @@ +export const ObservabilityAttributes = { + SALEOR_API_URL: "saleorApiUrl", + SALEOR_VERSION: "saleorVersion", + CHANNEL_SLUG: "channelSlug", + TRANSACTION_ID: "transactionId", +} as const; + +export enum GraphQLAttributeNames { + OPERATION_TYPE = "graphql.operation.type", + OPERATION_NAME = "graphql.operation.name", + OPERATION_BODY = "graphql.operation.body", + OPERATION_KEY = "graphql.operation.key", + VARIABLES = "graphql.variables.", +} diff --git a/apps/dummy-payment/src/lib/otel/lib/race.ts b/apps/dummy-payment/src/lib/otel/lib/race.ts new file mode 100644 index 000000000..6e88f70a1 --- /dev/null +++ b/apps/dummy-payment/src/lib/otel/lib/race.ts @@ -0,0 +1,24 @@ +export function race({ + promise, + timeout, + error, +}: { + promise: Promise; + timeout: number; + error: Error; +}): Promise { + let timer: NodeJS.Timeout | null = null; + + return Promise.race([ + new Promise((res, rej) => { + timer = setTimeout(() => { + rej(error); + }, timeout); + }), + promise.finally(() => { + if (timer) { + clearTimeout(timer); + } + }), + ]); +} diff --git a/apps/dummy-payment/src/lib/otel/otel-exchange.ts b/apps/dummy-payment/src/lib/otel/otel-exchange.ts new file mode 100644 index 000000000..8bae1d92e --- /dev/null +++ b/apps/dummy-payment/src/lib/otel/otel-exchange.ts @@ -0,0 +1,77 @@ +import { type Span, SpanKind, SpanStatusCode, context } from "@opentelemetry/api"; +import { type CombinedError, type Operation, makeOperation, mapExchange } from "urql"; +import { getOtelTracer } from "./otel-tracer"; +import { GraphQLAttributeNames, ObservabilityAttributes } from "./lib/observability-attributes"; +import { addInputVariableAttributes, addRequestHeaderAttributes } from "./otel-graphql-utils"; +import { SemanticAttributes } from "@opentelemetry/semantic-conventions"; + +type Definition = { + name: { + value: string; + }; +}; + +interface ExtendedFetchOptions extends RequestInit { + headers: Record; +} + +type ExtendedOperationContext = Operation["context"] & { + span: Span; + fetchOptions?: ExtendedFetchOptions; +}; + +interface ExtendedOperation extends Operation { + context: ExtendedOperationContext; +} + +export const otelExchange = mapExchange({ + onOperation(operation: ExtendedOperation) { + const span = getOtelTracer().startSpan( + "graphql-request", + { + kind: SpanKind.CLIENT, + }, + context.active(), + ); + + span.setAttribute( + GraphQLAttributeNames.OPERATION_NAME, + `${(operation.query.definitions[0] as Definition).name.value ?? "unknown"}`, + ); + + span.setAttribute(GraphQLAttributeNames.OPERATION_TYPE, operation.kind); + + span.setAttribute( + GraphQLAttributeNames.OPERATION_BODY, + operation.query.loc?.source.body ?? "unknown", + ); + + span.setAttribute(GraphQLAttributeNames.OPERATION_KEY, operation.key); + + span.setAttribute(ObservabilityAttributes.SALEOR_API_URL, operation.context.url); + + span.setAttribute(SemanticAttributes.HTTP_URL, operation.context.url); + + addRequestHeaderAttributes(span, operation.context.fetchOptions?.headers); + if (operation.variables) { + addInputVariableAttributes(span, operation.variables); + } + + return makeOperation(operation.kind, operation, { + ...operation.context, + span, + }); + }, + // @ts-expect-error - small hack, we're extending `operation` with `span` + onResult({ error, operation }: { operation: ExtendedOperation; error?: CombinedError }) { + const span = operation.context.span; + + if (error) { + span.recordException(error); + } + + span.setStatus({ code: error ? SpanStatusCode.ERROR : SpanStatusCode.OK }); + + span.end(); + }, +}); diff --git a/apps/dummy-payment/src/lib/otel/otel-graphql-utils.ts b/apps/dummy-payment/src/lib/otel/otel-graphql-utils.ts new file mode 100644 index 000000000..37c94ab95 --- /dev/null +++ b/apps/dummy-payment/src/lib/otel/otel-graphql-utils.ts @@ -0,0 +1,43 @@ +import { type Span } from "@opentelemetry/api"; +import { GraphQLAttributeNames } from "./lib/observability-attributes"; + +export const addRequestHeaderAttributes = ( + span: Span, + headers?: Record, +) => { + if (!headers) return; + + Object.entries(headers).forEach(([key, value]) => { + if (key.toLowerCase().includes("authorization")) { + span.setAttribute(`http.request.header.${key}`, "(redacted)"); + + return; + } + + if (Array.isArray(value)) { + span.setAttribute(`http.request.header.${key}`, value.join(", ")); + } else { + span.setAttribute(`http.request.header.${key}`, String(value)); + } + }); +}; + +const addInputVariableAttribute = (span: Span, key: string, variable: any) => { + if (Array.isArray(variable)) { + variable.forEach((value, idx) => { + addInputVariableAttribute(span, `${key}.${idx}`, value); + }); + } else if (variable instanceof Object) { + Object.entries(variable).forEach(([nestedKey, value]) => { + addInputVariableAttribute(span, `${key}.${nestedKey}`, value); + }); + } else { + span.setAttribute(`${GraphQLAttributeNames.VARIABLES}${String(key)}`, variable); + } +}; + +export const addInputVariableAttributes = (span: Span, variableValues: { [key: string]: any }) => { + Object.entries(variableValues).forEach(([key, value]) => { + addInputVariableAttribute(span, key, value); + }); +}; diff --git a/apps/dummy-payment/src/lib/otel/otel-logs-setup.ts b/apps/dummy-payment/src/lib/otel/otel-logs-setup.ts new file mode 100644 index 000000000..7685cfb3c --- /dev/null +++ b/apps/dummy-payment/src/lib/otel/otel-logs-setup.ts @@ -0,0 +1,32 @@ +import { logs } from "@opentelemetry/api-logs"; +import { BatchLogRecordProcessor, LoggerProvider } from "@opentelemetry/sdk-logs"; +import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http"; +import { + detectResourcesSync, + envDetectorSync, + hostDetectorSync, + osDetectorSync, + processDetector, +} from "@opentelemetry/resources"; +import { sharedOtelConfig } from "./shared-config"; + +const batchLogRecordProcessor = new BatchLogRecordProcessor( + new OTLPLogExporter({ + headers: sharedOtelConfig.exporterHeaders, + }), + sharedOtelConfig.batchProcessorConfig, +); + +export const otelLogsProcessor = batchLogRecordProcessor; + +const detectedResource = detectResourcesSync({ + detectors: [envDetectorSync, hostDetectorSync, osDetectorSync, processDetector], +}); + +export const loggerProvider = new LoggerProvider({ + resource: detectedResource, + forceFlushTimeoutMillis: sharedOtelConfig.flushTimeout, +}); + +loggerProvider.addLogRecordProcessor(otelLogsProcessor); +logs.setGlobalLoggerProvider(loggerProvider); diff --git a/apps/dummy-payment/src/lib/otel/otel-tracer.ts b/apps/dummy-payment/src/lib/otel/otel-tracer.ts new file mode 100644 index 000000000..2cfbc0184 --- /dev/null +++ b/apps/dummy-payment/src/lib/otel/otel-tracer.ts @@ -0,0 +1,5 @@ +import { trace } from "@opentelemetry/api"; + +const ROOT_TRACE_NAME = "app-api-handler"; + +export const getOtelTracer = () => trace.getTracer(ROOT_TRACE_NAME); diff --git a/apps/dummy-payment/src/lib/otel/otel-traces-setup.ts b/apps/dummy-payment/src/lib/otel/otel-traces-setup.ts new file mode 100644 index 000000000..dfd64d9af --- /dev/null +++ b/apps/dummy-payment/src/lib/otel/otel-traces-setup.ts @@ -0,0 +1,30 @@ +import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"; +import { BatchSpanProcessor, ReadableSpan } from "@opentelemetry/sdk-trace-base"; +import { sharedOtelConfig } from "./shared-config"; + +class CustomSpanProcessor extends BatchSpanProcessor { + onEnd(span: ReadableSpan): void { + /** + * Next.js has a bug and API functions don't propagate proper name. + * https://github.com/vercel/next.js/blob/faa44210340d2ef19da6252e40a9b3e66f214637/packages/next/src/server/base-server.ts#L821 + * + * Filter them out - they duplicate child span that have similar attributes. + * + * TODO: Verify with latest next.js versions + */ + const isBrokenNextSpan = + span.instrumentationLibrary.name === "next.js" && !span.attributes["next.route"]; + + if (!isBrokenNextSpan) { + super.onEnd(span); + } + } +} + +export const batchSpanProcessor = new CustomSpanProcessor( + new OTLPTraceExporter({ + headers: sharedOtelConfig.exporterHeaders, + timeoutMillis: sharedOtelConfig.flushTimeout, + }), + sharedOtelConfig.batchProcessorConfig, +); diff --git a/apps/dummy-payment/src/lib/otel/otel-wrapper.ts b/apps/dummy-payment/src/lib/otel/otel-wrapper.ts new file mode 100644 index 000000000..4605b3778 --- /dev/null +++ b/apps/dummy-payment/src/lib/otel/otel-wrapper.ts @@ -0,0 +1,126 @@ +import { SpanKind, SpanStatusCode, type Span } from "@opentelemetry/api"; +import { SemanticAttributes } from "@opentelemetry/semantic-conventions"; +import { type NextApiHandler, type NextApiRequest, type NextApiResponse } from "next"; +import { otelSdk } from "./instrumentation"; + +import { race } from "./lib/race"; +import { getOtelTracer } from "./otel-tracer"; + +import { getAttributesFromRequest } from "./get-attributes-from-request"; +import { loggerProvider, otelLogsProcessor } from "./otel-logs-setup"; +import { batchSpanProcessor } from "./otel-traces-setup"; +import { sharedOtelConfig } from "./shared-config"; + +const tracer = getOtelTracer(); + +if (process.env.OTEL_ENABLED === "true" && process.env.OTEL_SERVICE_NAME) { + otelSdk.start(); +} + +const OTEL_FLUSH_TIMEOUT = sharedOtelConfig.flushTimeout; + +const flushOtel = async () => { + await race({ + promise: loggerProvider.forceFlush(), + error: new Error("Timeout flushing OTEL logs from provider"), + timeout: OTEL_FLUSH_TIMEOUT, + }); + + await race({ + promise: Promise.all([batchSpanProcessor.forceFlush(), otelLogsProcessor.forceFlush()]), + error: new Error("Timeout flushing OTEL items from processors"), + timeout: OTEL_FLUSH_TIMEOUT, + }); +}; + +/** + * TODO: Consider injecting into Next.js config, to automatically wrap routes and infer static route name from file name + */ +export const withOtel = (handler: NextApiHandler, staticRouteName: string): NextApiHandler => { + if (process.env.OTEL_ENABLED !== "true") { + return handler; + } + + return new Proxy(handler, { + apply: async ( + wrappingTarget, + thisArg, + args: [NextApiRequest | undefined, NextApiResponse | undefined] + ) => { + const [req, res] = args; + + if (!req || !res) { + console.warn("No request and/or response objects found, OTEL is not set-up"); + + // @ts-expect-error runtime check + return wrappingTarget.apply(thisArg, args); + } + + const attributesFromRequest = getAttributesFromRequest(req); + + return tracer.startActiveSpan( + `${attributesFromRequest[SemanticAttributes.HTTP_METHOD]} ${staticRouteName}`, + { + kind: SpanKind.SERVER, + attributes: attributesFromRequest, + }, + async (span) => { + span.setAttribute(SemanticAttributes.HTTP_ROUTE, staticRouteName); + + const originalResEnd = res.end; + + /** + * Override native res.end to flush OTEL traces before it ends + */ + // @ts-expect-error - this is a hack to get around Vercel freezing lambda's + res.end = async function (this: unknown, ...args: unknown[]) { + span.setAttribute(SemanticAttributes.HTTP_STATUS_CODE, res.statusCode); + span.end(); + + try { + await flushOtel(); + } catch (e) { + console.error("Failed to flush OTEL", { error: e }); + // noop - don't block return even if we loose traces + } + + // @ts-expect-error passthrough args to the original function + return originalResEnd.apply(this, args); + }; + + try { + // return await loggerContext.wrap(() => wrappingTarget.apply(thisArg, [req, res])); + + wrappingTarget.apply(thisArg, [req, res]); + } catch (error) { + span.setAttribute(SemanticAttributes.HTTP_STATUS_CODE, 500); + + setErrorOnSpan(error, span); + + span.end(); + + try { + await flushOtel(); + } catch (e) { + console.error("Failed to flush OTEL", { error: e }); + } + + /** + * Rethrow error so that Next.js (and other wrappers like Sentry) can handle it + */ + throw error; + } + } + ); + }, + }); +}; + +export function setErrorOnSpan(error: unknown, span: Span) { + span.setStatus({ code: SpanStatusCode.ERROR }); + + if (error instanceof Error) { + span.setAttribute("error.type", error.name); + span.recordException(error); + } +} diff --git a/apps/dummy-payment/src/lib/otel/shared-config.ts b/apps/dummy-payment/src/lib/otel/shared-config.ts new file mode 100644 index 000000000..f7054d6ae --- /dev/null +++ b/apps/dummy-payment/src/lib/otel/shared-config.ts @@ -0,0 +1,27 @@ +import { BufferConfig } from "@opentelemetry/sdk-logs"; + +const FLUSH_TIMEOUT = 1_000; + +const batchProcessorConfig: BufferConfig = { + exportTimeoutMillis: FLUSH_TIMEOUT, + maxExportBatchSize: 1024, + maxQueueSize: 1024, + /** + * Long delay that will be for sure longer than the lambda timeout. + * Avoid mid-execution flushes, because we flush them manually in the end. + */ + scheduledDelayMillis: 2 * 5 * 60 * 1000, +}; + +export const sharedOtelConfig = { + flushTimeout: FLUSH_TIMEOUT, + batchProcessorConfig: batchProcessorConfig, + exporterHeaders: { + /** + * This is the token that is used to authenticate with the OTEL collector set up in the Saleor infrastructure. + * + * In case of forked usage, leave this field empty, but protecting collector is recommended. + */ + "x-alb-access-token": process.env.OTEL_ACCESS_TOKEN, + }, +} as const; diff --git a/apps/dummy-payment/src/lib/theme-synchronizer.tsx b/apps/dummy-payment/src/lib/theme-synchronizer.tsx new file mode 100644 index 000000000..339440cb3 --- /dev/null +++ b/apps/dummy-payment/src/lib/theme-synchronizer.tsx @@ -0,0 +1,24 @@ +import { useAppBridge } from "@saleor/app-sdk/app-bridge"; +import { useTheme } from "@saleor/macaw-ui"; +import { useEffect } from "react"; + +export function ThemeSynchronizer() { + const { appBridgeState } = useAppBridge(); + const { setTheme } = useTheme(); + + useEffect(() => { + if (!setTheme || !appBridgeState?.theme) { + return; + } + + if (appBridgeState.theme === "light") { + setTheme("defaultLight"); + } + + if (appBridgeState.theme === "dark") { + setTheme("defaultDark"); + } + }, [appBridgeState?.theme, setTheme]); + + return null; +} diff --git a/apps/dummy-payment/src/lib/transaction-actions.ts b/apps/dummy-payment/src/lib/transaction-actions.ts new file mode 100644 index 000000000..8c549e8fe --- /dev/null +++ b/apps/dummy-payment/src/lib/transaction-actions.ts @@ -0,0 +1,34 @@ +import { TransactionEventTypeEnum } from "../../generated/graphql"; + +export type TransactionEventType = `${TransactionEventTypeEnum}`; + +export function getTransactionActions( + type: TransactionEventType +): Array<"REFUND" | "CHARGE" | "CANCEL"> { + switch (type) { + case TransactionEventTypeEnum.Info: + case TransactionEventTypeEnum.ChargeBack: + case TransactionEventTypeEnum.ChargeFailure: + return ["REFUND", "CHARGE", "CANCEL"]; + case TransactionEventTypeEnum.AuthorizationAdjustment: + case TransactionEventTypeEnum.AuthorizationFailure: + case TransactionEventTypeEnum.AuthorizationRequest: + case TransactionEventTypeEnum.AuthorizationSuccess: + case TransactionEventTypeEnum.CancelFailure: + case TransactionEventTypeEnum.CancelRequest: + return ["CHARGE", "CANCEL"]; + case TransactionEventTypeEnum.ChargeRequest: + case TransactionEventTypeEnum.ChargeSuccess: + case TransactionEventTypeEnum.RefundFailure: + case TransactionEventTypeEnum.RefundRequest: + case TransactionEventTypeEnum.RefundSuccess: + return ["REFUND"]; + case TransactionEventTypeEnum.CancelSuccess: + case TransactionEventTypeEnum.ChargeActionRequired: + case TransactionEventTypeEnum.AuthorizationActionRequired: + case TransactionEventTypeEnum.RefundReverse: + return []; + default: + return []; + } +} diff --git a/apps/dummy-payment/src/lib/zod-error.ts b/apps/dummy-payment/src/lib/zod-error.ts new file mode 100644 index 000000000..c40eb0b2b --- /dev/null +++ b/apps/dummy-payment/src/lib/zod-error.ts @@ -0,0 +1,21 @@ +import { ZodError } from "zod"; + +export function getZodErrorMessage(error: ZodError): string { + const formattedError = error.format(); + + function formatError(obj: any, path: string = ""): string[] { + return Object.entries(obj).flatMap(([key, value]) => { + if (key === "_errors") { + return value as string[]; + } + if (typeof value === "object" && value !== null) { + const newPath = path ? `${path}.${key}` : key; + return formatError(value, newPath); + } + return []; + }); + } + + const errorMessages = formatError(formattedError); + return errorMessages.join(". "); +} diff --git a/apps/dummy-payment/src/logger-context.ts b/apps/dummy-payment/src/logger-context.ts new file mode 100644 index 000000000..965add852 --- /dev/null +++ b/apps/dummy-payment/src/logger-context.ts @@ -0,0 +1,6 @@ +import { LoggerContext } from "./lib/logger/logger-context"; + +/** + * Server-side only + */ +export const loggerContext = new LoggerContext(); diff --git a/apps/dummy-payment/src/modules/configuration/app-config.ts b/apps/dummy-payment/src/modules/configuration/app-config.ts new file mode 100644 index 000000000..881553ffc --- /dev/null +++ b/apps/dummy-payment/src/modules/configuration/app-config.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; + +export const appConfigSchema = z.object({ + refundConfig: z.enum(["SYNC_SUCESS", "ASYNC_SUCCESS", "ASYNC_FAILURE", "SYNC_FAILURE"]), + chargeConfig: z.enum(["SYNC_SUCESS", "ASYNC_SUCCESS", "ASYNC_FAILURE", "SYNC_FAILURE"]), + cancelConfig: z.enum(["SYNC_SUCESS", "ASYNC_SUCCESS", "ASYNC_FAILURE", "SYNC_FAILURE"]), + // TODO: Add implementation for ASYNC events via event queue + asyncActionConfig: z.object({ + delay: z.number().int().positive().default(30), + }), + // TODO: Add implementation for sync events delay via wait() + syncActionConfig: z.object({ + delay: z.number().int().positive().default(0), + }), +}); diff --git a/apps/dummy-payment/src/modules/transaction/transaction-psp-finder.ts b/apps/dummy-payment/src/modules/transaction/transaction-psp-finder.ts new file mode 100644 index 000000000..6d7f64853 --- /dev/null +++ b/apps/dummy-payment/src/modules/transaction/transaction-psp-finder.ts @@ -0,0 +1,15 @@ +import { SyncWebhookTransactionFragment } from "@/generated/graphql"; + +export class TransactionPspFinder { + findLastPspReference(transaction: Pick): string | null { + const events = transaction.events ?? []; + + const event = events.find((event) => !!event.pspReference); + + if (event) { + return event.pspReference; + } + + return null; + } +} diff --git a/apps/dummy-payment/src/modules/transaction/transaction-refund-checker.ts b/apps/dummy-payment/src/modules/transaction/transaction-refund-checker.ts new file mode 100644 index 000000000..c31f6b6a6 --- /dev/null +++ b/apps/dummy-payment/src/modules/transaction/transaction-refund-checker.ts @@ -0,0 +1,6 @@ +export class TransactionRefundChecker { + checkIfAnotherRefundIsPossible(requestedAmount: number, chargedAmount: { amount: number } | undefined) { + if (!chargedAmount) return true; + return requestedAmount <= chargedAmount.amount; + } +} diff --git a/apps/dummy-payment/src/modules/url/app-url-generator.ts b/apps/dummy-payment/src/modules/url/app-url-generator.ts new file mode 100644 index 000000000..9088ca1dc --- /dev/null +++ b/apps/dummy-payment/src/modules/url/app-url-generator.ts @@ -0,0 +1,19 @@ +import { AuthData } from "@saleor/app-sdk/APL"; + +export class AppUrlGenerator { + constructor(private authData: Pick) {} + + private getAppBaseUrlRelative(appId: string) { + return `/dashboard/apps/${encodeURIComponent(appId)}/app/app`; + } + + private getAppBaseUrlAbsolute(appId: string, saleorApiUrl: string) { + const saleorDashboardUrl = saleorApiUrl.replace("/graphql/", ""); + return `${saleorDashboardUrl}${this.getAppBaseUrlRelative(appId)}`; + } + + getTransactionDetailsUrl(transactionId: string) { + const baseUrl = this.getAppBaseUrlAbsolute(this.authData.appId, this.authData.saleorApiUrl); + return `${baseUrl}/transactions/${transactionId}`; + } +} diff --git a/apps/dummy-payment/src/modules/validation/cancel-webhook.ts b/apps/dummy-payment/src/modules/validation/cancel-webhook.ts new file mode 100644 index 000000000..43f2cbb01 --- /dev/null +++ b/apps/dummy-payment/src/modules/validation/cancel-webhook.ts @@ -0,0 +1,40 @@ +import { z } from "zod"; +import { transactionActionsSchema } from "./common"; + +export const cancelationRequestedInputSchema = z + .object({ + transaction: z.object({ + id: z.string(), + authorizedAmount: z.object({ + amount: z + .number() + .positive("Transaction cannot be charged when authorizedAmount is negative") + .refine((n) => n > 0, { + message: "Transaction cannot be cancelled when there is no authorizedAmount", + }), + currency: z.string(), + }), + }), + }) + .passthrough(); + +export const cancelationRequestedSyncResponseSchema = z.object({ + pspReference: z.string(), +}); + +export const cancelationRequestedAsyncResponseSchema = z.object({ + pspReference: z.string(), + result: z.enum(["CANCEL_SUCCESS", "CANCEL_FAILURE"]), + amount: z.number(), + time: z.string().optional(), + externalUrl: z.string().url().optional(), + message: z.string().optional(), + actions: transactionActionsSchema, +}); + +export const cancelationRequestedResponseSchema = z.union([ + cancelationRequestedSyncResponseSchema, + cancelationRequestedAsyncResponseSchema, +]); + +export type CancelationRequestedResponse = z.infer; diff --git a/apps/dummy-payment/src/modules/validation/charge-webhook.ts b/apps/dummy-payment/src/modules/validation/charge-webhook.ts new file mode 100644 index 000000000..2e6b8ba48 --- /dev/null +++ b/apps/dummy-payment/src/modules/validation/charge-webhook.ts @@ -0,0 +1,51 @@ +import { z } from "zod"; +import { transactionActionsSchema } from "./common"; + +export const chargeRequestedInputSchema = z + .object({ + action: z.object({ + amount: z + .number({ + required_error: "Charge amount cannot be missing", + invalid_type_error: "Charge amount is not a valid number", + }) + .positive("Charge amount cannot be negative") + .refine((n) => n > 0, { + message: "Charge amount must be greater than zero", + }), + }), + transaction: z.object({ + id: z.string(), + authorizedAmount: z.object({ + amount: z + .number() + .positive("Transaction cannot be charged when authorizedAmount is negative") + .refine((n) => n > 0, { + message: "Transaction cannot be charged when there is no authorizedAmount", + }), + currency: z.string(), + }), + }), + }) + .passthrough(); + +export const chargeRequestedSyncResponseSchema = z.object({ + pspReference: z.string(), +}); + +export const chargeRequestedAsyncResponseSchema = z.object({ + pspReference: z.string(), + result: z.enum(["CHARGE_SUCCESS", "CHARGE_FAILURE"]), + amount: z.number(), + time: z.string().optional(), + externalUrl: z.string().url().optional(), + message: z.string().optional(), + actions: transactionActionsSchema, +}); + +export const chargeRequestedResponseSchema = z.union([ + chargeRequestedSyncResponseSchema, + chargeRequestedAsyncResponseSchema, +]); + +export type ChargeRequestedResponse = z.infer; diff --git a/apps/dummy-payment/src/modules/validation/common.ts b/apps/dummy-payment/src/modules/validation/common.ts new file mode 100644 index 000000000..095147150 --- /dev/null +++ b/apps/dummy-payment/src/modules/validation/common.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +export const transactionEventTypeSchema = z.enum([ + "CHARGE_REQUEST", + "CHARGE_ACTION_REQUIRED", + "CHARGE_FAILURE", + "CHARGE_SUCCESS", + "AUTHORIZATION_REQUEST", + "AUTHORIZATION_ACTION_REQUIRED", + "AUTHORIZATION_FAILURE", + "AUTHORIZATION_SUCCESS", +]); + +export type TransactionEventType = z.infer; + +export const transactionActionsSchema = z.array( + z.union([z.literal("CHARGE"), z.literal("REFUND"), z.literal("CANCEL")]) +); diff --git a/apps/dummy-payment/src/modules/validation/refund-webhook.ts b/apps/dummy-payment/src/modules/validation/refund-webhook.ts new file mode 100644 index 000000000..84a3eea57 --- /dev/null +++ b/apps/dummy-payment/src/modules/validation/refund-webhook.ts @@ -0,0 +1,51 @@ +import { z } from "zod"; +import { transactionActionsSchema } from "./common"; + +export const refundRequestedInputSchema = z + .object({ + action: z.object({ + amount: z + .number({ + required_error: "Charge amount cannot be missing", + invalid_type_error: "Charge amount is not a valid number", + }) + .positive("Charge amount cannot be negative") + .refine((n) => n > 0, { + message: "Charge amount must be greater than zero", + }), + }), + transaction: z.object({ + id: z.string(), + chargedAmount: z.object({ + amount: z + .number() + .positive("Transaction cannot be refunded when chargedAmount is negative") + .refine((n) => n > 0, { + message: "Transaction cannot be refunded when there is no chargedAmount", + }), + currency: z.string(), + }), + }), + }) + .passthrough(); + +export const refundRequestedSyncResponseSchema = z.object({ + pspReference: z.string(), +}); + +export const refundRequestedAsyncResponseSchema = z.object({ + pspReference: z.string(), + result: z.enum(["REFUND_SUCCESS", "REFUND_FAILURE"]), + amount: z.number(), + time: z.string().optional(), + externalUrl: z.string().url().optional(), + message: z.string().optional(), + actions: transactionActionsSchema, +}); + +export const refundRequestedResponseSchema = z.union([ + refundRequestedSyncResponseSchema, + refundRequestedAsyncResponseSchema, +]); + +export type RefundRequestedResponse = z.infer; diff --git a/apps/dummy-payment/src/modules/validation/sync-transaction.ts b/apps/dummy-payment/src/modules/validation/sync-transaction.ts new file mode 100644 index 000000000..844249825 --- /dev/null +++ b/apps/dummy-payment/src/modules/validation/sync-transaction.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; +import { transactionActionsSchema, transactionEventTypeSchema } from "./common"; + +export const dataSchema = z.object({ + event: z.object({ + type: transactionEventTypeSchema, + includePspReference: z.boolean().optional().default(true), + }), +}); + +export type SyncWebhookRequestData = z.infer; diff --git a/apps/dummy-payment/src/order-example.tsx b/apps/dummy-payment/src/order-example.tsx new file mode 100644 index 000000000..e8aaa9143 --- /dev/null +++ b/apps/dummy-payment/src/order-example.tsx @@ -0,0 +1,117 @@ +import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge"; +import { Box, Text } from "@saleor/macaw-ui"; +import gql from "graphql-tag"; +import Link from "next/link"; +import { useLastOrderQuery } from "../generated/graphql"; + +/** + * GraphQL Code Generator scans for gql tags and generates types based on them. + * The below query is used to generate the "useLastOrderQuery" hook. + * If you modify it, make sure to run "pnpm codegen" to regenerate the types. + */ +gql` + query LastOrder { + orders(first: 1) { + edges { + node { + id + number + created + user { + firstName + lastName + } + shippingAddress { + country { + country + } + } + total { + gross { + amount + currency + } + } + lines { + id + } + } + } + } + } +`; + +function generateNumberOfLinesText(lines: any[]) { + if (lines.length === 0) { + return "no lines"; + } + + if (lines.length === 1) { + return "1 line"; + } + + return `${lines.length} lines`; +} + +export const OrderExample = () => { + const { appBridge } = useAppBridge(); + + // Using the generated hook + const [{ data, fetching }] = useLastOrderQuery(); + const lastOrder = data?.orders?.edges[0]?.node; + + const navigateToOrder = (id: string) => { + appBridge?.dispatch( + actions.Redirect({ + to: `/orders/${id}`, + }) + ); + }; + + return ( + + + Fetching data + + + <> + {fetching && Fetching the last order...} + {lastOrder && ( + <> + + ❗ The orders query requires the MANAGE_ORDERS permission. + If you want to query other resources, make sure to update the app permissions in the{" "} + /src/pages/api/manifest.ts file. + + + {`The last order #${lastOrder.number}:`} +
    +
  • + {`Contains ${generateNumberOfLinesText(lastOrder.lines)} 🛒`} +
  • +
  • + {`For a total amount of ${lastOrder.total.gross.amount} ${lastOrder.total.gross.currency} 💸`} +
  • +
  • + {`Ships to ${lastOrder.shippingAddress?.country.country} 📦`} +
  • +
+ navigateToOrder(lastOrder.id)} href={`/orders/${lastOrder.id}`}> + See the order details → + +
+ + )} + {!fetching && !lastOrder && No orders found} + +
+ ); +}; diff --git a/apps/dummy-payment/src/pages/_app.tsx b/apps/dummy-payment/src/pages/_app.tsx new file mode 100644 index 000000000..28ae69f30 --- /dev/null +++ b/apps/dummy-payment/src/pages/_app.tsx @@ -0,0 +1,52 @@ +import "@saleor/macaw-ui/style"; +import "../styles/globals.css"; + +import { AppBridge, AppBridgeProvider } from "@saleor/app-sdk/app-bridge"; +import { RoutePropagator } from "@saleor/app-sdk/app-bridge/next"; +import { AppProps } from "next/app"; +import { useEffect } from "react"; + +import { ThemeProvider } from "@saleor/macaw-ui"; +import { NoSSRWrapper } from "../lib/no-ssr-wrapper"; +import { ThemeSynchronizer } from "../lib/theme-synchronizer"; +import { GraphQLProvider } from "../providers/GraphQLProvider"; +import { Navigation } from "../components/Navigation"; +import { AppContent } from "../components/AppContent"; +import { trpcClient } from "@/trpc-client"; + +/** + * 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; + +function NextApp({ Component, pageProps }: AppProps) { + /** + * Configure JSS (used by MacawUI) for SSR. If Macaw is not used, can be removed. + */ + useEffect(() => { + const jssStyles = document.querySelector("#jss-server-side"); + if (jssStyles) { + jssStyles?.parentElement?.removeChild(jssStyles); + } + }, []); + + return ( + + + + + + + + + + + + + + + ); +} + +export default trpcClient.withTRPC(NextApp); diff --git a/apps/dummy-payment/src/pages/api/manifest.ts b/apps/dummy-payment/src/pages/api/manifest.ts new file mode 100644 index 000000000..ab015c6a0 --- /dev/null +++ b/apps/dummy-payment/src/pages/api/manifest.ts @@ -0,0 +1,86 @@ +import { createManifestHandler } from "@saleor/app-sdk/handlers/next"; +import { AppManifest } from "@saleor/app-sdk/types"; + +import packageJson from "../../../package.json"; +import { paymentGatewayInitializeSessionWebhook } from "./webhooks/payment-gateway-initialize-session"; +import { transactionInitializeSessionWebhook } from "./webhooks/transaction-initialize-session"; +import { transactionProcessSessionWebhook } from "./webhooks/transaction-process-session"; +import { transactionRefundRequestedWebhook } from "./webhooks/transaction-refund-requested"; +import { transactionChargeRequestedWebhook } from "./webhooks/transaction-charge-requested"; +import { transactionCancelationRequestedWebhook } from "./webhooks/transaction-cancel-requested"; +import { wrapWithLoggerContext } from "@/lib/logger/logger-context"; +import { withOtel } from "@/lib/otel/otel-wrapper"; +import { loggerContext } from "@/logger-context"; + +/** + * App SDK helps with the valid Saleor App Manifest creation. Read more: + * https://github.com/saleor/saleor-app-sdk/blob/main/docs/api-handlers.md#manifest-handler-factory + */ +export default wrapWithLoggerContext( + withOtel( + createManifestHandler({ + async manifestFactory({ appBaseUrl, request }) { + /** + * Allow to overwrite default app base url, to enable Docker support. + * + * See docs: https://docs.saleor.io/docs/3.x/developer/extending/apps/local-app-development + */ + const iframeBaseUrl = process.env.APP_IFRAME_BASE_URL ?? appBaseUrl; + const apiBaseURL = process.env.APP_API_BASE_URL ?? appBaseUrl; + + const manifest: AppManifest = { + name: "Dummy Payment App", + tokenTargetUrl: `${apiBaseURL}/api/register`, + appUrl: iframeBaseUrl, + /** + * Set permissions for app if needed + * https://docs.saleor.io/docs/3.x/developer/permissions + */ + permissions: [ + /** + * Add permission to allow "ORDER_CREATED" webhook registration. + * + * This can be removed + */ + "MANAGE_CHECKOUTS", + "HANDLE_PAYMENTS", + "MANAGE_ORDERS", + ], + id: "saleor.io.dummy-payment-app", + version: packageJson.version, + /** + * Configure webhooks here. They will be created in Saleor during installation + * Read more + * https://docs.saleor.io/docs/3.x/developer/api-reference/webhooks/objects/webhook + * + * Easiest way to create webhook is to use app-sdk + * https://github.com/saleor/saleor-app-sdk/blob/main/docs/saleor-webhook.md + */ + webhooks: [ + paymentGatewayInitializeSessionWebhook.getWebhookManifest(apiBaseURL), + transactionInitializeSessionWebhook.getWebhookManifest(apiBaseURL), + transactionProcessSessionWebhook.getWebhookManifest(apiBaseURL), + transactionRefundRequestedWebhook.getWebhookManifest(apiBaseURL), + transactionChargeRequestedWebhook.getWebhookManifest(apiBaseURL), + transactionCancelationRequestedWebhook.getWebhookManifest(apiBaseURL), + ], + /** + * Optionally, extend Dashboard with custom UIs + * https://docs.saleor.io/docs/3.x/developer/extending/apps/extending-dashboard-with-apps + */ + extensions: [], + author: "Saleor Commerce", + brand: { + logo: { + default: `${apiBaseURL}/logo.png`, + }, + }, + }; + + return manifest; + }, + }), + "/api/manifest" + ), + loggerContext +); diff --git a/apps/dummy-payment/src/pages/api/register.ts b/apps/dummy-payment/src/pages/api/register.ts new file mode 100644 index 000000000..fa44ca742 --- /dev/null +++ b/apps/dummy-payment/src/pages/api/register.ts @@ -0,0 +1,46 @@ +import { createAppRegisterHandler } from "@saleor/app-sdk/handlers/next"; + +import { saleorApp } from "../../saleor-app"; +import { wrapWithLoggerContext } from "@/lib/logger/logger-context"; +import { withOtel } from "@/lib/otel/otel-wrapper"; +import { loggerContext } from "@/logger-context"; +import { createLogger } from "@/lib/logger/create-logger"; + +const logger = createLogger("createAppRegisterHandler"); + +const allowedUrlsPattern = process.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 wrapWithLoggerContext( + withOtel( + createAppRegisterHandler({ + apl: saleorApp.apl, + /** + * Prohibit installation from Saleors other than specified by the regex. + * Regex source is ENV so if ENV is not set, all installations will be allowed. + */ + allowedSaleorUrls: [ + (url) => { + if (allowedUrlsPattern) { + const regex = new RegExp(allowedUrlsPattern); + + return regex.test(url); + } + + return true; + }, + ], + onAuthAplSaved: async (_req, context) => { + logger.info("Dummy payment app configuration set up successfully", { + saleorApiUrl: context.authData.saleorApiUrl, + }); + }, + }), + "/api/register" + ), + + loggerContext +); diff --git a/apps/dummy-payment/src/pages/api/trpc/[trpc].ts b/apps/dummy-payment/src/pages/api/trpc/[trpc].ts new file mode 100644 index 000000000..b6ceb31a0 --- /dev/null +++ b/apps/dummy-payment/src/pages/api/trpc/[trpc].ts @@ -0,0 +1,24 @@ +import * as trpcNext from "@trpc/server/adapters/next"; +import { appRouter } from "../../../server/routers/app-router"; +import { SALEOR_AUTHORIZATION_BEARER_HEADER, SALEOR_API_URL_HEADER } from "@saleor/app-sdk/headers"; +import { inferAsyncReturnType } from "@trpc/server"; + +/** + * Attach headers from request to tRPC context to expose them to resolvers + */ +const createContext = async ({ res, 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, + }; +}; + +export default trpcNext.createNextApiHandler({ + router: appRouter, + createContext, +}); + +export type Context = inferAsyncReturnType; diff --git a/apps/dummy-payment/src/pages/api/webhooks/payment-gateway-initialize-session.ts b/apps/dummy-payment/src/pages/api/webhooks/payment-gateway-initialize-session.ts new file mode 100644 index 000000000..46de2ff0f --- /dev/null +++ b/apps/dummy-payment/src/pages/api/webhooks/payment-gateway-initialize-session.ts @@ -0,0 +1,41 @@ +import { SaleorSyncWebhook } from "@saleor/app-sdk/handlers/next"; +import { saleorApp } from "@/saleor-app"; +import { + PaymentGatewayInitializeSessionDocument, + PaymentGatewayInitializeSessionEventFragment, +} from "@/generated/graphql"; +import { wrapWithLoggerContext } from "@/lib/logger/logger-context"; +import { withOtel } from "@/lib/otel/otel-wrapper"; +import { loggerContext } from "@/logger-context"; + +export const paymentGatewayInitializeSessionWebhook = + new SaleorSyncWebhook({ + name: "Payment Gateway Initialize Session", + webhookPath: "api/webhooks/payment-gateway-initialize-session", + event: "PAYMENT_GATEWAY_INITIALIZE_SESSION", + apl: saleorApp.apl, + query: PaymentGatewayInitializeSessionDocument, + }); + +export default wrapWithLoggerContext( + withOtel( + paymentGatewayInitializeSessionWebhook.createHandler((req, res, ctx) => { + return res.status(200).json({ + data: { + ok: true, + }, + }); + }), + "/api/webhooks/payment-gateway-initialize-session" + ), + loggerContext +); + +/** + * Disable body parser for this endpoint, so signature can be verified + */ +export const config = { + api: { + bodyParser: false, + }, +}; diff --git a/apps/dummy-payment/src/pages/api/webhooks/transaction-cancel-requested.ts b/apps/dummy-payment/src/pages/api/webhooks/transaction-cancel-requested.ts new file mode 100644 index 000000000..4abefcb4f --- /dev/null +++ b/apps/dummy-payment/src/pages/api/webhooks/transaction-cancel-requested.ts @@ -0,0 +1,87 @@ +import { SaleorSyncWebhook } from "@saleor/app-sdk/handlers/next"; +import { v7 as uuidv7 } from "uuid"; +import { saleorApp } from "@/saleor-app"; +import { + TransactionCancelRequestedDocument, + TransactionCancelRequestedEventFragment, +} from "@/generated/graphql"; +import { + CancelationRequestedResponse, + cancelationRequestedInputSchema, +} from "@/modules/validation/cancel-webhook"; +import { getZodErrorMessage } from "@/lib/zod-error"; +import { getTransactionActions } from "@/lib/transaction-actions"; +import { AppUrlGenerator } from "@/modules/url/app-url-generator"; +import { createLogger } from "@/lib/logger/create-logger"; +import { wrapWithLoggerContext } from "@/lib/logger/logger-context"; +import { withOtel } from "@/lib/otel/otel-wrapper"; +import { loggerContext } from "@/logger-context"; + +export const transactionCancelationRequestedWebhook = + new SaleorSyncWebhook({ + name: "Transaction cancelation Requested", + webhookPath: "api/webhooks/transaction-cancel-requested", + event: "TRANSACTION_CANCELATION_REQUESTED", + apl: saleorApp.apl, + query: TransactionCancelRequestedDocument, + }); + +export default wrapWithLoggerContext( + withOtel( + transactionCancelationRequestedWebhook.createHandler((req, res, ctx) => { + const logger = createLogger("transaction-cancelation-requested"); + const { payload } = ctx; + + logger.debug("Received webhook", { payload }); + + const payloadResult = cancelationRequestedInputSchema.safeParse(payload); + + if (payloadResult.error) { + logger.warn("Data received from Saleor didn't pass validation", { + error: payloadResult.error, + }); + + const failureResponse: CancelationRequestedResponse = { + pspReference: uuidv7(), + result: "CANCEL_FAILURE", + message: getZodErrorMessage(payloadResult.error), + actions: getTransactionActions("CANCEL_FAILURE"), + amount: 0, + }; + + logger.info("Returning error response from Saleor", { response: failureResponse }); + + return res.status(200).json(failureResponse); + } + + const parsedPayload = payloadResult.data; + const amount = parsedPayload.transaction.authorizedAmount.amount; + const urlGenerator = new AppUrlGenerator(ctx.authData); + + const successResponse: CancelationRequestedResponse = { + pspReference: uuidv7(), + // TODO: Add result customization + result: "CANCEL_SUCCESS", + message: "Great success!", + actions: getTransactionActions("CANCEL_SUCCESS"), + amount, + externalUrl: urlGenerator.getTransactionDetailsUrl(parsedPayload.transaction.id), + }; + + logger.info("Returning response to Saleor", { response: successResponse }); + + return res.status(200).json(successResponse); + }), + "/api/webhooks/transaction-cancel-requested" + ), + loggerContext +); + +/** + * Disable body parser for this endpoint, so signature can be verified + */ +export const config = { + api: { + bodyParser: false, + }, +}; diff --git a/apps/dummy-payment/src/pages/api/webhooks/transaction-charge-requested.ts b/apps/dummy-payment/src/pages/api/webhooks/transaction-charge-requested.ts new file mode 100644 index 000000000..5698f2cdd --- /dev/null +++ b/apps/dummy-payment/src/pages/api/webhooks/transaction-charge-requested.ts @@ -0,0 +1,87 @@ +import { SaleorSyncWebhook } from "@saleor/app-sdk/handlers/next"; +import { v7 as uuidv7 } from "uuid"; +import { saleorApp } from "@/saleor-app"; +import { + TransactionChargeRequestedDocument, + TransactionChargeRequestedEventFragment, +} from "@/generated/graphql"; +import { createLogger } from "@/lib/logger/create-logger"; +import { + ChargeRequestedResponse, + chargeRequestedInputSchema, +} from "@/modules/validation/charge-webhook"; +import { getZodErrorMessage } from "@/lib/zod-error"; +import { getTransactionActions } from "@/lib/transaction-actions"; +import { AppUrlGenerator } from "@/modules/url/app-url-generator"; +import { wrapWithLoggerContext } from "@/lib/logger/logger-context"; +import { withOtel } from "@/lib/otel/otel-wrapper"; +import { loggerContext } from "@/logger-context"; + +export const transactionChargeRequestedWebhook = + new SaleorSyncWebhook({ + name: "Transaction Charge Requested", + webhookPath: "api/webhooks/transaction-charge-requested", + event: "TRANSACTION_CHARGE_REQUESTED", + apl: saleorApp.apl, + query: TransactionChargeRequestedDocument, + }); + +export default wrapWithLoggerContext( + withOtel( + transactionChargeRequestedWebhook.createHandler((req, res, ctx) => { + const logger = createLogger("transaction-charge-requested"); + const { payload } = ctx; + const { amount } = payload.action; + + logger.debug("Received webhook", { payload }); + + const payloadResult = chargeRequestedInputSchema.safeParse(payload); + + if (payloadResult.error) { + logger.warn("Data received from Saleor didn't pass validation", { + error: payloadResult.error, + }); + + const failureResponse: ChargeRequestedResponse = { + pspReference: uuidv7(), + result: "CHARGE_FAILURE", + message: getZodErrorMessage(payloadResult.error), + actions: getTransactionActions("CHARGE_FAILURE"), + amount, + }; + + logger.info("Returning error response from Saleor", { response: failureResponse }); + + return res.status(200).json(failureResponse); + } + + const parsedPayload = payloadResult.data; + const urlGenerator = new AppUrlGenerator(ctx.authData); + + const successResponse: ChargeRequestedResponse = { + pspReference: uuidv7(), + // TODO: Add result customization + result: "CHARGE_SUCCESS", + message: "Great success!", + actions: getTransactionActions("CHARGE_SUCCESS"), + amount, + externalUrl: urlGenerator.getTransactionDetailsUrl(parsedPayload.transaction.id), + }; + + logger.info("Returning response to Saleor", { response: successResponse }); + + return res.status(200).json(successResponse); + }), + "/api/webhooks/transaction-charge-requested" + ), + loggerContext +); + +/** + * Disable body parser for this endpoint, so signature can be verified + */ +export const config = { + api: { + bodyParser: false, + }, +}; diff --git a/apps/dummy-payment/src/pages/api/webhooks/transaction-initialize-session.ts b/apps/dummy-payment/src/pages/api/webhooks/transaction-initialize-session.ts new file mode 100644 index 000000000..2502aeada --- /dev/null +++ b/apps/dummy-payment/src/pages/api/webhooks/transaction-initialize-session.ts @@ -0,0 +1,107 @@ +import { SaleorSyncWebhook } from "@saleor/app-sdk/handlers/next"; +import { saleorApp } from "@/saleor-app"; +import { + TransactionEventTypeEnum, + TransactionFlowStrategyEnum, + TransactionInitializeSessionDocument, + TransactionInitializeSessionEventFragment, +} from "@/generated/graphql"; +import { v7 as uuidv7 } from "uuid"; +import { getTransactionActions } from "@/lib/transaction-actions"; +import { createLogger } from "@/lib/logger/create-logger"; +import { getZodErrorMessage } from "@/lib/zod-error"; +import { dataSchema } from "@/modules/validation/sync-transaction"; +import { AppUrlGenerator } from "@/modules/url/app-url-generator"; +import { wrapWithLoggerContext } from "@/lib/logger/logger-context"; +import { withOtel } from "@/lib/otel/otel-wrapper"; +import { loggerContext } from "@/logger-context"; +import { + TransactionSessionFailure, + TransactionSessionSuccess, +} from "@/generated/app-webhooks-types/transaction-initialize-session"; + +export const transactionInitializeSessionWebhook = + new SaleorSyncWebhook({ + name: "Transaction Initialize Session", + webhookPath: "api/webhooks/transaction-initialize-session", + event: "TRANSACTION_INITIALIZE_SESSION", + apl: saleorApp.apl, + query: TransactionInitializeSessionDocument, + }); + +export default wrapWithLoggerContext( + withOtel( + transactionInitializeSessionWebhook.createHandler((req, res, ctx) => { + const logger = createLogger("transaction-initialize-session"); + const { payload } = ctx; + const { actionType, amount } = payload.action; + + logger.debug("Received webhook", { payload }); + + const rawEventData = payload.data; + const dataResult = dataSchema.safeParse(rawEventData); + + if (dataResult.error) { + logger.warn("Invalid data field received in notification", { error: dataResult.error }); + + const errorResponse: TransactionSessionFailure = { + pspReference: uuidv7(), + result: + actionType === TransactionFlowStrategyEnum.Charge + ? "CHARGE_FAILURE" + : "AUTHORIZATION_FAILURE", + message: getZodErrorMessage(dataResult.error), + amount, + actions: [], + data: { + exception: true, + }, + }; + + logger.info("Returning error response to Saleor", { response: errorResponse }); + + return res.status(200).json(errorResponse); + } + + const data = dataResult.data; + + logger.info("Parsed data field from notification", { data }); + + const urlGenerator = new AppUrlGenerator(ctx.authData); + + const successResponse: TransactionSessionSuccess = { + pspReference: data.event.includePspReference ? uuidv7() : "psp-ref", + result: data.event.type as TransactionSessionSuccess['result'], + message: "Great success!", + actions: getTransactionActions(data.event.type as TransactionEventTypeEnum), + amount, + externalUrl: urlGenerator.getTransactionDetailsUrl(payload.transaction.id), + // todo allow to set from ui + paymentMethodDetails: { + type:"CARD", + brand:"visa", + name:"Card", + expMonth: 4, + expYear: 2030, + firstDigits:"1234", + lastDigits:"1234" + }, + }; + + logger.info("Returning response to Saleor", { response: successResponse }); + + return res.status(200).json(successResponse); + }), + "/api/webhooks/transaction-initialize-session" + ), + loggerContext +); + +/** + * Disable body parser for this endpoint, so signature can be verified + */ +export const config = { + api: { + bodyParser: false, + }, +}; diff --git a/apps/dummy-payment/src/pages/api/webhooks/transaction-process-session.ts b/apps/dummy-payment/src/pages/api/webhooks/transaction-process-session.ts new file mode 100644 index 000000000..cd29d48e2 --- /dev/null +++ b/apps/dummy-payment/src/pages/api/webhooks/transaction-process-session.ts @@ -0,0 +1,97 @@ +import { SaleorSyncWebhook } from "@saleor/app-sdk/handlers/next"; +import { v7 as uuidv7 } from "uuid"; +import { saleorApp } from "@/saleor-app"; +import { + TransactionEventTypeEnum, + TransactionFlowStrategyEnum, + TransactionProcessSessionDocument, + TransactionProcessSessionEventFragment, +} from "@/generated/graphql"; +import { createLogger } from "@/lib/logger/create-logger"; +import { dataSchema } from "@/modules/validation/sync-transaction"; +import { getZodErrorMessage } from "@/lib/zod-error"; +import { getTransactionActions } from "@/lib/transaction-actions"; +import { AppUrlGenerator } from "@/modules/url/app-url-generator"; +import { wrapWithLoggerContext } from "@/lib/logger/logger-context"; +import { withOtel } from "@/lib/otel/otel-wrapper"; +import { loggerContext } from "@/logger-context"; +import { + TransactionSessionFailure, + TransactionSessionSuccess, +} from "@/generated/app-webhooks-types/transaction-process-session"; + +export const transactionProcessSessionWebhook = + new SaleorSyncWebhook({ + name: "Transaction Process Session", + webhookPath: "api/webhooks/transaction-process-session", + event: "TRANSACTION_PROCESS_SESSION", + apl: saleorApp.apl, + query: TransactionProcessSessionDocument, + }); + +export default wrapWithLoggerContext( + withOtel( + transactionProcessSessionWebhook.createHandler((req, res, ctx) => { + const logger = createLogger("transaction-process-session"); + const { payload } = ctx; + const { actionType, amount } = payload.action; + + logger.debug("Received webhook", { payload }); + + const rawEventData = payload.data; + const dataResult = dataSchema.safeParse(rawEventData); + + if (dataResult.error) { + logger.warn("Invalid data field received in notification", { error: dataResult.error }); + + const errorResponse: TransactionSessionFailure = { + pspReference: uuidv7(), + result: + actionType === TransactionFlowStrategyEnum.Charge + ? "CHARGE_FAILURE" + : "AUTHORIZATION_FAILURE", + message: getZodErrorMessage(dataResult.error), + amount, + actions: [], + data: { + exception: true, + }, + }; + + logger.info("Returning error response to Saleor", { response: errorResponse }); + + return res.status(200).json(errorResponse); + } + + const data = dataResult.data; + + logger.info("Parsed data field from notification", { data }); + + const urlGenerator = new AppUrlGenerator(ctx.authData); + + const successResponse: TransactionSessionSuccess = { + pspReference: data.event.includePspReference ? uuidv7() : "test-psp", + result: data.event.type as TransactionSessionSuccess["result"], + message: "Great success!", + actions: getTransactionActions(data.event.type as TransactionEventTypeEnum), + amount, + externalUrl: urlGenerator.getTransactionDetailsUrl(payload.transaction.id), + }; + + logger.info("Returning response to Saleor", { response: successResponse }); + + return res.status(200).json(successResponse); + }), + "/api/webhooks/transaction-process-session" + ), + loggerContext +); + +/** + * Disable body parser for this endpoint, so signature can be verified + */ +export const config = { + api: { + bodyParser: false, + }, +}; diff --git a/apps/dummy-payment/src/pages/api/webhooks/transaction-refund-requested.ts b/apps/dummy-payment/src/pages/api/webhooks/transaction-refund-requested.ts new file mode 100644 index 000000000..89557631d --- /dev/null +++ b/apps/dummy-payment/src/pages/api/webhooks/transaction-refund-requested.ts @@ -0,0 +1,96 @@ +import { SaleorSyncWebhook } from "@saleor/app-sdk/handlers/next"; +import { v7 as uuidv7 } from "uuid"; +import { saleorApp } from "@/saleor-app"; +import { + TransactionRefundRequestedDocument, + TransactionRefundRequestedEventFragment, +} from "@/generated/graphql"; +import { createLogger } from "@/lib/logger/create-logger"; +import { + RefundRequestedResponse, + refundRequestedInputSchema, +} from "@/modules/validation/refund-webhook"; +import { TransactionRefundChecker } from "@/modules/transaction/transaction-refund-checker"; +import { getZodErrorMessage } from "@/lib/zod-error"; +import { getTransactionActions } from "@/lib/transaction-actions"; +import { AppUrlGenerator } from "@/modules/url/app-url-generator"; +import { wrapWithLoggerContext } from "@/lib/logger/logger-context"; +import { withOtel } from "@/lib/otel/otel-wrapper"; +import { loggerContext } from "@/logger-context"; + +export const transactionRefundRequestedWebhook = + new SaleorSyncWebhook({ + name: "Transaction Refund Requested", + webhookPath: "api/webhooks/transaction-refund-requested", + event: "TRANSACTION_REFUND_REQUESTED", + apl: saleorApp.apl, + query: TransactionRefundRequestedDocument, + }); + +export default wrapWithLoggerContext( + withOtel( + transactionRefundRequestedWebhook.createHandler((req, res, ctx) => { + const logger = createLogger("transaction-refund-requested"); + const { payload } = ctx; + const { amount } = payload.action; + + const transactionRefundChecker = new TransactionRefundChecker(); + + logger.debug("Received webhook", { payload }); + + const payloadResult = refundRequestedInputSchema.safeParse(payload); + + if (payloadResult.error) { + logger.warn("Data received from Saleor didn't pass validation", { + error: payloadResult.error, + }); + + const failureResponse: RefundRequestedResponse = { + pspReference: uuidv7(), + result: "REFUND_FAILURE", + message: getZodErrorMessage(payloadResult.error), + actions: getTransactionActions("REFUND_FAILURE"), + amount, + }; + + logger.info("Returning error response from Saleor", { response: failureResponse }); + + return res.status(200).json(failureResponse); + } + + const parsedPayload = payloadResult.data; + const urlGenerator = new AppUrlGenerator(ctx.authData); + + const successResponse: RefundRequestedResponse = { + pspReference: uuidv7(), + // TODO: Add result customization + result: "REFUND_SUCCESS", + message: "Great success!", + actions: transactionRefundChecker.checkIfAnotherRefundIsPossible( + amount, + payload.transaction?.chargedAmount + ) + ? ["REFUND"] + : [], + amount, + externalUrl: urlGenerator.getTransactionDetailsUrl(parsedPayload.transaction.id), + }; + + logger.info("Returning response to Saleor", { response: successResponse }); + + return res.status(200).json(successResponse); + }), + + "/api/wb/transaction-refund-requested" + ), + loggerContext +); + +/** + * Disable body parser for this endpoint, so signature can be verified + */ +export const config = { + api: { + bodyParser: false, + }, +}; diff --git a/apps/dummy-payment/src/pages/app/checkout.tsx b/apps/dummy-payment/src/pages/app/checkout.tsx new file mode 100644 index 000000000..22420cd61 --- /dev/null +++ b/apps/dummy-payment/src/pages/app/checkout.tsx @@ -0,0 +1,271 @@ +// pages/checkout.tsx +import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge"; +import { + ArrowRightIcon, + Box, + Button, + Combobox, + ExternalLinkIcon, + List, + Text, + Toggle, +} from "@saleor/macaw-ui"; +import { + useChannelsListQuery, + useCompleteCheckoutMutation, + useCreateCheckoutMutation, + useInitializeTransactionMutation, + useProductListQuery, + useUpdateDeliveryMutation, +} from "@/generated/graphql"; +import { TransactionEventType, transactionEventTypeSchema } from "@/modules/validation/common"; +import React from "react"; +import { SyncWebhookRequestData } from "@/modules/validation/sync-transaction"; +import { useRouter } from "next/router"; + +interface TransactionResponseOptions { + value: TransactionEventType; + label: TransactionEventType; +} + +const CheckoutPage = () => { + const router = useRouter(); + const { appBridge, appBridgeState } = useAppBridge(); + const [response, setResponse] = React.useState({ + value: "CHARGE_SUCCESS", + label: "CHARGE_SUCCESS", + }); + const [channelSlug, setChannelSlug] = React.useState(""); + const [includePspReference, setIncludePspReference] = React.useState(true); + + const [{ data: channelsData, fetching: fetchingChannels }] = useChannelsListQuery(); + const [{ data: productsData, fetching: fetchingProducts }] = useProductListQuery({ + pause: channelSlug === "", + variables: { channelSlug }, + }); + const [checkoutCreateResult, checkoutCreateExecute] = useCreateCheckoutMutation(); + + const [deliveryUpdateResult, deliveryUpdateExecute] = useUpdateDeliveryMutation(); + const handleExecuteDeliveryUpdate = () => { + deliveryUpdateExecute({ + id: checkoutCreateResult.data?.checkoutCreate?.checkout?.id ?? "", + methodId: checkoutCreateResult.data?.checkoutCreate?.checkout?.shippingMethods[0]?.id ?? "", + }); + }; + + const [transactionInitializeResult, transactionInitializeExecute] = + useInitializeTransactionMutation(); + + const [completeCheckoutResult, completeCheckoutExecute] = useCompleteCheckoutMutation(); + + const handleExecuteInitializeTransaction = () => { + transactionInitializeExecute({ + id: checkoutCreateResult.data?.checkoutCreate?.checkout?.id ?? "", + data: { + event: { + type: response.value, + includePspReference, + }, + } as SyncWebhookRequestData, + }); + }; + + const handleExecuteCheckoutCreate = () => { + checkoutCreateExecute({ + channelSlug, + variants: [ + { + quantity: 1, + variantId: productsData?.products?.edges[0]?.node.defaultVariant?.id ?? "", + }, + ], + }); + }; + + const handleExecuteCompleteCheckout = () => { + completeCheckoutExecute({ + id: checkoutCreateResult.data?.checkoutCreate?.checkout?.id ?? "", + }); + }; + + const navigateToTransaction = (id: string | undefined) => { + if (id) { + router.push(`/app/transactions/${id}`); + } + }; + + const navigateToOrder = (id: string) => { + appBridge?.dispatch( + actions.Redirect({ + to: `/orders/${id}`, + newContext: true, + }) + ); + }; + + return ( + <> + + + Quick checkout tool + + + + + + + + + + + + Select channel: + setChannelSlug(value as string)} + options={(channelsData?.channels ?? []).map((value) => ({ + value: value.slug, + label: value.name, + }))} + /> + + + Select transaction response: + ({ + label: value, + value, + }))} + value={response} + onChange={(value) => setResponse(value as TransactionResponseOptions)} + size="small" + __width="250px" + /> + + + setIncludePspReference(pressed)} + > + Return pspReference + + + {checkoutCreateResult.data && ( + + + Checkout created: + + Checkout ID: {checkoutCreateResult.data.checkoutCreate?.checkout?.id ?? "Error"} + + + Available gateways:{" "} + {checkoutCreateResult.data.checkoutCreate?.checkout?.availablePaymentGateways?.map( + (gateway) => {gateway?.name} + ) ?? "Error "} + + + {deliveryUpdateResult.data && + (!!deliveryUpdateResult.error ? ( + + Error setting shipping method + + ) : ( + Shipping method set! + ))} + + {transactionInitializeResult.data && ( + <> + Transaction initialized: + + + pspReference: + + {transactionInitializeResult.data.transactionInitialize?.transactionEvent + ?.pspReference || ""} + + + + transactionId: + + {transactionInitializeResult.data.transactionInitialize?.transaction?.id || + ""} + + + + Event type: + + {transactionInitializeResult.data.transactionInitialize?.transactionEvent + ?.type ?? "Error type"} + + + + {transactionInitializeResult.data.transactionInitialize?.transaction?.id && ( + + navigateToTransaction( + transactionInitializeResult.data?.transactionInitialize?.transaction?.id + ) + } + cursor="pointer" + color="accent1" + display="flex" + gap={2} + alignItems="center" + > + + + Report changes on Transaction + + + )} + + )} + + + {completeCheckoutResult.data && ( + + navigateToOrder(completeCheckoutResult.data?.checkoutComplete?.order?.id ?? "") + } + cursor="pointer" + color="accent1" + display="flex" + gap={2} + alignItems="center" + > + + + Created order{" "} + {completeCheckoutResult.data.checkoutComplete?.order?.number ?? "Error"} + + + )} + + )} + + + ); +}; + +export default CheckoutPage; diff --git a/apps/dummy-payment/src/pages/app/configuration.tsx b/apps/dummy-payment/src/pages/app/configuration.tsx new file mode 100644 index 000000000..dce5a5ee9 --- /dev/null +++ b/apps/dummy-payment/src/pages/app/configuration.tsx @@ -0,0 +1,21 @@ +import { Box, Text } from "@saleor/macaw-ui"; + +const ConfigurationPage = () => { + return ( + <> + + 🚀 Welcome to Dummy Payment App! + + + ); +}; + +export default ConfigurationPage; diff --git a/apps/dummy-payment/src/pages/app/index.tsx b/apps/dummy-payment/src/pages/app/index.tsx new file mode 100644 index 000000000..2af15e33d --- /dev/null +++ b/apps/dummy-payment/src/pages/app/index.tsx @@ -0,0 +1,22 @@ +// pages/dashboard.tsx +import { Box, Text } from "@saleor/macaw-ui"; + +const DashboardPage = () => { + return ( + <> + + 🚀 Welcome to Dummy Payment App! + + + ); +}; + +export default DashboardPage; diff --git a/apps/dummy-payment/src/pages/app/transactions/[id].tsx b/apps/dummy-payment/src/pages/app/transactions/[id].tsx new file mode 100644 index 000000000..8eba8a2bc --- /dev/null +++ b/apps/dummy-payment/src/pages/app/transactions/[id].tsx @@ -0,0 +1,243 @@ +import { Box, Button, Combobox, Input, OrdersIcon, Spinner, Text, Toggle } from "@saleor/macaw-ui"; +import { useRouter } from "next/router"; +import { StatusChip } from "@/components/StatusChip"; +import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge"; +import React, { useState } from "react"; +import { trpcClient } from "@/trpc-client"; +import { TransactionEventTypeEnum, useTransactionDetailsViaIdQuery } from "@/generated/graphql"; +import { TransactionPspFinder } from "@/modules/transaction/transaction-psp-finder"; + +interface EventReporterOptions { + label: TransactionEventTypeEnum; + value: TransactionEventTypeEnum; +} + +function formatCurrency(amount: number, currencyCode: string, locale: string = "en-US") { + // Create a formatter + const formatter = new Intl.NumberFormat(locale, { + style: "currency", + currency: currencyCode, + }); + + // Format the number + return formatter.format(amount); +} + +function formatDateTime(dateString: string, locale = "en-US") { + // Parse the date string to a Date object + const date = new Date(dateString); + + // Create a formatter + const formatter = new Intl.DateTimeFormat(locale, { + year: "numeric", + month: "long", + day: "numeric", + hour: "numeric", + minute: "numeric", + second: "numeric", + hour12: false, // Use 24-hour format + }); + + // Format the date + return formatter.format(date); +} + +const EventReporterPage = () => { + const router = useRouter(); + const { appBridgeState, appBridge } = useAppBridge(); + const [otherError, setOtherError] = React.useState(null); + + const transactionId = router.query.id as string; + + const [eventType, setEventType] = React.useState({ + label: TransactionEventTypeEnum.ChargeSuccess, + value: TransactionEventTypeEnum.ChargeSuccess, + }); + + const [amount, setAmount] = React.useState(""); + + const [generateNewPspReference, setGenerateNewPspReference] = useState(false); + const [sendNullAmount, setSendNullAmount] = useState(false); + + const navigateToOrder = (id: string) => { + appBridge?.dispatch( + actions.Redirect({ + to: `/orders/${id}`, + newContext: true, + }) + ); + }; + + const [{ data, fetching }, refetch] = useTransactionDetailsViaIdQuery({ + variables: { + id: transactionId, + }, + }); + + const transaction = data?.transaction; + + const orderId = transaction?.order?.id; + + const mutation = trpcClient.transactionReporter.reportEvent.useMutation(); + + const isLoading = fetching || mutation.isLoading; + + const handleReportEvent = async () => { + setOtherError(null); + const pspFinder = new TransactionPspFinder(); + try { + const parsedAmount = sendNullAmount ? null : parseFloat(amount); + + if (!sendNullAmount && Number.isNaN(parsedAmount as number)) { + setOtherError("Invalid amount"); + return; + } + + let pspReference: string | undefined; + if (!generateNewPspReference) { + if (!data?.transaction) { + setOtherError("No transaction found to find lastPspRefernce"); + return; + } + + pspReference = pspFinder.findLastPspReference(data.transaction) || undefined; + + if (!pspReference) { + setOtherError("No pspReference found in transaction, change setting to generate new one"); + return; + } + + console.log(pspReference); + } + + await mutation.mutateAsync({ + id: transaction?.id ?? "", + amount: parsedAmount, + type: eventType.value, + pspReference, + }); + refetch({ requestPolicy: "network-only" }); + } catch (error) { + console.error(error); + } + }; + + return ( + + Transaction event reporter + + {data ? ( + <> + + {transaction?.name.length ? transaction.name : "Transaction"} + + + + {transaction?.pspReference} + + {transaction?.events.map((event) => ( + + + + + + {formatCurrency( + event.amount.amount, + event.amount.currency, + appBridgeState?.locale + )} + + {event.message} + {formatDateTime(event.createdAt)} + + ))} + + ) : ( + + )} + + + Selet event type: + ({ + label: eventType, + value: eventType, + }))} + value={eventType} + onChange={(val) => setEventType(val as EventReporterOptions)} + size="small" + __width="220px" + /> + + + Enter event amount: + setAmount(e.target.value)} + disabled={sendNullAmount} + __opacity={sendNullAmount ? "0.4" : "1"} + endAdornment={{transaction?.chargedAmount.currency}} + /> + setSendNullAmount(pressed)} + > + null + + + setGenerateNewPspReference(pressed)} + > + Generate new pspReference + + + {mutation.data && ( + + Transaction reported:
{JSON.stringify(mutation.data, null, 2)}
+
+ )} + {mutation.error && ( + + + Error returned when reporting event: + + {mutation.error.message} + + )} + {otherError && {otherError}} +
+ ); +}; + +export default EventReporterPage; diff --git a/apps/dummy-payment/src/pages/app/transactions/index.tsx b/apps/dummy-payment/src/pages/app/transactions/index.tsx new file mode 100644 index 000000000..2d133f868 --- /dev/null +++ b/apps/dummy-payment/src/pages/app/transactions/index.tsx @@ -0,0 +1,87 @@ +import { useTransactionDetailsViaPspQuery } from "@/generated/graphql"; +import { Box, Button, Input, Text } from "@saleor/macaw-ui"; +import { useRouter } from "next/router"; +import React, { useEffect } from "react"; + +const TransactionsPage = () => { + const router = useRouter(); + const [pspReference, setPspReference] = React.useState(""); + const [transactionId, setTransactionId] = React.useState(""); + const [notFoundError, setNotFoundError] = React.useState(false); + + const [{ data, error: apiError }, fetchTransactions] = useTransactionDetailsViaPspQuery({ + variables: { + pspReference, + }, + pause: true, + }); + + useEffect(() => { + if (data) { + const transaction = data?.orders?.edges[0]?.node?.transactions.find((transaction) => { + return transaction?.pspReference === pspReference; + }); + if (transaction) { + router.push(`/app/transactions/${transaction.id}`); + } else { + setNotFoundError(true); + } + } + }, [data]); + + const displayError = notFoundError || apiError; + + return ( + + Here you can create events for any transaction made using this app. + + Please paste PSP Reference of the transaction you want to create events for. + setPspReference(event.target.value)} + /> + + + Or paste TransactionItem.id from Saleor + setTransactionId(event.target.value)} + /> + + + + + + {notFoundError && Invalid PSP Reference} + {apiError && Error fetching transaction} + + ); +}; + +export default TransactionsPage; diff --git a/apps/dummy-payment/src/pages/index.tsx b/apps/dummy-payment/src/pages/index.tsx new file mode 100644 index 000000000..edbd4afb3 --- /dev/null +++ b/apps/dummy-payment/src/pages/index.tsx @@ -0,0 +1,219 @@ +import { isInIframe } from "@/lib/is-in-iframe"; +import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge"; +import { Box, Button, Input, Text } from "@saleor/macaw-ui"; +import { NextPage } from "next"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { MouseEventHandler, useEffect, useState } from "react"; +import { useIsMounted } from "usehooks-ts"; + +const AddToSaleorForm = () => ( + { + event.preventDefault(); + + const saleorUrl = new FormData(event.currentTarget as HTMLFormElement).get("saleor-url"); + const manifestUrl = new URL("/api/manifest", window.location.origin); + const redirectUrl = new URL( + `/dashboard/apps/install?manifestUrl=${manifestUrl}`, + saleorUrl as string + ).href; + + window.open(redirectUrl, "_blank"); + }} + > + + + +); + +/** + * This is page publicly accessible from your app. + * You should probably remove it. + */ +const IndexPage: NextPage = () => { + const isMounted = useIsMounted(); + const { replace } = useRouter(); + const { appBridgeState, appBridge } = useAppBridge(); + + useEffect(() => { + if (isMounted() && appBridgeState?.ready) { + replace("/app"); + } + }, [isMounted, appBridgeState?.ready, replace]); + + if (isInIframe()) { + return Loading...; + } + + const handleLinkClick: MouseEventHandler = (e) => { + /** + * In iframe, link can't be opened in new tab, so Dashboard must be a proxy + */ + if (appBridgeState?.ready) { + e.preventDefault(); + + appBridge?.dispatch( + actions.Redirect({ + newContext: true, + to: e.currentTarget.href, + }) + ); + } + + /** + * Otherwise, assume app is accessed outside of Dashboard, so href attribute on will work + */ + }; + + const isLocalHost = global.location.href.includes("localhost"); + + return ( + + Welcome to Saleor App Template (Next.js) 🚀 + + Saleor App Template is a minimalistic boilerplate that provides a working example of a + Saleor app. + + {appBridgeState?.ready && isMounted() && ( + + + + )} + + + Explore the App Template by visiting: + + + + Resources + + + + {isMounted() && !isLocalHost && !appBridgeState?.ready && ( + <> + + Install this app in your Dashboard and get extra powers! + + + Go to App Dashboard + + )} + + ); +}; + +export default IndexPage; diff --git a/apps/dummy-payment/src/providers/GraphQLProvider.tsx b/apps/dummy-payment/src/providers/GraphQLProvider.tsx new file mode 100644 index 000000000..acfa35c5e --- /dev/null +++ b/apps/dummy-payment/src/providers/GraphQLProvider.tsx @@ -0,0 +1,18 @@ +import { useAppBridge } from "@saleor/app-sdk/app-bridge"; +import { PropsWithChildren } from "react"; +import { Provider } from "urql"; +import { createClient } from "../lib/create-graphql-client"; + +export function GraphQLProvider(props: PropsWithChildren<{}>) { + const { appBridgeState } = useAppBridge(); + const url = appBridgeState?.saleorApiUrl!; + + if (!url) { + console.warn("Install the app in the Dashboard to be able to query Saleor API."); + return
{props.children}
; + } + + const client = createClient(url, async () => Promise.resolve({ token: appBridgeState?.token! })); + + return ; +} diff --git a/apps/dummy-payment/src/saleor-app.ts b/apps/dummy-payment/src/saleor-app.ts new file mode 100644 index 000000000..a1e7be1b5 --- /dev/null +++ b/apps/dummy-payment/src/saleor-app.ts @@ -0,0 +1,44 @@ +import { SaleorApp } from "@saleor/app-sdk/saleor-app"; +import { DynamoAPL } from "@saleor/app-sdk/APL/dynamodb"; +import { UpstashAPL } from "@saleor/app-sdk/APL/upstash"; +import { FileAPL } from "@saleor/app-sdk/APL/file"; +import { logger } from "@/lib/logger/logger"; +import { APL } from "@saleor/app-sdk/APL"; +import { dynamoMainTable } from "@/db/dynamo-main-table"; + +/** + * By default auth data are stored in the `.auth-data.json` (FileAPL). + * For multi-tenant applications and deployments please use UpstashAPL. + * + * To read more about storing auth data, read the + * [APL documentation](https://github.com/saleor/saleor-app-sdk/blob/main/docs/apl.md) + */ +export let apl: APL; +switch (process.env.APL) { + case "dynamodb": { + apl = DynamoAPL.create({ + table: dynamoMainTable, + externalLogger: (message, level) => { + if (level === "error") { + logger.error(`[DynamoAPL] ${message}`); + } else { + logger.debug(`[DynamoAPL] ${message}`); + } + }, + }); + + break; + } + case "upstash": + // Require `UPSTASH_URL` and `UPSTASH_TOKEN` environment variables + apl = new UpstashAPL(); + break; + default: + apl = new FileAPL({ + fileName: process.env.FILE_APL_PATH, + }); +} + +export const saleorApp = new SaleorApp({ + apl, +}); diff --git a/apps/dummy-payment/src/server/middleware/attach-app-token.ts b/apps/dummy-payment/src/server/middleware/attach-app-token.ts new file mode 100644 index 000000000..1dcbdf952 --- /dev/null +++ b/apps/dummy-payment/src/server/middleware/attach-app-token.ts @@ -0,0 +1,32 @@ +import { TRPCError } from "@trpc/server"; +import { middleware } from "../server"; +import { saleorApp } from "@/saleor-app"; + +/** + * Perform APL token retrieval in middleware, required by every handler that connects to Saleor + */ +export const attachAppToken = middleware(async ({ ctx, next }) => { + if (!ctx.saleorApiUrl) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Missing saleorApiUrl in request", + }); + } + + const authData = await saleorApp.apl.get(ctx.saleorApiUrl); + + if (!authData?.token) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Missing auth data", + }); + } + + return next({ + ctx: { + authData, + // TODO: Remove appToken + appToken: authData.token, + }, + }); +}); diff --git a/apps/dummy-payment/src/server/procedure/procedure-with-graphql-client.ts b/apps/dummy-payment/src/server/procedure/procedure-with-graphql-client.ts new file mode 100644 index 000000000..571d32a1b --- /dev/null +++ b/apps/dummy-payment/src/server/procedure/procedure-with-graphql-client.ts @@ -0,0 +1,23 @@ +import { MiddlewareFunction, TRPCError } from "@trpc/server"; +import { middleware, procedure } from "../server"; +import { attachAppToken } from "../middleware/attach-app-token"; +import { createClient } from "@/lib/create-graphql-client"; +import { invariant } from "@/lib/invariant"; + +/** + * Construct common graphQL client and attach it to the context + */ +export const procedureWithGraphqlClient = procedure + .use(attachAppToken) + .use(async ({ ctx, next }) => { + invariant(ctx.saleorApiUrl); + const client = createClient(ctx.saleorApiUrl, async () => + Promise.resolve({ token: ctx.appToken }) + ); + + return next({ + ctx: { + apiClient: client, + }, + }); + }); diff --git a/apps/dummy-payment/src/server/routers/app-router.ts b/apps/dummy-payment/src/server/routers/app-router.ts new file mode 100644 index 000000000..27de68f4e --- /dev/null +++ b/apps/dummy-payment/src/server/routers/app-router.ts @@ -0,0 +1,8 @@ +import { router } from "../server"; +import { transactionReporterRouter } from "./transaction-reporter.router"; + +export const appRouter = router({ + transactionReporter: transactionReporterRouter, +}); + +export type AppRouter = typeof appRouter; diff --git a/apps/dummy-payment/src/server/routers/transaction-reporter.router.ts b/apps/dummy-payment/src/server/routers/transaction-reporter.router.ts new file mode 100644 index 000000000..989c0407a --- /dev/null +++ b/apps/dummy-payment/src/server/routers/transaction-reporter.router.ts @@ -0,0 +1,78 @@ +import { createLogger } from "@/lib/logger/create-logger"; +import { getTransactionActions } from "@/lib/transaction-actions"; +import { AppUrlGenerator } from "@/modules/url/app-url-generator"; +import { TRPCError } from "@trpc/server"; +import { v7 as uuidv7 } from "uuid"; +import { z } from "zod"; +import { + TransactionActionEnum, + TransactionEventReportDocument, + TransactionEventTypeEnum, +} from "../../../generated/graphql"; +import { procedureWithGraphqlClient } from "../procedure/procedure-with-graphql-client"; +import { router } from "../server"; + +export const transactionReporterRouter = router({ + reportEvent: procedureWithGraphqlClient + .input( + z.object({ + id: z.string(), + amount: z.number().nonnegative().finite().nullable(), + type: z.nativeEnum(TransactionEventTypeEnum), + pspReference: z.string().optional(), + message: z.string().optional(), + }) + ) + .mutation(async ({ ctx, input }) => { + const { id, amount, type, pspReference, message } = input; + const logger = createLogger("transactionReporterRouter.reportEvent", { input }); + + const urlGenerator = new AppUrlGenerator(ctx.authData); + + const result = await ctx.apiClient.mutation(TransactionEventReportDocument, { + id, + amount, + type, + pspReference: pspReference ?? uuidv7(), + message: message ?? "Great success!", + availableActions: getTransactionActions(type) as TransactionActionEnum[], + externalUrl: urlGenerator.getTransactionDetailsUrl(id), + }); + + logger.info("Received result from Saleor", { result }); + + if (result.error) { + logger.error("There was an error while making mutation call", { error: result.error }); + throw new TRPCError({ + message: result.error.message, + cause: result.error, + code: "INTERNAL_SERVER_ERROR", + }); + } + + const errors = result.data?.transactionEventReport?.errors; + if (errors && errors.length > 0) { + logger.error("Error in mutation result", { errors }); + throw new TRPCError({ + message: errors.map((error) => error.message).join(", "), + cause: errors[0], + code: "INTERNAL_SERVER_ERROR", + }); + } + + const data = result.data?.transactionEventReport; + + if (!data) { + logger.error("Missing data in response", { data: result.data }); + throw new TRPCError({ + message: "Saleor didn't return response from transactionEventReport mutation", + code: "INTERNAL_SERVER_ERROR", + }); + } + + return { + alreadyProcessed: data.alreadyProcessed, + transactionEvent: data.transactionEvent, + }; + }), +}); diff --git a/apps/dummy-payment/src/server/server.ts b/apps/dummy-payment/src/server/server.ts new file mode 100644 index 000000000..4671df18a --- /dev/null +++ b/apps/dummy-payment/src/server/server.ts @@ -0,0 +1,8 @@ +import { initTRPC } from "@trpc/server"; +import { Context } from "../pages/api/trpc/[trpc]"; + +const t = initTRPC.context().create(); + +export const router = t.router; +export const procedure = t.procedure; +export const middleware = t.middleware; diff --git a/apps/dummy-payment/src/setup-tests.ts b/apps/dummy-payment/src/setup-tests.ts new file mode 100644 index 000000000..504627866 --- /dev/null +++ b/apps/dummy-payment/src/setup-tests.ts @@ -0,0 +1,6 @@ +/** + * Add test setup logic here + * + * https://vitest.dev/config/#setupfiles + */ +export {} diff --git a/apps/dummy-payment/src/styles/globals.css b/apps/dummy-payment/src/styles/globals.css new file mode 100644 index 000000000..9ccce7512 --- /dev/null +++ b/apps/dummy-payment/src/styles/globals.css @@ -0,0 +1,11 @@ +a { + text-decoration: none; +} + +code { + background-color: var(--mu-colors-background-surface-brand-subdued); +} + +body { + margin: 0; +} diff --git a/apps/dummy-payment/src/trpc-client.ts b/apps/dummy-payment/src/trpc-client.ts new file mode 100644 index 000000000..cc9ce99d7 --- /dev/null +++ b/apps/dummy-payment/src/trpc-client.ts @@ -0,0 +1,32 @@ +import { httpBatchLink } from "@trpc/client"; +import { createTRPCNext } from "@trpc/next"; +import { AppRouter } from "./server/routers/app-router"; +import { SALEOR_API_URL_HEADER, SALEOR_AUTHORIZATION_BEARER_HEADER } from "@saleor/app-sdk/headers"; +import { appBridgeInstance } from "./pages/_app"; + +function getBaseUrl() { + if (typeof window !== "undefined") return ""; + if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`; + + return `http://localhost:${process.env.PORT ?? 3000}`; +} + +export const trpcClient = createTRPCNext({ + config({ ctx }) { + return { + links: [ + httpBatchLink({ + url: `${getBaseUrl()}/api/trpc`, + headers() { + return { + [SALEOR_API_URL_HEADER]: appBridgeInstance?.getState().saleorApiUrl, + [SALEOR_AUTHORIZATION_BEARER_HEADER]: appBridgeInstance?.getState().token, + }; + }, + }), + ], + // queryClientConfig: { defaultOptions: { queries: { staleTime: 60 } } }, + }; + }, + ssr: true, +}); diff --git a/apps/dummy-payment/tsconfig.json b/apps/dummy-payment/tsconfig.json new file mode 100644 index 000000000..8266f2ebf --- /dev/null +++ b/apps/dummy-payment/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@saleor/typescript-config-apps/base.json", + "compilerOptions": { + "baseUrl": "." + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "next.config.ts", "graphql.config.ts"], + "exclude": ["node_modules"] +} diff --git a/apps/dummy-payment/vitest.config.ts b/apps/dummy-payment/vitest.config.ts new file mode 100644 index 000000000..1dde5bc44 --- /dev/null +++ b/apps/dummy-payment/vitest.config.ts @@ -0,0 +1,13 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vitest/config"; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], + test: { + passWithNoTests: true, + environment: "jsdom", + setupFiles: "./src/setup-tests.ts", + css: false, + }, +});