├── apps ├── client │ ├── .env.example │ ├── app │ │ ├── constants.ts │ │ ├── routes.ts │ │ ├── utils.ts │ │ ├── app.css │ │ ├── services │ │ │ ├── runtime-client.ts │ │ │ ├── api.ts │ │ │ └── paddle.ts │ │ ├── root.tsx │ │ ├── components │ │ │ └── Breadcrumbs.tsx │ │ ├── routes │ │ │ └── home.tsx │ │ └── machines │ │ │ └── paddle-machine.ts │ ├── .gitignore │ ├── vite.config.ts │ ├── README.md │ ├── tsconfig.json │ └── package.json └── server │ ├── .env.example │ ├── src │ ├── utils.ts │ ├── database.ts │ ├── paddle.ts │ ├── main.ts │ ├── schema │ │ └── drizzle.ts │ ├── paddle-sdk.ts │ └── paddle-api.ts │ ├── drizzle │ ├── 0000_foamy_sharon_carter.sql │ ├── 0002_complete_mentallo.sql │ ├── 0001_bouncy_cardiac.sql │ ├── 0005_friendly_stick.sql │ ├── 0003_far_cerise.sql │ ├── meta │ │ ├── 0000_snapshot.json │ │ ├── _journal.json │ │ ├── 0003_snapshot.json │ │ ├── 0001_snapshot.json │ │ ├── 0002_snapshot.json │ │ ├── 0004_snapshot.json │ │ └── 0005_snapshot.json │ └── 0004_many_frog_thor.sql │ ├── drizzle.config.ts │ ├── tsconfig.json │ ├── package.json │ └── test │ ├── pg-container.ts │ └── paddle.test.ts ├── pnpm-workspace.yaml ├── .env.example ├── .vscode └── settings.json ├── packages └── api-client │ ├── package.json │ ├── tsconfig.json │ └── src │ ├── schemas │ └── paddle.ts │ └── api.ts ├── package.json ├── turbo.json ├── .gitignore ├── docker-compose.yaml └── README.md /apps/client/.env.example: -------------------------------------------------------------------------------- 1 | PADDLE_CLIENT_TOKEN= -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "apps/*" 3 | - "packages/*" 4 | -------------------------------------------------------------------------------- /apps/server/.env.example: -------------------------------------------------------------------------------- 1 | PADDLE_API_KEY= 2 | WEBHOOK_SECRET_KEY= 3 | POSTGRES_PW= -------------------------------------------------------------------------------- /apps/client/app/constants.ts: -------------------------------------------------------------------------------- 1 | export const PADDLE_CONTAINER_CLASS = "checkout-container"; 2 | -------------------------------------------------------------------------------- /apps/client/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /.cache 4 | /build 5 | .env 6 | .react-router 7 | -------------------------------------------------------------------------------- /apps/server/src/utils.ts: -------------------------------------------------------------------------------- 1 | export const slugFromName = (name: string) => 2 | name.replace(/[^a-zA-Z]+/g, "").replace(/ /g, "-"); 3 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | POSTGRES_USER=postgres 2 | POSTGRES_PW=postgres 3 | POSTGRES_DB=postgres 4 | PGADMIN_MAIL=example@mail.com 5 | PGADMIN_PW=password -------------------------------------------------------------------------------- /apps/server/drizzle/0000_foamy_sharon_carter.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "product" ( 2 | "id" integer PRIMARY KEY NOT NULL, 3 | "name" varchar(255), 4 | "price" integer NOT NULL 5 | ); 6 | -------------------------------------------------------------------------------- /apps/client/app/routes.ts: -------------------------------------------------------------------------------- 1 | import { type RouteConfig, route } from "@react-router/dev/routes"; 2 | 3 | export default [ 4 | route("checkout/:slug", "routes/home.tsx"), 5 | ] satisfies RouteConfig; 6 | -------------------------------------------------------------------------------- /apps/server/drizzle/0002_complete_mentallo.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "product" ADD COLUMN "slug" varchar(255) NOT NULL;--> statement-breakpoint 2 | ALTER TABLE "product" ADD CONSTRAINT "product_slug_unique" UNIQUE("slug"); -------------------------------------------------------------------------------- /apps/client/app/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "typescript.enablePromptUseWorkspaceTsdk": true, 4 | "eslint.workingDirectories": [ 5 | { 6 | "mode": "auto" 7 | } 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /apps/client/app/app.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | 3 | @theme { 4 | --font-family-sans: "Inter", ui-sans-serif, system-ui, sans-serif, 5 | "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 6 | } 7 | 8 | html, 9 | body { 10 | @apply font-sans; 11 | } 12 | -------------------------------------------------------------------------------- /apps/client/app/services/runtime-client.ts: -------------------------------------------------------------------------------- 1 | import { Layer, ManagedRuntime } from "effect"; 2 | import { Api } from "./api"; 3 | import { Paddle } from "./paddle"; 4 | 5 | const MainLayer = Layer.mergeAll(Api.Default, Paddle.Default); 6 | 7 | export const RuntimeClient = ManagedRuntime.make(MainLayer); 8 | -------------------------------------------------------------------------------- /apps/client/vite.config.ts: -------------------------------------------------------------------------------- 1 | import tailwindcss from "@tailwindcss/vite"; 2 | import { reactRouter } from "@react-router/dev/vite"; 3 | import tsconfigPaths from "vite-tsconfig-paths"; 4 | import { defineConfig } from "vite"; 5 | 6 | export default defineConfig({ 7 | plugins: [tailwindcss(), reactRouter(), tsconfigPaths()], 8 | }); 9 | -------------------------------------------------------------------------------- /apps/server/drizzle/0001_bouncy_cardiac.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "product" ALTER COLUMN "id" ADD GENERATED ALWAYS AS IDENTITY (sequence name "product_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1);--> statement-breakpoint 2 | ALTER TABLE "product" ADD COLUMN "description" varchar(255);--> statement-breakpoint 3 | ALTER TABLE "product" ADD COLUMN "imageUrl" varchar(255); -------------------------------------------------------------------------------- /apps/server/drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "drizzle-kit"; 2 | 3 | export default defineConfig({ 4 | out: "./drizzle", 5 | schema: "./src/schema/drizzle.ts", 6 | dialect: "postgresql", 7 | dbCredentials: { 8 | password: "postgres", 9 | host: "localhost", 10 | port: 5435, 11 | user: "postgres", 12 | database: "postgres", 13 | ssl: false, 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /packages/api-client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@app/api-client", 3 | "version": "0.0.0", 4 | "author": "Typeonce", 5 | "license": "MIT", 6 | "exports": { 7 | ".": "./src/api.ts", 8 | "./schemas": "./src/schemas/paddle.ts" 9 | }, 10 | "scripts": { 11 | "typecheck": "tsc" 12 | }, 13 | "dependencies": { 14 | "@effect/platform": "^0.79.1", 15 | "effect": "^3.13.10" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /apps/server/drizzle/0005_friendly_stick.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "price" ( 2 | "id" varchar(255) PRIMARY KEY NOT NULL, 3 | "productId" varchar(255), 4 | "amount" varchar(255) NOT NULL, 5 | "currencyCode" "currencyCode" NOT NULL 6 | ); 7 | --> statement-breakpoint 8 | DO $$ BEGIN 9 | ALTER TABLE "price" ADD CONSTRAINT "price_productId_product_id_fk" FOREIGN KEY ("productId") REFERENCES "public"."product"("id") ON DELETE no action ON UPDATE no action; 10 | EXCEPTION 11 | WHEN duplicate_object THEN null; 12 | END $$; 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "full-stack-paddle-billing-web-app", 3 | "private": true, 4 | "scripts": { 5 | "build": "turbo build", 6 | "dev": "turbo dev", 7 | "lint": "turbo lint", 8 | "typecheck": "turbo typecheck", 9 | "test": "turbo test", 10 | "format": "prettier --write \"**/*.{ts,tsx,md}\"" 11 | }, 12 | "devDependencies": { 13 | "prettier": "^3.2.5", 14 | "turbo": "^2.1.3", 15 | "typescript": "^5.8.2" 16 | }, 17 | "packageManager": "pnpm@8.15.6", 18 | "engines": { 19 | "node": ">=18" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "ui": "tui", 4 | "tasks": { 5 | "build": { 6 | "dependsOn": ["^build"], 7 | "inputs": ["$TURBO_DEFAULT$", ".env*"], 8 | "outputs": [".next/**", "!.next/cache/**"] 9 | }, 10 | "lint": { 11 | "dependsOn": ["^lint"] 12 | }, 13 | "typecheck": { 14 | "dependsOn": ["^typecheck"] 15 | }, 16 | "test": { 17 | "dependsOn": ["^test"] 18 | }, 19 | "dev": { 20 | "cache": false, 21 | "persistent": true 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /apps/client/app/services/api.ts: -------------------------------------------------------------------------------- 1 | import { MainApi } from "@app/api-client"; 2 | import { FetchHttpClient, HttpApiClient } from "@effect/platform"; 3 | import { Config, Effect } from "effect"; 4 | 5 | export class Api extends Effect.Service()("Api", { 6 | effect: Effect.gen(function* () { 7 | const baseUrl = yield* Config.string("API_BASE_URL").pipe( 8 | Config.withDefault("http://localhost:3000") 9 | ); 10 | return yield* HttpApiClient.make(MainApi, { 11 | baseUrl, 12 | }); 13 | }), 14 | dependencies: [FetchHttpClient.layer], 15 | }) {} 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # Dependencies 4 | node_modules 5 | .pnp 6 | .pnp.js 7 | 8 | # Local env files 9 | .env 10 | .env.local 11 | .env.development.local 12 | .env.test.local 13 | .env.production.local 14 | _env.ts 15 | 16 | # Testing 17 | coverage 18 | 19 | # Turbo 20 | .turbo 21 | 22 | # Vercel 23 | .vercel 24 | 25 | # Build Outputs 26 | .next/ 27 | out/ 28 | build 29 | dist 30 | 31 | 32 | # Debug 33 | npm-debug.log* 34 | yarn-debug.log* 35 | yarn-error.log* 36 | 37 | # Misc 38 | .DS_Store 39 | *.pem 40 | -------------------------------------------------------------------------------- /apps/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "skipLibCheck": true, 5 | "target": "es2022", 6 | "allowJs": true, 7 | "resolveJsonModule": true, 8 | "moduleDetection": "force", 9 | "isolatedModules": true, 10 | "verbatimModuleSyntax": true, 11 | "strict": true, 12 | "noUncheckedIndexedAccess": true, 13 | "noImplicitOverride": true, 14 | "module": "preserve", 15 | "noEmit": true, 16 | "lib": ["es2022"] 17 | }, 18 | "include": ["**/*.ts", "**/*.tsx"], 19 | "exclude": ["node_modules"] 20 | } 21 | -------------------------------------------------------------------------------- /packages/api-client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "skipLibCheck": true, 5 | "target": "es2022", 6 | "allowJs": true, 7 | "resolveJsonModule": true, 8 | "moduleDetection": "force", 9 | "isolatedModules": true, 10 | "verbatimModuleSyntax": true, 11 | "strict": true, 12 | "noUncheckedIndexedAccess": true, 13 | "noImplicitOverride": true, 14 | "module": "preserve", 15 | "noEmit": true, 16 | "lib": ["es2022"] 17 | }, 18 | "include": ["**/*.ts", "**/*.tsx"], 19 | "exclude": ["node_modules"] 20 | } 21 | -------------------------------------------------------------------------------- /apps/server/drizzle/0003_far_cerise.sql: -------------------------------------------------------------------------------- 1 | DO $$ BEGIN 2 | CREATE TYPE "public"."currencyCode" AS ENUM('USD', 'EUR', 'GBP', 'JPY'); 3 | EXCEPTION 4 | WHEN duplicate_object THEN null; 5 | END $$; 6 | --> statement-breakpoint 7 | ALTER TABLE "product" DROP CONSTRAINT "product_slug_unique";--> statement-breakpoint 8 | ALTER TABLE "product" ALTER COLUMN "id" SET DATA TYPE varchar(255);--> statement-breakpoint 9 | ALTER TABLE "product" ALTER COLUMN "id" DROP IDENTITY;--> statement-breakpoint 10 | ALTER TABLE "product" DROP COLUMN IF EXISTS "slug";--> statement-breakpoint 11 | ALTER TABLE "product" DROP COLUMN IF EXISTS "price"; -------------------------------------------------------------------------------- /apps/client/app/root.tsx: -------------------------------------------------------------------------------- 1 | import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router"; 2 | import "./app.css"; 3 | 4 | export function Layout({ children }: { children: React.ReactNode }) { 5 | return ( 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | {children} 15 | 16 | 17 | 18 | 19 | ); 20 | } 21 | 22 | export default function App() { 23 | return ; 24 | } 25 | -------------------------------------------------------------------------------- /apps/server/src/database.ts: -------------------------------------------------------------------------------- 1 | import * as PgDrizzle from "@effect/sql-drizzle/Pg"; 2 | import { PgClient } from "@effect/sql-pg"; 3 | import { Cause, Config, Console, Layer } from "effect"; 4 | 5 | const PgLive = PgClient.layerConfig({ 6 | password: Config.redacted("POSTGRES_PW"), 7 | username: Config.succeed("postgres"), 8 | database: Config.succeed("postgres"), 9 | host: Config.succeed("localhost"), 10 | port: Config.succeed(5435), 11 | }).pipe(Layer.tapErrorCause((cause) => Console.log(Cause.pretty(cause)))); 12 | 13 | const DrizzleLive = PgDrizzle.layer.pipe(Layer.provide(PgLive)); 14 | 15 | export const DatabaseLive = Layer.mergeAll(PgLive, DrizzleLive); 16 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | # https://github.com/docker/awesome-compose/tree/master/postgresql-pgadmin 2 | version: "0.2" 3 | name: app_docker 4 | 5 | services: 6 | postgres: 7 | env_file: .env 8 | container_name: postgres 9 | image: postgres:16-alpine 10 | environment: 11 | - POSTGRES_USER=${POSTGRES_USER} 12 | - POSTGRES_PASSWORD=${POSTGRES_PW} 13 | - POSTGRES_DB=${POSTGRES_DB} 14 | ports: 15 | - 5435:5432 16 | 17 | pgadmin: 18 | env_file: .env 19 | container_name: pgadmin 20 | image: dpage/pgadmin4:latest 21 | environment: 22 | - PGADMIN_DEFAULT_EMAIL=${PGADMIN_MAIL} 23 | - PGADMIN_DEFAULT_PASSWORD=${PGADMIN_PW} 24 | ports: 25 | - 5050:80 26 | -------------------------------------------------------------------------------- /apps/client/app/components/Breadcrumbs.tsx: -------------------------------------------------------------------------------- 1 | import * as Aria from "react-aria-components"; 2 | import { cn } from "~/utils"; 3 | 4 | const Breadcrumbs = ({ 5 | className, 6 | ...props 7 | }: Aria.BreadcrumbsProps) => { 8 | return ( 9 | 10 | {...props} 11 | className={cn("flex items-center justify-center", className)} 12 | /> 13 | ); 14 | }; 15 | 16 | const Breadcrumb = ({ ...props }: Aria.BreadcrumbProps) => { 17 | return ( 18 | 22 | ); 23 | }; 24 | 25 | export { Breadcrumb, Breadcrumbs }; 26 | -------------------------------------------------------------------------------- /apps/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@app/server", 3 | "version": "0.0.0", 4 | "author": "Typeonce", 5 | "license": "MIT", 6 | "scripts": { 7 | "dev": "tsx watch src/main.ts", 8 | "test": "vitest", 9 | "typecheck": "tsc" 10 | }, 11 | "dependencies": { 12 | "@app/api-client": "workspace:*", 13 | "@effect/platform": "^0.79.1", 14 | "@effect/platform-node": "^0.75.1", 15 | "@effect/sql": "^0.32.1", 16 | "@effect/sql-drizzle": "^0.31.1", 17 | "@effect/sql-pg": "^0.33.1", 18 | "@paddle/paddle-node-sdk": "^1.7.0", 19 | "drizzle-kit": "^0.25.0", 20 | "drizzle-orm": "^0.34.1", 21 | "effect": "^3.13.10", 22 | "pg": "^8.13.0" 23 | }, 24 | "devDependencies": { 25 | "@effect/vitest": "^0.19.8", 26 | "@testcontainers/postgresql": "^10.13.2", 27 | "@types/node": "^22.7.4", 28 | "tsx": "^4.19.1" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /apps/client/README.md: -------------------------------------------------------------------------------- 1 | # Welcome to React Router! 2 | 3 | - 📖 [React Router docs](https://reactrouter.com/dev) 4 | 5 | ## Development 6 | 7 | Run the dev server: 8 | 9 | ```shellscript 10 | npm run dev 11 | ``` 12 | 13 | ## Deployment 14 | 15 | First, build your app for production: 16 | 17 | ```sh 18 | npm run build 19 | ``` 20 | 21 | Then run the app in production mode: 22 | 23 | ```sh 24 | npm start 25 | ``` 26 | 27 | Now you'll need to pick a host to deploy it to. 28 | 29 | ### DIY 30 | 31 | If you're familiar with deploying Node applications, the built-in app server is production-ready. 32 | 33 | Make sure to deploy the output of `npm run build` 34 | 35 | - `build/server` 36 | - `build/client` 37 | 38 | ## Styling 39 | 40 | This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever CSS framework you prefer. 41 | -------------------------------------------------------------------------------- /apps/client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "**/*.ts", 4 | "**/*.tsx", 5 | "**/.server/**/*.ts", 6 | "**/.server/**/*.tsx", 7 | "**/.client/**/*.ts", 8 | "**/.client/**/*.tsx", 9 | ".react-router/types/**/*" 10 | ], 11 | "compilerOptions": { 12 | "lib": ["DOM", "DOM.Iterable", "ES2022"], 13 | "types": ["@react-router/node", "vite/client"], 14 | "isolatedModules": true, 15 | "esModuleInterop": true, 16 | "jsx": "react-jsx", 17 | "module": "ESNext", 18 | "moduleResolution": "Bundler", 19 | "resolveJsonModule": true, 20 | "target": "ES2022", 21 | "strict": true, 22 | "allowJs": true, 23 | "skipLibCheck": true, 24 | "forceConsistentCasingInFileNames": true, 25 | "baseUrl": ".", 26 | "paths": { 27 | "~/*": ["./app/*"] 28 | }, 29 | "noEmit": true, 30 | "rootDirs": [".", "./.react-router/types"], 31 | "plugins": [{ "name": "@react-router/dev" }] 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /apps/server/src/paddle.ts: -------------------------------------------------------------------------------- 1 | import { Effect, Redacted, Schema } from "effect"; 2 | import { PaddleSdk } from "./paddle-sdk"; 3 | 4 | export class ErrorPaddle extends Schema.TaggedError()( 5 | "ErrorPaddle", 6 | { cause: Schema.Unknown } 7 | ) {} 8 | 9 | export class Paddle extends Effect.Service()("Paddle", { 10 | effect: Effect.gen(function* () { 11 | const paddle = yield* PaddleSdk; 12 | 13 | const webhooksUnmarshal = ({ 14 | paddleSignature, 15 | payload, 16 | webhookSecret, 17 | }: { 18 | payload: string; 19 | webhookSecret: Redacted.Redacted; 20 | paddleSignature: string; 21 | }) => 22 | Effect.fromNullable( 23 | paddle.webhooks.unmarshal( 24 | payload, 25 | Redacted.value(webhookSecret), 26 | paddleSignature 27 | ) 28 | ).pipe(Effect.mapError((cause) => new ErrorPaddle({ cause }))); 29 | 30 | return { webhooksUnmarshal }; 31 | }), 32 | dependencies: [PaddleSdk.Default], 33 | }) {} 34 | -------------------------------------------------------------------------------- /apps/server/src/main.ts: -------------------------------------------------------------------------------- 1 | import { MainApi } from "@app/api-client"; 2 | import { 3 | HttpApiBuilder, 4 | HttpMiddleware, 5 | HttpServer, 6 | PlatformConfigProvider, 7 | } from "@effect/platform"; 8 | import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"; 9 | import { Effect, Layer } from "effect"; 10 | import { createServer } from "node:http"; 11 | import { PaddleApiLive } from "./paddle-api"; 12 | 13 | const DotEnvConfigProvider = PlatformConfigProvider.fromDotEnv(".env").pipe( 14 | Effect.map(Layer.setConfigProvider), 15 | Layer.unwrapEffect 16 | ); 17 | 18 | const MainApiLive = HttpApiBuilder.api(MainApi).pipe( 19 | Layer.provide(PaddleApiLive), 20 | Layer.provide(DotEnvConfigProvider) 21 | ); 22 | 23 | const HttpLive = HttpApiBuilder.serve(HttpMiddleware.logger).pipe( 24 | Layer.provide(HttpApiBuilder.middlewareCors()), 25 | Layer.provide(MainApiLive), 26 | HttpServer.withLogAddress, 27 | Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })) 28 | ); 29 | 30 | Layer.launch(HttpLive).pipe(NodeRuntime.runMain); 31 | -------------------------------------------------------------------------------- /apps/client/app/services/paddle.ts: -------------------------------------------------------------------------------- 1 | import { initializePaddle } from "@paddle/paddle-js"; 2 | import { Data, Effect } from "effect"; 3 | import { PADDLE_CONTAINER_CLASS } from "~/constants"; 4 | 5 | class ErrorPaddle extends Data.TaggedError("PaddleError")<{ cause: unknown }> {} 6 | 7 | export class Paddle extends Effect.Service()("Paddle", { 8 | succeed: ({ clientToken }: { clientToken: string }) => 9 | Effect.tryPromise(() => 10 | initializePaddle({ 11 | token: clientToken, 12 | environment: "sandbox", 13 | debug: true, 14 | checkout: { 15 | settings: { 16 | displayMode: "inline", 17 | frameInitialHeight: 450, 18 | frameTarget: PADDLE_CONTAINER_CLASS, 19 | frameStyle: 20 | "width: 100%; min-width: 312px; background-color: transparent; border: none;", 21 | locale: "en", 22 | }, 23 | }, 24 | }) 25 | ).pipe( 26 | Effect.flatMap(Effect.fromNullable), 27 | Effect.mapError((cause) => new ErrorPaddle({ cause })) 28 | ), 29 | }) {} 30 | -------------------------------------------------------------------------------- /apps/server/test/pg-container.ts: -------------------------------------------------------------------------------- 1 | // https://github.com/Effect-TS/effect/blob/main/packages/sql-pg/test/utils.ts 2 | import { PgClient } from "@effect/sql-pg"; 3 | import { PostgreSqlContainer } from "@testcontainers/postgresql"; 4 | import { Config, Data, Effect, Layer, Redacted } from "effect"; 5 | 6 | export class ContainerError extends Data.TaggedError("ContainerError")<{ 7 | cause: unknown; 8 | }> {} 9 | 10 | export class PgContainer extends Effect.Service()("PgContainer", { 11 | scoped: Effect.acquireRelease( 12 | Effect.tryPromise({ 13 | try: () => new PostgreSqlContainer("postgres:16-alpine").start(), 14 | catch: (cause) => new ContainerError({ cause }), 15 | }), 16 | (container) => Effect.promise(() => container.stop()) 17 | ), 18 | }) { 19 | static ClientLive = Layer.unwrapEffect( 20 | Effect.gen(function* () { 21 | const container = yield* PgContainer; 22 | return PgClient.layerConfig({ 23 | url: Config.succeed(Redacted.make(container.getConnectionUri())), 24 | }); 25 | }) 26 | ).pipe(Layer.provide(this.Default)); 27 | } 28 | -------------------------------------------------------------------------------- /packages/api-client/src/schemas/paddle.ts: -------------------------------------------------------------------------------- 1 | import { Schema } from "effect"; 2 | 3 | export const EntityId = Schema.NonEmptyString.pipe(Schema.brand("EntityId")); 4 | export const CurrencyCode = Schema.Literal( 5 | "USD", 6 | "EUR", 7 | "GBP", 8 | "JPY", 9 | "AUD", 10 | "CAD", 11 | "CHF", 12 | "HKD", 13 | "SGD", 14 | "SEK", 15 | "ARS", 16 | "BRL", 17 | "CNY", 18 | "COP", 19 | "CZK", 20 | "DKK", 21 | "HUF", 22 | "ILS", 23 | "INR", 24 | "KRW", 25 | "MXN", 26 | "NOK", 27 | "NZD", 28 | "PLN", 29 | "RUB", 30 | "THB", 31 | "TRY", 32 | "TWD", 33 | "UAH", 34 | "ZAR" 35 | ); 36 | 37 | export class PaddleProduct extends Schema.Class("PaddleProduct")( 38 | { 39 | id: EntityId, 40 | slug: Schema.NonEmptyString, 41 | name: Schema.String, 42 | description: Schema.NullOr(Schema.String), 43 | imageUrl: Schema.NullOr(Schema.String), 44 | } 45 | ) {} 46 | 47 | export class PaddlePrice extends Schema.Class("PaddlePrice")({ 48 | id: EntityId, 49 | productId: EntityId, 50 | amount: Schema.NonEmptyString, 51 | currencyCode: CurrencyCode, 52 | }) {} 53 | -------------------------------------------------------------------------------- /apps/server/drizzle/meta/0000_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "3fd48be9-ad7a-4e47-aec6-11fc4d7acc12", 3 | "prevId": "00000000-0000-0000-0000-000000000000", 4 | "version": "7", 5 | "dialect": "postgresql", 6 | "tables": { 7 | "public.product": { 8 | "name": "product", 9 | "schema": "", 10 | "columns": { 11 | "id": { 12 | "name": "id", 13 | "type": "integer", 14 | "primaryKey": true, 15 | "notNull": true 16 | }, 17 | "name": { 18 | "name": "name", 19 | "type": "varchar(255)", 20 | "primaryKey": false, 21 | "notNull": false 22 | }, 23 | "price": { 24 | "name": "price", 25 | "type": "integer", 26 | "primaryKey": false, 27 | "notNull": true 28 | } 29 | }, 30 | "indexes": {}, 31 | "foreignKeys": {}, 32 | "compositePrimaryKeys": {}, 33 | "uniqueConstraints": {} 34 | } 35 | }, 36 | "enums": {}, 37 | "schemas": {}, 38 | "sequences": {}, 39 | "_meta": { 40 | "columns": {}, 41 | "schemas": {}, 42 | "tables": {} 43 | } 44 | } -------------------------------------------------------------------------------- /apps/server/drizzle/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "7", 3 | "dialect": "postgresql", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "7", 8 | "when": 1728724600821, 9 | "tag": "0000_foamy_sharon_carter", 10 | "breakpoints": true 11 | }, 12 | { 13 | "idx": 1, 14 | "version": "7", 15 | "when": 1728741033890, 16 | "tag": "0001_bouncy_cardiac", 17 | "breakpoints": true 18 | }, 19 | { 20 | "idx": 2, 21 | "version": "7", 22 | "when": 1728827423141, 23 | "tag": "0002_complete_mentallo", 24 | "breakpoints": true 25 | }, 26 | { 27 | "idx": 3, 28 | "version": "7", 29 | "when": 1728916107105, 30 | "tag": "0003_far_cerise", 31 | "breakpoints": true 32 | }, 33 | { 34 | "idx": 4, 35 | "version": "7", 36 | "when": 1728916681197, 37 | "tag": "0004_many_frog_thor", 38 | "breakpoints": true 39 | }, 40 | { 41 | "idx": 5, 42 | "version": "7", 43 | "when": 1728917160257, 44 | "tag": "0005_friendly_stick", 45 | "breakpoints": true 46 | } 47 | ] 48 | } -------------------------------------------------------------------------------- /apps/client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@app/client", 3 | "version": "0.0.0", 4 | "private": true, 5 | "sideEffects": false, 6 | "type": "module", 7 | "scripts": { 8 | "dev": "react-router dev", 9 | "build": "react-router build", 10 | "start": "react-router-serve ./build/server/index.js", 11 | "typecheck": "react-router typegen && tsc" 12 | }, 13 | "dependencies": { 14 | "@app/api-client": "workspace:*", 15 | "@effect/platform": "^0.79.1", 16 | "@paddle/paddle-js": "^1.2.3", 17 | "@react-router/node": "^7.3.0", 18 | "@react-router/serve": "^7.3.0", 19 | "@xstate/react": "^5.0.3", 20 | "clsx": "^2.1.1", 21 | "effect": "^3.13.10", 22 | "isbot": "^5.1.17", 23 | "react": "^19.0.0", 24 | "react-aria-components": "^1.4.0", 25 | "react-dom": "^19.0.0", 26 | "react-router": "^7.3.0", 27 | "tailwind-merge": "^2.5.3", 28 | "xstate": "^5.19.2" 29 | }, 30 | "devDependencies": { 31 | "@react-router/dev": "^7.3.0", 32 | "@tailwindcss/vite": "^4.0.13", 33 | "@types/react": "^19.0.1", 34 | "@types/react-dom": "^19.0.1", 35 | "tailwindcss": "^4.0.13", 36 | "vite": "^5.4.8", 37 | "vite-tsconfig-paths": "^5.0.1" 38 | }, 39 | "engines": { 40 | "node": ">=20.0.0" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /apps/server/src/schema/drizzle.ts: -------------------------------------------------------------------------------- 1 | import { relations } from "drizzle-orm"; 2 | import { pgEnum, pgTable, varchar } from "drizzle-orm/pg-core"; 3 | 4 | export const currencyCodeEnum = pgEnum("currencyCode", [ 5 | "USD", 6 | "EUR", 7 | "GBP", 8 | "JPY", 9 | "AUD", 10 | "CAD", 11 | "CHF", 12 | "HKD", 13 | "SGD", 14 | "SEK", 15 | "ARS", 16 | "BRL", 17 | "CNY", 18 | "COP", 19 | "CZK", 20 | "DKK", 21 | "HUF", 22 | "ILS", 23 | "INR", 24 | "KRW", 25 | "MXN", 26 | "NOK", 27 | "NZD", 28 | "PLN", 29 | "RUB", 30 | "THB", 31 | "TRY", 32 | "TWD", 33 | "UAH", 34 | "ZAR", 35 | ]); 36 | 37 | export const productTable = pgTable("product", { 38 | id: varchar({ length: 255 }).notNull().primaryKey(), 39 | slug: varchar({ length: 255 }).notNull().unique(), 40 | name: varchar({ length: 255 }).notNull(), 41 | description: varchar({ length: 255 }), 42 | imageUrl: varchar({ length: 255 }), 43 | }); 44 | 45 | export const priceTable = pgTable("price", { 46 | id: varchar({ length: 255 }).notNull().primaryKey(), 47 | productId: varchar({ length: 255 }).references(() => productTable.id), 48 | amount: varchar({ length: 255 }).notNull(), 49 | currencyCode: currencyCodeEnum().notNull(), 50 | }); 51 | 52 | export const priceProductRelation = relations(productTable, ({ many }) => ({ 53 | prices: many(priceTable), 54 | })); 55 | -------------------------------------------------------------------------------- /apps/client/app/routes/home.tsx: -------------------------------------------------------------------------------- 1 | import { useMachine } from "@xstate/react"; 2 | import { Config, Effect } from "effect"; 3 | import { Breadcrumb, Breadcrumbs } from "~/components/Breadcrumbs"; 4 | import { PADDLE_CONTAINER_CLASS } from "~/constants"; 5 | import { machine } from "~/machines/paddle-machine"; 6 | import type { Route } from "./+types/home"; 7 | 8 | export async function loader() { 9 | return Effect.runPromise( 10 | Config.all({ paddleClientToken: Config.string("PADDLE_CLIENT_TOKEN") }) 11 | ); 12 | } 13 | 14 | // Testing cards: https://developer.paddle.com/concepts/payment-methods/credit-debit-card#test-payment-method 15 | export default function Index({ 16 | loaderData, 17 | params: { slug }, 18 | }: Route.ComponentProps) { 19 | const [snapshot] = useMachine(machine, { 20 | input: { clientToken: loaderData.paddleClientToken, slug }, 21 | }); 22 | return ( 23 |
24 | 25 | 26 | Customer 27 | 28 | 29 | Checkout 30 | 31 | 32 | Success 33 | 34 | 35 |
36 |
37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /apps/server/src/paddle-sdk.ts: -------------------------------------------------------------------------------- 1 | import * as _Paddle from "@paddle/paddle-node-sdk"; 2 | import { Config, Effect, Layer, Redacted } from "effect"; 3 | 4 | export class PaddleSdk extends Effect.Service()("PaddleSdk", { 5 | effect: Effect.gen(function* () { 6 | const apiKey = yield* Config.redacted("PADDLE_API_KEY"); 7 | return new _Paddle.Paddle(Redacted.value(apiKey), { 8 | environment: _Paddle.Environment.sandbox, 9 | logLevel: _Paddle.LogLevel.verbose, 10 | }); 11 | }), 12 | }) { 13 | static readonly Test = Layer.effect( 14 | this, 15 | Effect.sync(() => { 16 | class Test extends _Paddle.Paddle { 17 | override webhooks: _Paddle.Webhooks = { 18 | unmarshal(requestBody, secretKey, signature) { 19 | return { 20 | eventType: _Paddle.EventName.CustomerCreated, 21 | } as _Paddle.EventEntity; 22 | }, 23 | } as _Paddle.Webhooks; 24 | 25 | override products: _Paddle.ProductsResource = { 26 | async get(productId) { 27 | return { 28 | id: productId, 29 | name: "Test", 30 | description: "Test", 31 | imageUrl: "https://example.com/image.png", 32 | price: 100, 33 | } as unknown as _Paddle.Product; 34 | }, 35 | } as _Paddle.ProductsResource; 36 | } 37 | 38 | return PaddleSdk.make(new Test("")); 39 | }) 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /apps/server/drizzle/meta/0003_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "655d7e98-ff5d-4c87-afae-965b5d03d2f3", 3 | "prevId": "99b1e63b-3c54-4788-a7d4-5a336d4799bb", 4 | "version": "7", 5 | "dialect": "postgresql", 6 | "tables": { 7 | "public.product": { 8 | "name": "product", 9 | "schema": "", 10 | "columns": { 11 | "id": { 12 | "name": "id", 13 | "type": "varchar(255)", 14 | "primaryKey": true, 15 | "notNull": true 16 | }, 17 | "name": { 18 | "name": "name", 19 | "type": "varchar(255)", 20 | "primaryKey": false, 21 | "notNull": true 22 | }, 23 | "description": { 24 | "name": "description", 25 | "type": "varchar(255)", 26 | "primaryKey": false, 27 | "notNull": false 28 | }, 29 | "imageUrl": { 30 | "name": "imageUrl", 31 | "type": "varchar(255)", 32 | "primaryKey": false, 33 | "notNull": false 34 | } 35 | }, 36 | "indexes": {}, 37 | "foreignKeys": {}, 38 | "compositePrimaryKeys": {}, 39 | "uniqueConstraints": {} 40 | } 41 | }, 42 | "enums": { 43 | "public.currencyCode": { 44 | "name": "currencyCode", 45 | "schema": "public", 46 | "values": [ 47 | "USD", 48 | "EUR", 49 | "GBP", 50 | "JPY" 51 | ] 52 | } 53 | }, 54 | "schemas": {}, 55 | "sequences": {}, 56 | "_meta": { 57 | "columns": {}, 58 | "schemas": {}, 59 | "tables": {} 60 | } 61 | } -------------------------------------------------------------------------------- /packages/api-client/src/api.ts: -------------------------------------------------------------------------------- 1 | import { HttpApi, HttpApiEndpoint, HttpApiGroup } from "@effect/platform"; 2 | import { Schema } from "effect"; 3 | import { PaddlePrice, PaddleProduct } from "./schemas/paddle"; 4 | 5 | export class ErrorWebhook extends Schema.TaggedError()( 6 | "ErrorWebhook", 7 | { 8 | reason: Schema.Literal( 9 | "missing-secret", 10 | "verify-signature", 11 | "query-error", 12 | "missing-payload" 13 | ), 14 | } 15 | ) {} 16 | 17 | export class ErrorInvalidProduct extends Schema.TaggedError()( 18 | "ErrorInvalidProduct", 19 | {} 20 | ) {} 21 | 22 | export class ErrorSqlQuery extends Schema.TaggedError()( 23 | "ErrorSqlQuery", 24 | {} 25 | ) {} 26 | 27 | export class PaddleApiGroup extends HttpApiGroup.make("paddle") 28 | .add( 29 | HttpApiEndpoint.post("webhook", "/paddle/webhook") 30 | .addError(ErrorWebhook) 31 | .addSuccess(Schema.Boolean) 32 | .setHeaders( 33 | Schema.Struct({ 34 | "paddle-signature": Schema.NonEmptyString, 35 | }) 36 | ) 37 | ) 38 | .add( 39 | HttpApiEndpoint.get("product", "/paddle/product/:slug") 40 | .addError(ErrorInvalidProduct) 41 | .addSuccess( 42 | Schema.Struct({ 43 | product: PaddleProduct, 44 | price: PaddlePrice, 45 | }) 46 | ) 47 | .setPath( 48 | Schema.Struct({ 49 | slug: Schema.NonEmptyString, 50 | }) 51 | ) 52 | ) {} 53 | 54 | export class MainApi extends HttpApi.make("MainApi").add(PaddleApiGroup) {} 55 | -------------------------------------------------------------------------------- /apps/server/drizzle/meta/0001_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "d3f7dbb4-2b46-4deb-98eb-0f7a26248368", 3 | "prevId": "3fd48be9-ad7a-4e47-aec6-11fc4d7acc12", 4 | "version": "7", 5 | "dialect": "postgresql", 6 | "tables": { 7 | "public.product": { 8 | "name": "product", 9 | "schema": "", 10 | "columns": { 11 | "id": { 12 | "name": "id", 13 | "type": "integer", 14 | "primaryKey": true, 15 | "notNull": true, 16 | "identity": { 17 | "type": "always", 18 | "name": "product_id_seq", 19 | "schema": "public", 20 | "increment": "1", 21 | "startWith": "1", 22 | "minValue": "1", 23 | "maxValue": "2147483647", 24 | "cache": "1", 25 | "cycle": false 26 | } 27 | }, 28 | "name": { 29 | "name": "name", 30 | "type": "varchar(255)", 31 | "primaryKey": false, 32 | "notNull": true 33 | }, 34 | "description": { 35 | "name": "description", 36 | "type": "varchar(255)", 37 | "primaryKey": false, 38 | "notNull": false 39 | }, 40 | "imageUrl": { 41 | "name": "imageUrl", 42 | "type": "varchar(255)", 43 | "primaryKey": false, 44 | "notNull": false 45 | }, 46 | "price": { 47 | "name": "price", 48 | "type": "integer", 49 | "primaryKey": false, 50 | "notNull": true 51 | } 52 | }, 53 | "indexes": {}, 54 | "foreignKeys": {}, 55 | "compositePrimaryKeys": {}, 56 | "uniqueConstraints": {} 57 | } 58 | }, 59 | "enums": {}, 60 | "schemas": {}, 61 | "sequences": {}, 62 | "_meta": { 63 | "columns": {}, 64 | "schemas": {}, 65 | "tables": {} 66 | } 67 | } -------------------------------------------------------------------------------- /apps/server/drizzle/0004_many_frog_thor.sql: -------------------------------------------------------------------------------- 1 | ALTER TYPE "currencyCode" ADD VALUE 'AUD';--> statement-breakpoint 2 | ALTER TYPE "currencyCode" ADD VALUE 'CAD';--> statement-breakpoint 3 | ALTER TYPE "currencyCode" ADD VALUE 'CHF';--> statement-breakpoint 4 | ALTER TYPE "currencyCode" ADD VALUE 'HKD';--> statement-breakpoint 5 | ALTER TYPE "currencyCode" ADD VALUE 'SGD';--> statement-breakpoint 6 | ALTER TYPE "currencyCode" ADD VALUE 'SEK';--> statement-breakpoint 7 | ALTER TYPE "currencyCode" ADD VALUE 'ARS';--> statement-breakpoint 8 | ALTER TYPE "currencyCode" ADD VALUE 'BRL';--> statement-breakpoint 9 | ALTER TYPE "currencyCode" ADD VALUE 'CNY';--> statement-breakpoint 10 | ALTER TYPE "currencyCode" ADD VALUE 'COP';--> statement-breakpoint 11 | ALTER TYPE "currencyCode" ADD VALUE 'CZK';--> statement-breakpoint 12 | ALTER TYPE "currencyCode" ADD VALUE 'DKK';--> statement-breakpoint 13 | ALTER TYPE "currencyCode" ADD VALUE 'HUF';--> statement-breakpoint 14 | ALTER TYPE "currencyCode" ADD VALUE 'ILS';--> statement-breakpoint 15 | ALTER TYPE "currencyCode" ADD VALUE 'INR';--> statement-breakpoint 16 | ALTER TYPE "currencyCode" ADD VALUE 'KRW';--> statement-breakpoint 17 | ALTER TYPE "currencyCode" ADD VALUE 'MXN';--> statement-breakpoint 18 | ALTER TYPE "currencyCode" ADD VALUE 'NOK';--> statement-breakpoint 19 | ALTER TYPE "currencyCode" ADD VALUE 'NZD';--> statement-breakpoint 20 | ALTER TYPE "currencyCode" ADD VALUE 'PLN';--> statement-breakpoint 21 | ALTER TYPE "currencyCode" ADD VALUE 'RUB';--> statement-breakpoint 22 | ALTER TYPE "currencyCode" ADD VALUE 'THB';--> statement-breakpoint 23 | ALTER TYPE "currencyCode" ADD VALUE 'TRY';--> statement-breakpoint 24 | ALTER TYPE "currencyCode" ADD VALUE 'TWD';--> statement-breakpoint 25 | ALTER TYPE "currencyCode" ADD VALUE 'UAH';--> statement-breakpoint 26 | ALTER TYPE "currencyCode" ADD VALUE 'ZAR';--> statement-breakpoint 27 | ALTER TABLE "product" ADD COLUMN "slug" varchar(255) NOT NULL;--> statement-breakpoint 28 | ALTER TABLE "product" ADD CONSTRAINT "product_slug_unique" UNIQUE("slug"); -------------------------------------------------------------------------------- /apps/server/drizzle/meta/0002_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "99b1e63b-3c54-4788-a7d4-5a336d4799bb", 3 | "prevId": "d3f7dbb4-2b46-4deb-98eb-0f7a26248368", 4 | "version": "7", 5 | "dialect": "postgresql", 6 | "tables": { 7 | "public.product": { 8 | "name": "product", 9 | "schema": "", 10 | "columns": { 11 | "id": { 12 | "name": "id", 13 | "type": "integer", 14 | "primaryKey": true, 15 | "notNull": true, 16 | "identity": { 17 | "type": "always", 18 | "name": "product_id_seq", 19 | "schema": "public", 20 | "increment": "1", 21 | "startWith": "1", 22 | "minValue": "1", 23 | "maxValue": "2147483647", 24 | "cache": "1", 25 | "cycle": false 26 | } 27 | }, 28 | "slug": { 29 | "name": "slug", 30 | "type": "varchar(255)", 31 | "primaryKey": false, 32 | "notNull": true 33 | }, 34 | "name": { 35 | "name": "name", 36 | "type": "varchar(255)", 37 | "primaryKey": false, 38 | "notNull": true 39 | }, 40 | "price": { 41 | "name": "price", 42 | "type": "integer", 43 | "primaryKey": false, 44 | "notNull": true 45 | }, 46 | "description": { 47 | "name": "description", 48 | "type": "varchar(255)", 49 | "primaryKey": false, 50 | "notNull": false 51 | }, 52 | "imageUrl": { 53 | "name": "imageUrl", 54 | "type": "varchar(255)", 55 | "primaryKey": false, 56 | "notNull": false 57 | } 58 | }, 59 | "indexes": {}, 60 | "foreignKeys": {}, 61 | "compositePrimaryKeys": {}, 62 | "uniqueConstraints": { 63 | "product_slug_unique": { 64 | "name": "product_slug_unique", 65 | "nullsNotDistinct": false, 66 | "columns": [ 67 | "slug" 68 | ] 69 | } 70 | } 71 | } 72 | }, 73 | "enums": {}, 74 | "schemas": {}, 75 | "sequences": {}, 76 | "_meta": { 77 | "columns": {}, 78 | "schemas": {}, 79 | "tables": {} 80 | } 81 | } -------------------------------------------------------------------------------- /apps/server/drizzle/meta/0004_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "bddf8439-4058-4aa8-a586-242249d5542f", 3 | "prevId": "655d7e98-ff5d-4c87-afae-965b5d03d2f3", 4 | "version": "7", 5 | "dialect": "postgresql", 6 | "tables": { 7 | "public.product": { 8 | "name": "product", 9 | "schema": "", 10 | "columns": { 11 | "id": { 12 | "name": "id", 13 | "type": "varchar(255)", 14 | "primaryKey": true, 15 | "notNull": true 16 | }, 17 | "slug": { 18 | "name": "slug", 19 | "type": "varchar(255)", 20 | "primaryKey": false, 21 | "notNull": true 22 | }, 23 | "name": { 24 | "name": "name", 25 | "type": "varchar(255)", 26 | "primaryKey": false, 27 | "notNull": true 28 | }, 29 | "description": { 30 | "name": "description", 31 | "type": "varchar(255)", 32 | "primaryKey": false, 33 | "notNull": false 34 | }, 35 | "imageUrl": { 36 | "name": "imageUrl", 37 | "type": "varchar(255)", 38 | "primaryKey": false, 39 | "notNull": false 40 | } 41 | }, 42 | "indexes": {}, 43 | "foreignKeys": {}, 44 | "compositePrimaryKeys": {}, 45 | "uniqueConstraints": { 46 | "product_slug_unique": { 47 | "name": "product_slug_unique", 48 | "nullsNotDistinct": false, 49 | "columns": [ 50 | "slug" 51 | ] 52 | } 53 | } 54 | } 55 | }, 56 | "enums": { 57 | "public.currencyCode": { 58 | "name": "currencyCode", 59 | "schema": "public", 60 | "values": [ 61 | "USD", 62 | "EUR", 63 | "GBP", 64 | "JPY", 65 | "AUD", 66 | "CAD", 67 | "CHF", 68 | "HKD", 69 | "SGD", 70 | "SEK", 71 | "ARS", 72 | "BRL", 73 | "CNY", 74 | "COP", 75 | "CZK", 76 | "DKK", 77 | "HUF", 78 | "ILS", 79 | "INR", 80 | "KRW", 81 | "MXN", 82 | "NOK", 83 | "NZD", 84 | "PLN", 85 | "RUB", 86 | "THB", 87 | "TRY", 88 | "TWD", 89 | "UAH", 90 | "ZAR" 91 | ] 92 | } 93 | }, 94 | "schemas": {}, 95 | "sequences": {}, 96 | "_meta": { 97 | "columns": {}, 98 | "schemas": {}, 99 | "tables": {} 100 | } 101 | } -------------------------------------------------------------------------------- /apps/server/drizzle/meta/0005_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "20937826-0559-497a-a507-67bed64e29b4", 3 | "prevId": "bddf8439-4058-4aa8-a586-242249d5542f", 4 | "version": "7", 5 | "dialect": "postgresql", 6 | "tables": { 7 | "public.price": { 8 | "name": "price", 9 | "schema": "", 10 | "columns": { 11 | "id": { 12 | "name": "id", 13 | "type": "varchar(255)", 14 | "primaryKey": true, 15 | "notNull": true 16 | }, 17 | "productId": { 18 | "name": "productId", 19 | "type": "varchar(255)", 20 | "primaryKey": false, 21 | "notNull": false 22 | }, 23 | "amount": { 24 | "name": "amount", 25 | "type": "varchar(255)", 26 | "primaryKey": false, 27 | "notNull": true 28 | }, 29 | "currencyCode": { 30 | "name": "currencyCode", 31 | "type": "currencyCode", 32 | "typeSchema": "public", 33 | "primaryKey": false, 34 | "notNull": true 35 | } 36 | }, 37 | "indexes": {}, 38 | "foreignKeys": { 39 | "price_productId_product_id_fk": { 40 | "name": "price_productId_product_id_fk", 41 | "tableFrom": "price", 42 | "tableTo": "product", 43 | "columnsFrom": [ 44 | "productId" 45 | ], 46 | "columnsTo": [ 47 | "id" 48 | ], 49 | "onDelete": "no action", 50 | "onUpdate": "no action" 51 | } 52 | }, 53 | "compositePrimaryKeys": {}, 54 | "uniqueConstraints": {} 55 | }, 56 | "public.product": { 57 | "name": "product", 58 | "schema": "", 59 | "columns": { 60 | "id": { 61 | "name": "id", 62 | "type": "varchar(255)", 63 | "primaryKey": true, 64 | "notNull": true 65 | }, 66 | "slug": { 67 | "name": "slug", 68 | "type": "varchar(255)", 69 | "primaryKey": false, 70 | "notNull": true 71 | }, 72 | "name": { 73 | "name": "name", 74 | "type": "varchar(255)", 75 | "primaryKey": false, 76 | "notNull": true 77 | }, 78 | "description": { 79 | "name": "description", 80 | "type": "varchar(255)", 81 | "primaryKey": false, 82 | "notNull": false 83 | }, 84 | "imageUrl": { 85 | "name": "imageUrl", 86 | "type": "varchar(255)", 87 | "primaryKey": false, 88 | "notNull": false 89 | } 90 | }, 91 | "indexes": {}, 92 | "foreignKeys": {}, 93 | "compositePrimaryKeys": {}, 94 | "uniqueConstraints": { 95 | "product_slug_unique": { 96 | "name": "product_slug_unique", 97 | "nullsNotDistinct": false, 98 | "columns": [ 99 | "slug" 100 | ] 101 | } 102 | } 103 | } 104 | }, 105 | "enums": { 106 | "public.currencyCode": { 107 | "name": "currencyCode", 108 | "schema": "public", 109 | "values": [ 110 | "USD", 111 | "EUR", 112 | "GBP", 113 | "JPY", 114 | "AUD", 115 | "CAD", 116 | "CHF", 117 | "HKD", 118 | "SGD", 119 | "SEK", 120 | "ARS", 121 | "BRL", 122 | "CNY", 123 | "COP", 124 | "CZK", 125 | "DKK", 126 | "HUF", 127 | "ILS", 128 | "INR", 129 | "KRW", 130 | "MXN", 131 | "NOK", 132 | "NZD", 133 | "PLN", 134 | "RUB", 135 | "THB", 136 | "TRY", 137 | "TWD", 138 | "UAH", 139 | "ZAR" 140 | ] 141 | } 142 | }, 143 | "schemas": {}, 144 | "sequences": {}, 145 | "_meta": { 146 | "columns": {}, 147 | "schemas": {}, 148 | "tables": {} 149 | } 150 | } -------------------------------------------------------------------------------- /apps/client/app/machines/paddle-machine.ts: -------------------------------------------------------------------------------- 1 | import type { PaddlePrice, PaddleProduct } from "@app/api-client/schemas"; 2 | import { CheckoutEventNames, type Paddle as _Paddle } from "@paddle/paddle-js"; 3 | import { Effect } from "effect"; 4 | import { assertEvent, assign, fromPromise, setup } from "xstate"; 5 | import { Api } from "~/services/api"; 6 | import { Paddle } from "~/services/paddle"; 7 | import { RuntimeClient } from "~/services/runtime-client"; 8 | 9 | type Input = { slug: string; clientToken: string }; 10 | 11 | const loadProductActor = fromPromise( 12 | ({ 13 | input: { slug }, 14 | }: { 15 | input: { 16 | slug: string; 17 | }; 18 | }) => 19 | RuntimeClient.runPromise( 20 | Effect.gen(function* () { 21 | const api = yield* Api; 22 | return yield* api.paddle.product({ path: { slug } }); 23 | }) 24 | ) 25 | ); 26 | 27 | const paddleInitActor = fromPromise( 28 | ({ 29 | input: { clientToken }, 30 | }: { 31 | input: { 32 | clientToken: string; 33 | }; 34 | }) => 35 | RuntimeClient.runPromise( 36 | Effect.gen(function* () { 37 | const _ = yield* Paddle; 38 | return yield* _({ clientToken }); 39 | }) 40 | ) 41 | ); 42 | 43 | export const machine = setup({ 44 | types: { 45 | input: {} as Input, 46 | context: {} as { 47 | paddle: _Paddle | null; 48 | clientToken: string; 49 | product: PaddleProduct | null; 50 | price: PaddlePrice | null; 51 | }, 52 | events: {} as 53 | | Readonly<{ type: "xstate.init"; input: Input }> 54 | | Readonly<{ type: "checkout.completed" }> 55 | | Readonly<{ type: "checkout.created" }>, 56 | }, 57 | actors: { loadProductActor, paddleInitActor }, 58 | }).createMachine({ 59 | id: "paddle-machine", 60 | context: ({ input }) => ({ 61 | paddle: null, 62 | product: null, 63 | price: null, 64 | clientToken: input.clientToken, 65 | }), 66 | initial: "LoadingProduct", 67 | states: { 68 | LoadingProduct: { 69 | invoke: { 70 | src: "loadProductActor", 71 | input: ({ event }) => { 72 | assertEvent(event, "xstate.init"); 73 | return { slug: event.input.slug }; 74 | }, 75 | onError: { target: "Error" }, 76 | onDone: { 77 | target: "Init", 78 | actions: assign(({ event }) => ({ 79 | product: event.output.product, 80 | price: event.output.price, 81 | })), 82 | }, 83 | }, 84 | }, 85 | Init: { 86 | invoke: { 87 | src: "paddleInitActor", 88 | input: ({ context }) => ({ clientToken: context.clientToken }), 89 | onError: { target: "Error" }, 90 | onDone: { 91 | target: "Customer", 92 | actions: assign(({ event }) => ({ paddle: event.output })), 93 | }, 94 | }, 95 | }, 96 | Customer: { 97 | always: { 98 | target: "Error", 99 | guard: ({ context }) => !context.product, 100 | }, 101 | entry: [ 102 | ({ context, self }) => 103 | context.paddle?.Update({ 104 | eventCallback: (event) => { 105 | if (event.name === CheckoutEventNames.CHECKOUT_CUSTOMER_CREATED) { 106 | self.send({ type: "checkout.created" }); 107 | } else if (event.name === CheckoutEventNames.CHECKOUT_COMPLETED) { 108 | self.send({ type: "checkout.completed" }); 109 | } 110 | }, 111 | }), 112 | ({ context }) => 113 | context.paddle?.Checkout.open({ 114 | items: [{ priceId: context.price?.id!, quantity: 1 }], 115 | }), 116 | ], 117 | on: { 118 | "checkout.created": { target: "Checkout" }, 119 | }, 120 | }, 121 | Checkout: { 122 | on: { 123 | "checkout.completed": { target: "Success" }, 124 | }, 125 | }, 126 | Error: {}, 127 | Success: { 128 | type: "final", 129 | }, 130 | }, 131 | }); 132 | -------------------------------------------------------------------------------- /apps/server/test/paddle.test.ts: -------------------------------------------------------------------------------- 1 | import { MainApi } from "@app/api-client"; 2 | import { EntityId, PaddleProduct } from "@app/api-client/schemas"; 3 | import { HttpApi, HttpApiBuilder, HttpApiClient } from "@effect/platform"; 4 | import { NodeHttpServer } from "@effect/platform-node"; 5 | import * as PgDrizzle from "@effect/sql-drizzle/Pg"; 6 | import { expect, it } from "@effect/vitest"; 7 | import { sql } from "drizzle-orm"; 8 | import { ConfigProvider, Effect, Layer } from "effect"; 9 | import { Paddle } from "../src/paddle"; 10 | import { PaddleApi, PaddleApiLive } from "../src/paddle-api"; 11 | import { PaddleSdk } from "../src/paddle-sdk"; 12 | import { priceTable, productTable } from "../src/schema/drizzle"; 13 | import { PgContainer } from "./pg-container"; 14 | 15 | const TestConfigProvider = Layer.setConfigProvider( 16 | ConfigProvider.fromMap( 17 | new Map([ 18 | ["PADDLE_API_KEY", ""], 19 | ["POSTGRES_PW", ""], 20 | ["WEBHOOK_SECRET_KEY", ""], 21 | ]) 22 | ) 23 | ); 24 | 25 | // https://discord.com/channels/795981131316985866/1294957004791484476/1296039483782856777 26 | const HttpGroupTest = ( 27 | api: Api, 28 | groupLayer: Layer.Layer 29 | ) => 30 | HttpApiBuilder.serve().pipe( 31 | Layer.provideMerge( 32 | Layer.mergeAll( 33 | groupLayer, 34 | HttpApiBuilder.api(api as any as HttpApi.HttpApi), 35 | NodeHttpServer.layerTest 36 | ) 37 | ) 38 | ); 39 | 40 | const LayerTest = HttpGroupTest( 41 | MainApi, 42 | PaddleApiLive.pipe( 43 | Layer.provideMerge( 44 | Layer.mergeAll( 45 | PaddleApi.Default, 46 | Paddle.Default.pipe(Layer.provide(PaddleSdk.Test)), 47 | PgDrizzle.layer.pipe(Layer.provide(PgContainer.ClientLive)) 48 | ) 49 | ) 50 | ) 51 | ).pipe(Layer.provide(TestConfigProvider)); 52 | 53 | it.layer(LayerTest, { timeout: "30 seconds" })("MainApi", (it) => { 54 | it.effect("paddle api webhook success", () => 55 | Effect.gen(function* () { 56 | const client = yield* HttpApiClient.make(MainApi); 57 | const result = yield* client.paddle.webhook({ 58 | headers: { 59 | "paddle-signature": "---", 60 | }, 61 | }); 62 | expect(result).toBe(true); 63 | }) 64 | ); 65 | 66 | it.effect("api get product", () => 67 | Effect.gen(function* () { 68 | const client = yield* HttpApiClient.make(MainApi); 69 | const drizzle = yield* PgDrizzle.PgDrizzle; 70 | 71 | yield* drizzle.execute(sql` 72 | DO $$ BEGIN 73 | CREATE TYPE "public"."currencyCode" AS ENUM('USD','EUR','GBP','JPY','AUD','CAD','CHF','HKD','SGD','SEK','ARS','BRL','CNY','COP','CZK','DKK','HUF','ILS','INR','KRW','MXN','NOK','NZD','PLN','RUB','THB','TRY','TWD','UAH', 'ZAR'); 74 | EXCEPTION 75 | WHEN duplicate_object THEN null; 76 | END $$; 77 | 78 | CREATE TABLE IF NOT EXISTS "product" ( 79 | "id" varchar(255) PRIMARY KEY NOT NULL, 80 | "slug" varchar(255) NOT NULL, 81 | "name" varchar(255) NOT NULL, 82 | "description" varchar(255), 83 | "imageUrl" varchar(255) 84 | ); 85 | 86 | CREATE TABLE IF NOT EXISTS "price" ( 87 | "id" varchar(255) PRIMARY KEY NOT NULL, 88 | "productId" varchar(255), 89 | "amount" varchar(255) NOT NULL, 90 | "currencyCode" "currencyCode" NOT NULL 91 | ); 92 | --> statement-breakpoint 93 | DO $$ BEGIN 94 | ALTER TABLE "price" ADD CONSTRAINT "price_productId_product_id_fk" FOREIGN KEY ("productId") REFERENCES "public"."product"("id") ON DELETE no action ON UPDATE no action; 95 | EXCEPTION 96 | WHEN duplicate_object THEN null; 97 | END $$; 98 | `); 99 | 100 | yield* drizzle.insert(productTable).values({ 101 | slug: "test", 102 | name: "Test", 103 | description: "Test", 104 | imageUrl: "https://example.com/image.png", 105 | id: "test", 106 | }); 107 | 108 | yield* drizzle.insert(priceTable).values({ 109 | productId: "test", 110 | amount: "100", 111 | currencyCode: "USD", 112 | id: "test", 113 | }); 114 | 115 | const { product } = yield* client.paddle.product({ 116 | path: { slug: "test" }, 117 | }); 118 | 119 | expect(product).toStrictEqual( 120 | PaddleProduct.make({ 121 | id: EntityId.make("test"), 122 | slug: "test", 123 | name: "Test", 124 | price: 100, 125 | description: "Test", 126 | imageUrl: "https://example.com/image.png", 127 | }) 128 | ); 129 | }) 130 | ); 131 | }); 132 | -------------------------------------------------------------------------------- /apps/server/src/paddle-api.ts: -------------------------------------------------------------------------------- 1 | import { ErrorInvalidProduct, ErrorWebhook, MainApi } from "@app/api-client"; 2 | import { PaddlePrice, PaddleProduct } from "@app/api-client/schemas"; 3 | import { HttpApiBuilder, HttpServerRequest } from "@effect/platform"; 4 | import { PgDrizzle } from "@effect/sql-drizzle/Pg"; 5 | import { EventName } from "@paddle/paddle-node-sdk"; 6 | import { eq } from "drizzle-orm"; 7 | import { Array, Config, Effect, Layer, Match, Schema } from "effect"; 8 | import { DatabaseLive } from "./database"; 9 | import { Paddle } from "./paddle"; 10 | import { priceTable, productTable } from "./schema/drizzle"; 11 | import { slugFromName } from "./utils"; 12 | 13 | export class PaddleApi extends Effect.Service()("PaddleApi", { 14 | succeed: { 15 | webhook: ({ 16 | paddleSignature, 17 | payload, 18 | }: { 19 | payload: string; 20 | paddleSignature: string; 21 | }) => 22 | Effect.gen(function* () { 23 | const { webhooksUnmarshal } = yield* Paddle; 24 | const webhookSecret = yield* Config.redacted("WEBHOOK_SECRET_KEY").pipe( 25 | Effect.mapError(() => new ErrorWebhook({ reason: "missing-secret" })) 26 | ); 27 | 28 | const eventData = yield* webhooksUnmarshal({ 29 | payload, 30 | webhookSecret, 31 | paddleSignature, 32 | }).pipe( 33 | Effect.mapError( 34 | () => new ErrorWebhook({ reason: "verify-signature" }) 35 | ) 36 | ); 37 | 38 | yield* Effect.log(eventData); 39 | return yield* Match.value(eventData).pipe( 40 | Match.when({ eventType: EventName.ProductCreated }, ({ data }) => 41 | Effect.gen(function* () { 42 | const drizzle = yield* PgDrizzle; 43 | yield* drizzle.insert(productTable).values({ 44 | id: data.id, 45 | name: data.name, 46 | description: data.description, 47 | imageUrl: data.imageUrl, 48 | slug: slugFromName(data.name), 49 | }); 50 | return true; 51 | }).pipe( 52 | Effect.mapError(() => new ErrorWebhook({ reason: "query-error" })) 53 | ) 54 | ), 55 | Match.when({ eventType: EventName.PriceCreated }, ({ data }) => 56 | Effect.gen(function* () { 57 | const drizzle = yield* PgDrizzle; 58 | yield* drizzle.insert(priceTable).values({ 59 | id: data.id, 60 | productId: data.productId, 61 | amount: data.unitPrice.amount, 62 | currencyCode: data.unitPrice.currencyCode, 63 | }); 64 | return true; 65 | }).pipe( 66 | Effect.mapError(() => new ErrorWebhook({ reason: "query-error" })) 67 | ) 68 | ), 69 | Match.orElse(() => Effect.succeed(true)) 70 | ); 71 | }), 72 | getProduct: ({ slug }: { slug: string }) => 73 | Effect.gen(function* () { 74 | const drizzle = yield* PgDrizzle; 75 | 76 | const { price, product } = yield* drizzle 77 | .select() 78 | .from(productTable) 79 | .where(eq(productTable.slug, slug)) 80 | .limit(1) 81 | .leftJoin(priceTable, eq(productTable.id, priceTable.productId)) 82 | .pipe( 83 | Effect.flatMap(Array.head), 84 | Effect.mapError(() => new ErrorInvalidProduct()) 85 | ); 86 | 87 | return yield* Effect.all({ 88 | product: Schema.decode(PaddleProduct)(product), 89 | price: Effect.fromNullable(price).pipe( 90 | Effect.flatMap((price) => 91 | Effect.fromNullable(price.productId).pipe( 92 | Effect.flatMap((productId) => 93 | Schema.decode(PaddlePrice)({ ...price, productId }) 94 | ) 95 | ) 96 | ) 97 | ), 98 | }).pipe( 99 | Effect.tapError((parseError) => Effect.logError(parseError)), 100 | Effect.mapError(() => new ErrorInvalidProduct()) 101 | ); 102 | }), 103 | }, 104 | }) {} 105 | 106 | export const PaddleApiLive = HttpApiBuilder.group( 107 | MainApi, 108 | "paddle", 109 | (handlers) => 110 | handlers 111 | // https://developer.paddle.com/webhooks/signature-verification#verify-sdks 112 | .handle("webhook", ({ headers }) => 113 | Effect.gen(function* () { 114 | const request = yield* HttpServerRequest.HttpServerRequest; 115 | const payload = yield* request.text.pipe( 116 | Effect.mapError( 117 | () => new ErrorWebhook({ reason: "missing-payload" }) 118 | ) 119 | ); 120 | return yield* PaddleApi.pipe( 121 | Effect.flatMap((api) => 122 | api.webhook({ 123 | paddleSignature: headers["paddle-signature"], 124 | payload, 125 | }) 126 | ) 127 | ); 128 | }) 129 | ) 130 | .handle("product", ({ path: { slug } }) => 131 | PaddleApi.pipe(Effect.flatMap((api) => api.getProduct({ slug }))) 132 | ) 133 | ).pipe(Layer.provide([PaddleApi.Default, Paddle.Default, DatabaseLive])); 134 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Paddle Billing Payments Full Stack TypeScript App 2 | This repository contains all the code for the course [Paddle Billing Payments Full Stack TypeScript App](https://www.typeonce.dev/course/paddle-payments-full-stack-typescript-app). 3 | 4 | > Full stack TypeScript project template that implements Paddle Billing Payments. The project uses React Router v7 on the client and Effect with Node on the server. 5 | 6 | *** 7 | 8 | ## Project overview 9 | Payments is one of those features that you will find in most apps. [Paddle](https://www.paddle.com/) acts as a merchant of record, handling your payments, tax and compliance. 10 | 11 | This project template implements **payments with Paddle on both client and server**: 12 | - Client Paddle checkout using [@paddle/paddle-js](https://www.npmjs.com/package/@paddle/paddle-js) 13 | - Server Paddle webhook using [@paddle/paddle-node-sdk](https://www.npmjs.com/package/@paddle/paddle-node-sdk) 14 | 15 | The template provides a full stack implementation of a working payments system. It includes: 16 | - Initialize and sync events with [Paddle inline checkout](https://developer.paddle.com/concepts/sell/branded-integrated-inline-checkout) using [XState](https://xstate.js.org/) to manage the checkout process 17 | - Server webhook signature validation and event processing using [Effect](https://effect.website/) with [NodeJs](https://nodejs.org/en) 18 | 19 | ## Project structure 20 | The repository of the project is a monorepo initialized using [Turborepo](https://turbo.build/repo/docs). The same project contains both client and server inside the `apps` folder. 21 | 22 | ### Client 23 | The client is built using React with the latest version of [React Router v7](https://reactrouter.com/dev/guides) as framework. 24 | 25 | The client also uses Effect to organize and execute services (`Paddle`). 26 | 27 | The client state is managed using XState. Using a state machine the client keeps track of the current step during the checkout process, keeping the state in sync with Paddle. 28 | 29 | Styles are implemented using the latest version of Tailwind CSS v4. Components are based on [React Aria](https://react-spectrum.adobe.com/react-aria/). 30 | 31 | This is the final `package.json` file of the client: 32 | 33 | ```json title="package.json" {15} 34 | { 35 | "name": "@app/client", 36 | "version": "0.0.0", 37 | "private": true, 38 | "sideEffects": false, 39 | "type": "module", 40 | "scripts": { 41 | "dev": "react-router dev", 42 | "build": "react-router build", 43 | "start": "react-router-serve ./build/server/index.js", 44 | "typecheck": "react-router typegen && tsc" 45 | }, 46 | "dependencies": { 47 | "@effect/schema": "^0.74.1", 48 | "@paddle/paddle-js": "^1.2.3", 49 | "@react-router/node": "7.0.0-pre.0", 50 | "@react-router/serve": "7.0.0-pre.0", 51 | "@tailwindcss/vite": "^4.0.0-alpha.25", 52 | "@xstate/react": "^4.1.3", 53 | "clsx": "^2.1.1", 54 | "effect": "^3.8.4", 55 | "isbot": "^5.1.17", 56 | "react": "^18.3.1", 57 | "react-aria-components": "^1.4.0", 58 | "react-dom": "^18.3.1", 59 | "react-router": "7.0.0-pre.0", 60 | "tailwind-merge": "^2.5.3", 61 | "xstate": "^5.18.2" 62 | }, 63 | "devDependencies": { 64 | "@react-router/dev": "7.0.0-pre.0", 65 | "@types/react": "^18.3.9", 66 | "@types/react-dom": "^18.3.0", 67 | "tailwindcss": "^4.0.0-alpha.25", 68 | "vite": "^5.4.8", 69 | "vite-tsconfig-paths": "^5.0.1" 70 | }, 71 | "engines": { 72 | "node": ">=20.0.0" 73 | } 74 | } 75 | ``` 76 | 77 | ### Server 78 | The server API is built from scratch using [Effect](https://effect.website/). The project is a normal NodeJs app that uses `node:http` as server. 79 | 80 | The API is based on `@effect/platform`, specifically using the following modules: 81 | - `HttpApi` 82 | - `HttpApiBuilder` 83 | - `HttpApiEndpoint` 84 | - `HttpApiGroup` 85 | - `HttpMiddleware` 86 | - `HttpServer` 87 | 88 | Other dependencies include `dotenv` (to load environment variables) and `tsx` (to execute the server code). 89 | 90 | This is the final `package.json` file of the server: 91 | 92 | ```json title="package.json" {14} 93 | { 94 | "name": "@app/server", 95 | "version": "0.0.0", 96 | "author": "Typeonce", 97 | "license": "MIT", 98 | "scripts": { 99 | "dev": "tsx src/main.ts", 100 | "typecheck": "tsc" 101 | }, 102 | "dependencies": { 103 | "@effect/platform": "^0.66.2", 104 | "@effect/platform-node": "^0.61.3", 105 | "@effect/schema": "^0.74.1", 106 | "@paddle/paddle-node-sdk": "^1.7.0", 107 | "dotenv": "^16.4.5", 108 | "effect": "^3.8.4" 109 | }, 110 | "devDependencies": { 111 | "@types/node": "^22.7.4", 112 | "tsx": "^4.19.1" 113 | } 114 | } 115 | ``` 116 | 117 | *** 118 | 119 | ## Course content 120 | Instead of explaining step by step how to implement the project, **this course focuses on the overall project structure and code architecture**. 121 | 122 | For both client and server, the course explains the pro and cons of each technology and the role and benefits of each dependency. 123 | 124 | It then goes more into the details of client and server, showing how the code is organized and discussing some implementation details for the most important files. 125 | 126 | The course aims to provide an overview of how to approach the implementation of a full stack project in the specific example of payments with Paddle. --------------------------------------------------------------------------------