diff --git a/.github/actions/setup/action.yaml b/.github/actions/setup/action.yaml index 1204482..8d5e580 100644 --- a/.github/actions/setup/action.yaml +++ b/.github/actions/setup/action.yaml @@ -4,20 +4,22 @@ runs: using: composite steps: - - name: ⚙️ Setup pnpm + - name: ⚙️ Install pnpm uses: pnpm/action-setup@v4 + with: + run_install: false - name: 🚧 Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: .node-version cache: 'pnpm' + - name: 📥 Install dependencies + shell: bash + run: pnpm install --frozen-lockfile + - name: 🐳 Setup nx.dev uses: nrwl/nx-set-shas@v4 with: main-branch-name: ${{ github.ref_name }} - - - name: 📥 Install dependencies - shell: bash - run: pnpm install diff --git a/.oxlintrc.json b/.oxlintrc.json index 2241fbf..4fad89b 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -118,5 +118,17 @@ "builtin": true }, "globals": {}, - "ignorePatterns": ["entry.server.ts", "**/vendor"] + "ignorePatterns": [ + ".nx", + ".direnv", + ".repo", + "**/vendor", + "**/.wrangler", + "**/build", + "**/dist", + "**/.expo", + "**/ios", + "**/andriod", + "entry.server.ts" + ] } diff --git a/.prettierignore b/.prettierignore index a207759..44aadfc 100644 --- a/.prettierignore +++ b/.prettierignore @@ -5,6 +5,7 @@ .nx .direnv +.repo **/coverage **/.wrangler **/build diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..92536a9 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12.0 diff --git a/flake.lock b/flake.lock index 204b508..128df5b 100644 --- a/flake.lock +++ b/flake.lock @@ -20,11 +20,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1756266583, - "narHash": "sha256-cr748nSmpfvnhqSXPiCfUPxRz2FJnvf/RjJGvFfaCsM=", + "lastModified": 1763966396, + "narHash": "sha256-6eeL1YPcY1MV3DDStIDIdy/zZCDKgHdkCmsrLJFiZf0=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "8a6d5427d99ec71c64f0b93d45778c889005d9c2", + "rev": "5ae3b07d8d6527c42f17c876e404993199144b6a", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index cdc6b08..9ebd170 100644 --- a/flake.nix +++ b/flake.nix @@ -15,6 +15,8 @@ }; }; + inherit (pkgs) lib; + androidComposition = pkgs.androidenv.composeAndroidPackages { cmdLineToolsVersion = "9.0"; toolsVersion = "26.1.1"; @@ -29,25 +31,14 @@ "extras;google;m2repository" ]; }; - in - { - devShells.default = pkgs.mkShell { - name = "DevBox"; - buildInputs = with pkgs; lib.unique ([ - # Node.js ecosystem + devPackages = with pkgs; + lib.unique ([ corepack nodejs_24 bun - - # Python environment - python3 - python3Packages.pip - python3Packages.virtualenv - python3Packages.setuptools - python3Packages.wheel - - # Android development + python312 + uv androidComposition.androidsdk androidComposition.platform-tools androidComposition.build-tools @@ -55,113 +46,129 @@ androidComposition.cmake jdk17_headless gradle - watchman ] ++ lib.optionals stdenv.isDarwin [ - # iOS development (macOS only) cocoapods - ruby_3_2 + ruby_3_4 xcbeautify libimobiledevice ccache ]); - shellHook = '' - # Set up Node.js environment - export NODE_ENV=development - - # Set up pnpm - export PNPM_HOME="$HOME/.local/share/pnpm" - export PATH="$PNPM_HOME:$PATH" - - # Set up Android environment - export ANDROID_HOME="${androidComposition.androidsdk}/libexec/android-sdk" - export ANDROID_SDK_ROOT="$ANDROID_HOME" - export ANDROID_NDK_ROOT="$ANDROID_HOME/ndk-bundle" - export PATH="$ANDROID_HOME/tools:$ANDROID_HOME/platform-tools:$ANDROID_HOME/cmdline-tools/latest/bin:$PATH" - - # Set up Java environment - export JAVA_HOME="${pkgs.jdk17_headless}" - export PATH="$JAVA_HOME/bin:$PATH" - - # Set up Gradle - export GRADLE_HOME="${pkgs.gradle}/lib/gradle" - export PATH="$GRADLE_HOME/bin:$PATH" + shellHookScript = pkgs.writeShellScript "devbox-shell-hook" '' + export NODE_ENV=development + + export PNPM_HOME="$HOME/.local/share/pnpm" + export PATH="$PNPM_HOME:$PATH" + + export ANDROID_HOME="${androidComposition.androidsdk}/libexec/android-sdk" + export ANDROID_SDK_ROOT="$ANDROID_HOME" + export ANDROID_NDK_ROOT="$ANDROID_HOME/ndk-bundle" + export PATH="$ANDROID_HOME/tools:$ANDROID_HOME/platform-tools:$ANDROID_HOME/cmdline-tools/latest/bin:$PATH" + + export JAVA_HOME="${pkgs.jdk17_headless}" + export PATH="$JAVA_HOME/bin:$PATH" + + export GRADLE_HOME="${pkgs.gradle}/lib/gradle" + export PATH="$GRADLE_HOME/bin:$PATH" + + if [[ "$OSTYPE" == "darwin"* ]]; then + export LANG=en_US.UTF-8 + export LC_ALL=en_US.UTF-8 + export DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer" + export PATH="$DEVELOPER_DIR/Toolchains/XcodeDefault.xctoolchain/usr/bin:$DEVELOPER_DIR/usr/bin:$PATH" + export SDKROOT="$DEVELOPER_DIR/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk" + export MACOSX_DEPLOYMENT_TARGET="11.0" + export IOS_DEPLOYMENT_TARGET="15.1" + export IPHONEOS_DEPLOYMENT_TARGET="15.1" + fi + + export PATH="$(pwd)/node_modules/.bin:$PATH" + + export NODE_OPTIONS="--max-old-space-size=8192" + export GRADLE_OPTS="-Xmx4g -XX:+UseG1GC" + + export UV_CACHE_DIR="''${XDG_CACHE_HOME:-$HOME/.cache}/uv" + export UV_PYTHON_INSTALL_DIR="$UV_CACHE_DIR/python" + export UV_LINK_MODE=copy + export UV_PROJECT_ENVIRONMENT="$(pwd)/.venv" + export UV_PROJECT_PYTHON="3.12.0" + + uv_activate_project_env() { + if [ -d "$UV_PROJECT_ENVIRONMENT" ]; then + export VIRTUAL_ENV="$UV_PROJECT_ENVIRONMENT" + export PATH="$VIRTUAL_ENV/bin:$PATH" + if [ -f "$VIRTUAL_ENV/bin/activate" ]; then + # shellcheck disable=SC1090 + . "$VIRTUAL_ENV/bin/activate" + fi + fi + } + + uv_prepare_project_env() { + local current_version + if [ -f "$UV_PROJECT_ENVIRONMENT/pyvenv.cfg" ]; then + current_version="$(grep -E '^version_info' "$UV_PROJECT_ENVIRONMENT/pyvenv.cfg" | awk -F ' = ' '{print $2}')" + if [ "''${current_version:-}" != "$UV_PROJECT_PYTHON" ]; then + rm -rf "$UV_PROJECT_ENVIRONMENT" + fi + fi - # Enable modern React Native features - export ANDROID_HERMES_ENABLED=true - export ANDROID_NEW_ARCH_ENABLED=true + if [ ! -d "$UV_PROJECT_ENVIRONMENT" ]; then + uv venv --python "$UV_PROJECT_PYTHON" + fi - # iOS development setup - if [[ "$OSTYPE" == "darwin"* ]]; then - # Set up CocoaPods - export LANG=en_US.UTF-8 - export LC_ALL=en_US.UTF-8 + uv python pin "$UV_PROJECT_PYTHON" --project >/dev/null 2>&1 || true + } - # Ensure we're using Nix's Ruby and CocoaPods - export PATH="${pkgs.ruby_3_2}/bin:${pkgs.cocoapods}/bin:$PATH" - export GEM_HOME="${pkgs.cocoapods}/lib/ruby/gems/3.2.0" - export GEM_PATH="${pkgs.cocoapods}/lib/ruby/gems/3.2.0" + uv_prepare_project_env + uv_activate_project_env - # Set up Xcode tools with highest priority - export DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer" - export PATH="$DEVELOPER_DIR/Toolchains/XcodeDefault.xctoolchain/usr/bin:$DEVELOPER_DIR/usr/bin:$PATH" + alias pip="uv pip" + alias pip-sync="uv pip sync" - # Enable new architecture for iOS - export RCT_NEW_ARCH_ENABLED=1 + devbox_debug_env() { + echo "=== Development Environment ===" - # Set up Xcode environment - export SDKROOT="$DEVELOPER_DIR/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk" - export MACOSX_DEPLOYMENT_TARGET="11.0" + echo "Android SDK:" + echo " SDK Root: $ANDROID_HOME" + echo " NDK Root: $ANDROID_NDK_ROOT" + echo " SDK Version: $(sdkmanager --version 2>/dev/null || echo 'N/A')" + echo " Platform Tools: $(adb version 2>/dev/null | head -n1 || echo 'N/A')" + echo " Build Tools: $(ls -1 $ANDROID_HOME/build-tools 2>/dev/null | sort -V | tail -n1 || echo 'N/A')" + echo " Platform: Android $(ls -1 $ANDROID_HOME/platforms 2>/dev/null | grep -o '[0-9]*' | sort -V | tail -n1 || echo 'N/A')" + echo " NDK Version: $(ls -1 $ANDROID_HOME/ndk 2>/dev/null | sort -V | tail -n1 || echo 'N/A')" - # Set up iOS development environment - export IOS_DEPLOYMENT_TARGET="15.1" - export IPHONEOS_DEPLOYMENT_TARGET="15.1" + if [[ "$OSTYPE" == "darwin"* ]]; then + echo "iOS Environment:" + echo " Xcode Version: $(xcodebuild -version 2>/dev/null | head -n1 || echo 'N/A')" + echo " GCC Version: $(gcc --version 2>/dev/null | head -n1 || echo 'N/A')" + echo " Clang Version: $(clang --version 2>/dev/null | head -n1 || echo 'N/A')" + echo " CocoaPods Version: $(pod --version 2>/dev/null || echo 'N/A')" + echo " Ruby Version: $(ruby --version 2>/dev/null || echo 'N/A')" + echo " Deployment Target: $IOS_DEPLOYMENT_TARGET" fi - # Add project's node_modules/.bin to PATH - export PATH="$(pwd)/node_modules/.bin:$PATH" - - # Performance optimizations - export NODE_OPTIONS="--max-old-space-size=8192" - export GRADLE_OPTS="-Xmx4g -XX:+UseG1GC" - - # Print development environment information (controlled by DEBUG_ENV) - if [ "$DEBUG_ENV" = "1" ] || [ "$DEBUG_ENV" = "true" ]; then - echo "=== Development Environment ===" - - # Android Environment - echo "Android SDK:" - echo " SDK Root: $ANDROID_HOME" - echo " NDK Root: $ANDROID_NDK_ROOT" - echo " SDK Version: $(sdkmanager --version 2>/dev/null || echo 'N/A')" - echo " Platform Tools: $(adb version 2>/dev/null | head -n1 || echo 'N/A')" - echo " Build Tools: $(ls -1 $ANDROID_HOME/build-tools 2>/dev/null | sort -V | tail -n1 || echo 'N/A')" - echo " Platform: Android $(ls -1 $ANDROID_HOME/platforms 2>/dev/null | grep -o '[0-9]*' | sort -V | tail -n1 || echo 'N/A')" - echo " NDK Version: $(ls -1 $ANDROID_HOME/ndk 2>/dev/null | sort -V | tail -n1 || echo 'N/A')" - - # iOS Environment (macOS only) - if [[ "$OSTYPE" == "darwin"* ]]; then - echo "iOS Environment:" - echo " Xcode Version: $(xcodebuild -version 2>/dev/null | head -n1 || echo 'N/A')" - echo " GCC Version: $(gcc --version 2>/dev/null | head -n1 || echo 'N/A')" - echo " Clang Version: $(clang --version 2>/dev/null | head -n1 || echo 'N/A')" - echo " CocoaPods Version: $(pod --version 2>/dev/null || echo 'N/A')" - echo " Ruby Version: $(ruby --version 2>/dev/null || echo 'N/A')" - echo " Deployment Target: $IOS_DEPLOYMENT_TARGET" - fi - - # Common Environment - echo "Common Environment:" - echo " Node Version: $(node --version 2>/dev/null || echo 'N/A')" - echo " NPM Version: $(npm --version 2>/dev/null || echo 'N/A')" - echo " PNPM Version: $(pnpm --version 2>/dev/null || echo 'N/A')" - echo " Python Version: $(python3 --version 2>/dev/null || echo 'N/A')" - echo " Java Version: $(java -version 2>&1 | head -n1 || echo 'N/A')" - echo " Gradle Version: $(gradle --version 2>/dev/null | grep Gradle | head -n1 || echo 'N/A')" - echo "=====================================" + echo "Common Environment:" + echo " Node Version: $(node --version 2>/dev/null || echo 'N/A')" + echo " NPM Version: $(npm --version 2>/dev/null || echo 'N/A')" + echo " PNPM Version: $(pnpm --version 2>/dev/null || echo 'N/A')" + echo " UV Version: $(uv --version 2>/dev/null | head -n1 || echo 'N/A')" + if [ -n "$VIRTUAL_ENV" ]; then + echo " Active Python Env: $VIRTUAL_ENV" fi - ''; + echo " Python Version: $(python3 --version 2>/dev/null || echo 'N/A')" + echo " Java Version: $(java -version 2>&1 | head -n1 || echo 'N/A')" + echo " Gradle Version: $(gradle --version 2>/dev/null | grep Gradle | head -n1 || echo 'N/A')" + echo "=====================================" + } + ''; + in + { + devShells.default = pkgs.mkShell { + name = "DevBox"; + buildInputs = devPackages; + shellHook = "source ${shellHookScript}"; }; } ); diff --git a/nx.json b/nx.json index a79f995..69d6c7d 100644 --- a/nx.json +++ b/nx.json @@ -98,12 +98,16 @@ "plugins": [ { "plugin": "@nx/expo/plugin", - "include": ["**/mobile"], "options": { - "prebuildTargetName": "prebuild", - "buildTargetName": "native-build", + "startTargetName": "start", + "serveTargetName": "serve", "runIosTargetName": "run-ios", - "runAndroidTargetName": "run-android" + "runAndroidTargetName": "run-android", + "exportTargetName": "export", + "prebuildTargetName": "prebuild", + "installTargetName": "install", + "buildTargetName": "build", + "submitTargetName": "submit" } } ], diff --git a/package.json b/package.json index bfdca7e..e595441 100644 --- a/package.json +++ b/package.json @@ -27,34 +27,34 @@ "dependencies": { "@alexanderolsen/libsamplerate-js": "^2.1.2", "@babel/runtime": "^7.28.4", - "@craftzdog/react-native-buffer": "6.1.1", + "@craftzdog/react-native-buffer": "^6.1.1", "@discordjs/opus": "^0.10.0", "@ebay/nice-modal-react": "^1.2.13", "@effect-atom/atom": "^0.4.3", "@effect-x/wa-sqlite": "^0.1.5", "@effect/ai": "^0.32.1", "@effect/ai-openai": "^0.35.0", - "@effect/cluster": "^0.53.5", - "@effect/experimental": "^0.57.4", + "@effect/cluster": "^0.53.6", + "@effect/experimental": "^0.57.5", "@effect/opentelemetry": "^0.59.1", - "@effect/platform": "^0.93.3", + "@effect/platform": "^0.93.4", "@effect/platform-browser": "^0.73.0", "@effect/platform-bun": "^0.84.0", - "@effect/platform-node": "^0.101.1", + "@effect/platform-node": "^0.101.2", "@effect/rpc": "^0.72.2", - "@effect/sql": "^0.48.0", + "@effect/sql": "^0.48.1", "@effect/sql-d1": "^0.46.0", "@effect/sql-sqlite-do": "^0.26.0", - "@effect/sql-sqlite-node": "^0.49.0", - "@expo/metro-runtime": "6.1.2", - "@expo/ui": "0.2.0-beta.7", - "@glideapps/glide-data-grid": "6.0.4-alpha8", + "@effect/sql-sqlite-node": "^0.49.1", + "@expo/metro-runtime": "^6.1.2", + "@expo/ui": "^0.2.0-beta.7", + "@glideapps/glide-data-grid": "^6.0.4-alpha8", "@noble/hashes": "^2.0.1", "@noble/secp256k1": "^3.0.0", "@op-engineering/op-sqlite": "^15.1.1", "@opentelemetry/semantic-conventions": "^1.38.0", - "@opentui/core": "^0.1.49", - "@opentui/react": "^0.1.49", + "@opentui/core": "^0.1.50", + "@opentui/react": "^0.1.50", "@paddle/paddle-js": "^1.5.1", "@paddle/paddle-node-sdk": "^3.4.0", "@portabletext/react": "^5.0.0", @@ -89,12 +89,12 @@ "@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-tooltip": "^1.2.8", - "@react-navigation/drawer": "7.7.4", - "@react-navigation/elements": "2.8.3", - "@react-navigation/native": "7.1.21", - "@react-navigation/stack": "7.6.7", + "@react-navigation/drawer": "^7.7.5", + "@react-navigation/elements": "^2.8.4", + "@react-navigation/native": "^7.1.22", + "@react-navigation/stack": "^7.6.8", "@recappi/sdk": "^1.0.0", - "@sanity/client": "^7.13.0", + "@sanity/client": "^7.13.1", "@sanity/image-url": "^1.2.0", "@sanity/react-loader": "^2.0.0", "@scure/bip39": "^2.0.1", @@ -107,43 +107,44 @@ "chroma-js": "^3.1.2", "class-variance-authority": "^0.7.1", "cloudflare": "^5.2.0", - "clsx": "2.1.1", + "clsx": "^2.1.1", "cmdk": "^1.1.1", - "cookie": "1.0.2", + "cookie": "^1.1.1", "culori": "^4.0.2", - "effect": "3.19.6", + "effect": "^3.19.6", "embla-carousel-react": "^8.6.0", - "expo": "54.0.25", - "expo-asset": "12.0.10", - "expo-audio": "1.0.15", + "expo": "^54.0.25", + "expo-asset": "^12.0.10", + "expo-audio": "^1.0.15", "expo-bip39": "workspace:*", - "expo-clipboard": "8.0.7", - "expo-constants": "18.0.10", - "expo-crypto": "15.0.7", - "expo-dev-client": "6.0.18", - "expo-device": "8.0.9", - "expo-document-picker": "14.0.7", - "expo-file-system": "19.0.19", - "expo-font": "14.0.9", - "expo-haptics": "15.0.7", - "expo-image": "3.0.10", - "expo-image-manipulator": "14.0.7", - "expo-image-picker": "17.0.8", - "expo-keep-awake": "15.0.7", - "expo-linear-gradient": "15.0.7", - "expo-linking": "8.0.9", - "expo-local-authentication": "17.0.7", - "expo-localization": "17.0.7", - "expo-network": "8.0.7", - "expo-router": "6.0.15", - "expo-screen-orientation": "9.0.7", - "expo-secure-store": "15.0.7", - "expo-sharing": "14.0.7", - "expo-speech": "14.0.7", - "expo-splash-screen": "31.0.11", - "expo-status-bar": "3.0.8", - "expo-system-ui": "6.0.8", - "groq": "^4.18.0", + "expo-clipboard": "^8.0.7", + "expo-constants": "^18.0.10", + "expo-crypto": "^15.0.7", + "expo-dev-client": "^6.0.18", + "expo-device": "^8.0.9", + "expo-document-picker": "^14.0.7", + "expo-file-system": "^19.0.19", + "expo-font": "^14.0.9", + "expo-haptics": "^15.0.7", + "expo-image": "^3.0.10", + "expo-image-manipulator": "^14.0.7", + "expo-image-picker": "^17.0.8", + "expo-keep-awake": "^15.0.7", + "expo-linear-gradient": "^15.0.7", + "expo-linking": "^8.0.9", + "expo-local-authentication": "^17.0.7", + "expo-localization": "^17.0.7", + "expo-modules-core": "^3.0.26", + "expo-network": "^8.0.7", + "expo-router": "^6.0.15", + "expo-screen-orientation": "^9.0.7", + "expo-secure-store": "^15.0.7", + "expo-sharing": "^14.0.7", + "expo-speech": "^14.0.7", + "expo-splash-screen": "^31.0.11", + "expo-status-bar": "^3.0.8", + "expo-system-ui": "^6.0.8", + "groq": "^4.19.0", "i18next": "^25.6.3", "ieee754": "^1.2.1", "input-otp": "^1.4.2", @@ -157,24 +158,28 @@ "msgpackr": "^1.11.5", "nanoid": "^5.1.6", "nativewind": "^4.2.1", - "react": "19.2.0", + "react": "^19.2.0", "react-day-picker": "^9.11.2", - "react-dom": "19.2.0", + "react-dom": "^19.2.0", "react-error-boundary": "^6.0.0", "react-fast-marquee": "^1.6.5", "react-hook-form": "^7.66.1", - "react-hotkeys-hook": "^5.2.0", + "react-hotkeys-hook": "^5.2.1", "react-i18next": "^16.3.5", "react-image-previewer": "^1.1.6", - "react-is": "19.2.0", - "react-native": "0.82.1", - "react-native-gesture-handler": "2.29.1", - "react-native-quick-base64": "2.2.2", - "react-native-quick-crypto": "0.7.17", - "react-native-reanimated": "4.1.5", - "react-native-safe-area-context": "5.6.2", - "react-native-screens": "4.18.0", - "react-native-svg": "15.15.0", + "react-is": "^19.2.0", + "react-native": "0.83.0-rc.3", + "react-native-edge-to-edge": "^1.7.0", + "react-native-gesture-handler": "^2.29.1", + "react-native-keyboard-controller": "^1.19.6", + "react-native-masked-view": "^0.2.0", + "react-native-nitro-modules": "^0.31.10", + "react-native-quick-base64": "^2.2.2", + "react-native-quick-crypto": "^0.7.17", + "react-native-reanimated": "^4.1.5", + "react-native-safe-area-context": "^5.6.2", + "react-native-screens": "^4.18.0", + "react-native-svg": "^15.15.0", "react-native-worklets": "^0.6.1", "react-resizable-panels": "^3.0.6", "react-router": "^7.9.6", @@ -190,7 +195,7 @@ "tailwind-merge": "^3.4.0", "temporal-polyfill": "^0.3.0", "type-fest": "^5.2.0", - "ua-parser-js": "2.0.6", + "ua-parser-js": "^2.0.6", "uuid": "^13.0.0", "vaul": "^1.1.2" }, @@ -202,43 +207,43 @@ "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@cloudflare/types": "^7.0.0", - "@cloudflare/workers-types": "^4.20251121.0", - "@cloudflare/workers-utils": "^0.2.0", + "@cloudflare/workers-types": "^4.20251127.0", + "@cloudflare/workers-utils": "^0.3.0", "@dotenvx/dotenvx": "^1.51.1", "@effect/cli": "^0.72.1", - "@effect/language-service": "^0.56.0", + "@effect/language-service": "^0.57.0", "@effect/printer": "^0.47.0", "@effect/printer-ansi": "^0.47.0", "@effect/typeclass": "^0.38.0", "@effect/vitest": "^0.27.0", "@egoist/tailwindcss-icons": "^1.9.0", - "@expo/cli": "54.0.16", - "@expo/metro-config": "54.0.9", - "@faker-js/faker": "^10.0.0", + "@expo/cli": "^54.0.16", + "@expo/metro-config": "^54.0.9", + "@faker-js/faker": "^10.1.0", "@githubnext/vitale": "^0.0.19", "@iconify-json/logos": "^1.2.10", "@iconify-json/lucide": "^1.2.75", - "@nx/devkit": "^22.1.1", - "@nx/expo": "^22.1.1", - "@nx/js": "^22.1.1", - "@nx/vite": "^22.1.1", - "@nx/workspace": "^22.1.1", + "@nx/devkit": "^22.1.2", + "@nx/expo": "^22.1.2", + "@nx/js": "^22.1.2", + "@nx/vite": "^22.1.2", + "@nx/workspace": "^22.1.2", "@octokit/types": "^16.0.0", "@portabletext/types": "^3.0.0", "@prettier/plugin-oxc": "^0.0.5", - "@prisma/generator-helper": "^7.0.0", - "@prisma/internals": "^7.0.0", - "@prisma/migrate": "^7.0.0", - "@react-native/metro-config": "0.82.1", + "@prisma/generator-helper": "^7.0.1", + "@prisma/internals": "^7.0.1", + "@prisma/migrate": "^7.0.1", + "@react-native/metro-config": "^0.82.1", "@react-router/dev": "^7.9.6", "@react-router/node": "^7.9.6", - "@rnx-kit/metro-config": "^2.2.0", + "@rnx-kit/metro-config": "^2.2.1", "@rnx-kit/metro-resolver-symlinks": "^0.2.7", "@rnx-kit/tools-node": "^3.0.2", "@sanity/document-internationalization": "^4.1.0", - "@sanity/types": "^4.18.0", + "@sanity/types": "^4.19.0", "@sanity/ui": "^3.1.11", - "@sanity/vision": "^4.18.0", + "@sanity/vision": "^4.19.0", "@sanity/visual-editing": "^4.0.2", "@sindresorhus/slugify": "^3.0.0", "@standard-schema/spec": "^1.0.0", @@ -256,48 +261,48 @@ "@types/react-virtualized": "^9.22.3", "@types/ua-parser-js": "^0.7.39", "@types/ws": "^8.18.1", - "@typescript/native-preview": "7.0.0-dev.20251124.1", + "@typescript/native-preview": "^7.0.0-dev.20251126.1", "@vite-pwa/assets-generator": "^1.0.2", - "@vitest/browser": "^4.0.13", - "@vitest/browser-playwright": "^4.0.13", - "@vitest/coverage-v8": "^4.0.13", - "@vitest/ui": "^4.0.13", + "@vitest/browser": "^4.0.14", + "@vitest/browser-playwright": "^4.0.14", + "@vitest/coverage-v8": "^4.0.14", + "@vitest/ui": "^4.0.14", "address": "^2.0.3", "autoprefixer": "^10.4.22", "babel-plugin-react-compiler": "^1.0.0", "browserslist": "^4.28.0", "esbuild": "0.27.0", - "expo-atlas": "0.4.3", - "expo-build-properties": "1.0.9", - "expo-module-scripts": "5.0.7", + "expo-atlas": "^0.4.3", + "expo-build-properties": "^1.0.9", + "expo-module-scripts": "^5.0.7", "fast-check": "^4.3.0", "fast-xml-parser": "^5.3.2", "js-toml": "^1.0.2", - "jsx-email": "2.8.1", + "jsx-email": "^2.8.1", "lefthook": "^2.0.4", "libsodium-wrappers": "^0.7.15", "lodash-es": "^4.17.21", "madge": "^8.0.0", "metro": "^0.83.3", - "metro-config": "0.83.3", - "miniflare": "^4.20251118.1", - "nx": "^22.1.1", + "metro-config": "^0.83.3", + "miniflare": "^4.20251125.0", + "nx": "^22.1.2", "oxc-transform": "^0.99.0", "oxlint": "^1.30.0", - "playwright": "^1.56.1", + "playwright": "^1.57.0", "postcss": "^8.5.6", "postcss-flexbugs-fixes": "^5.0.2", "postcss-preset-env": "^10.4.0", "prettier": "^3.6.2", - "prisma": "^7.0.0", + "prisma": "^7.0.1", "pure-rand": "^7.0.1", "react-dev-inspector": "beta", - "react-native-svg-transformer": "1.5.2", + "react-native-svg-transformer": "^1.5.2", "react-scan": "^0.4.3", - "rolldown": "1.0.0-beta.51", + "rolldown": "^1.0.0-beta.52", "rollup-plugin-visualizer": "^6.0.5", "rxjs": "^7.8.2", - "sanity": "^4.18.0", + "sanity": "^4.19.0", "shellac": "^0.8.0", "styled-components": "^6.1.19", "svgo": "^4.0.0", @@ -310,20 +315,20 @@ "turbo-stream": "2.4.1", "typescript": "^5.9.3", "unplugin-preprocessor-directives": "^1.2.0", - "vite": "npm:rolldown-vite@latest", + "vite": "^7.2.4", "vite-plugin-checker": "^0.11.0", "vite-plugin-mkcert": "^1.17.9", "vite-plugin-pwa": "^1.1.0", - "vitest": "^4.0.13", + "vitest": "^4.0.14", "vitest-browser-react": "^2.0.2", "workbox-build": "^7.4.0", - "workbox-core": "^7.3.0", + "workbox-core": "^7.4.0", "workbox-expiration": "^7.3.0", "workbox-precaching": "^7.3.0", "workbox-routing": "^7.3.0", "workbox-strategies": "^7.3.0", "workbox-window": "^7.3.0", - "wrangler": "^4.50.0", + "wrangler": "^4.51.0", "ws": "^8.18.3" } } diff --git a/packages/app-kit/src/api/purchase.ts b/packages/app-kit/src/api/purchase.ts index 3f4afcc..1999ae9 100644 --- a/packages/app-kit/src/api/purchase.ts +++ b/packages/app-kit/src/api/purchase.ts @@ -2,9 +2,9 @@ import * as HttpApi from '@effect/platform/HttpApi' import * as HttpApiEndpoint from '@effect/platform/HttpApiEndpoint' import * as HttpApiGroup from '@effect/platform/HttpApiGroup' import * as OpenApi from '@effect/platform/OpenApi' -import { InvoiceNotFound, SubscriptionCancelError, SubscriptionNotFound } from '@infra/purchase/errors' -import { PaymentEnvironmentTag, PaymentProviderTag } from '@infra/purchase/payment/type' -import { TransactionId } from '@infra/purchase/schema' +import { InvoiceNotFound, SubscriptionCancelError, SubscriptionNotFound } from '@xstack/purchase/errors' +import { PaymentEnvironmentTag, PaymentProviderTag } from '@xstack/purchase/payment' +import { TransactionId } from '@xstack/purchase/schema' import { AppPlan, AppSubscription, AppTransaction, SubscriptionQuery } from '@xstack/app-kit/schema' import * as Ratelimit from '@xstack/server/ratelimit' import { SessionSecurityMiddleware } from '@xstack/user-kit/middleware' @@ -27,7 +27,7 @@ class PlanApi extends HttpApiGroup.make('plans') .annotateContext( OpenApi.annotations({ title: 'List plans', - description: 'List plans for Verx.app', + description: 'List plans', }), ), ) @@ -47,7 +47,7 @@ class PlanApi extends HttpApiGroup.make('plans') .annotateContext( OpenApi.annotations({ title: 'List plans', - description: 'List plans for Verx.app', + description: 'List plans', }), ), ) @@ -56,7 +56,7 @@ class PlanApi extends HttpApiGroup.make('plans') .annotateContext( OpenApi.annotations({ title: 'Plans API', - description: 'Plans API for Verx', + description: 'Plans API', }), ) {} @@ -90,7 +90,7 @@ class SubscriptionApi extends HttpApiGroup.make('subscriptions') .annotateContext( OpenApi.annotations({ title: 'Subscriptions API', - description: 'Subscriptions API for Verx', + description: 'Subscriptions', }), ) {} @@ -136,6 +136,6 @@ export class PurchaseHttpApi extends HttpApi.make('api') .annotateContext( OpenApi.annotations({ title: 'Purchase API', - description: 'Purchase API for Verx', + description: 'Purchase', }), ) {} diff --git a/packages/app-kit/src/http.ts b/packages/app-kit/src/http.ts index 5c9f6bd..f887ee5 100644 --- a/packages/app-kit/src/http.ts +++ b/packages/app-kit/src/http.ts @@ -1,9 +1,9 @@ import * as HttpApiBuilder from '@effect/platform/HttpApiBuilder' import * as HttpApiClient from '@effect/platform/HttpApiClient' import * as HttpServerResponse from '@effect/platform/HttpServerResponse' -import { MyHttpApi as Api } from '@infra/purchase/api' -import { PaymentEnvironmentTag, PaymentProviderTag } from '@infra/purchase/payment/type' -import { CustomerEmail } from '@infra/purchase/schema' +import { MyHttpApi as Api } from '@xstack/purchase/http-api' +import { PaymentEnvironmentTag, PaymentProviderTag } from '@xstack/purchase/payment' +import { CustomerEmail } from '@xstack/purchase/schema' import { PurchaseHttpApi } from '@xstack/app-kit/api/purchase' import { AppPlan, AppSubscription, AppTransaction } from '@xstack/app-kit/schema' import * as WorkerService from '@xstack/cloudflare/worker-service' diff --git a/packages/app-kit/src/purchase/components/loader.ts b/packages/app-kit/src/purchase/components/loader.ts index fb62d02..2ce5138 100644 --- a/packages/app-kit/src/purchase/components/loader.ts +++ b/packages/app-kit/src/purchase/components/loader.ts @@ -1,4 +1,4 @@ -import type { PaymentEnvironmentTag, PaymentProviderTag } from '@infra/purchase/payment/type' +import type { PaymentEnvironmentTag, PaymentProviderTag } from '@xstack/purchase/payment' import type { AppPlan } from '@xstack/app-kit/schema' import { useLoaderData } from 'react-router' diff --git a/packages/app-kit/src/schema.ts b/packages/app-kit/src/schema.ts index 66b3fe5..858af2f 100644 --- a/packages/app-kit/src/schema.ts +++ b/packages/app-kit/src/schema.ts @@ -1,4 +1,4 @@ -import { ProjectSubscription } from '@infra/purchase/namespace/schema' +import { ProjectSubscription } from '@xstack/purchase/api/schema' import { BillingCycle, PaymentCardType, @@ -6,7 +6,7 @@ import { PriceQuantity, TrialPeriod, UnitPrice, -} from '@infra/purchase/schema' +} from '@xstack/purchase/schema' import * as Schema from 'effect/Schema' import * as Struct from 'effect/Struct' @@ -69,7 +69,7 @@ export class AppPlan extends Schema.Class('@appkit:plan')({ } } -export { SubscriptionInfo } from '@infra/purchase/schema' +export { SubscriptionInfo } from '@xstack/purchase/schema' export class AppSubscription extends Schema.Class('@appkit:subscription')({ ...Struct.omit(ProjectSubscription.fields, 'latestPayment'), diff --git a/packages/app/src/components/boot.tsx b/packages/app/src/components/boot.tsx index ad1a6f8..14c5672 100644 --- a/packages/app/src/components/boot.tsx +++ b/packages/app/src/components/boot.tsx @@ -4,9 +4,9 @@ import * as Cause from 'effect/Cause' import * as Effect from 'effect/Effect' import type * as Layer from 'effect/Layer' import type { ReactNode } from 'react' -import { ErrorBoundary } from 'react-error-boundary' -import { Button } from '@/components/ui/button' -import { ErrorFullPageFallback } from '@xstack/errors/react/error-boundary' +// import { ErrorBoundary } from 'react-error-boundary' +// import { Button } from '@/components/ui/button' +// import { ErrorFullPageFallback } from '@xstack/errors/react/error-boundary' export const Boot = ({ layer, @@ -22,23 +22,23 @@ export const Boot = ({ children: ReactNode }) => { return ( - { - // @ts-ignore - globalThis.hideLoading() + // { + // // @ts-ignore + // globalThis.hideLoading() - onError?.(error) - }} - fallbackRender={({ error, resetErrorBoundary }) => ( - - - - )} - > + // onError?.(error) + // }} + // fallbackRender={({ error, resetErrorBoundary }) => ( + // + // + // + // )} + // > {children} - + // ) } diff --git a/packages/atom-react/src/hooks/use-pagination.ts b/packages/atom-react/src/hooks/use-pagination.ts index 2e56cd2..19f7e56 100644 --- a/packages/atom-react/src/hooks/use-pagination.ts +++ b/packages/atom-react/src/hooks/use-pagination.ts @@ -1,6 +1,6 @@ import * as Registry from '@effect-atom/atom/Registry' import * as Result from '@effect-atom/atom/Result' -import * as Atom from '@effect-atom/Atom/Atom' +import * as Atom from '@effect-atom/atom/Atom' import { useAtomSet, useAtomSuspense, useAtomValue } from '@xstack/atom-react/react' import * as Deferred from 'effect/Deferred' import * as Duration from 'effect/Duration' diff --git a/packages/atom-react/src/index.ts b/packages/atom-react/src/index.ts index 4bd72c9..5ac29b6 100644 --- a/packages/atom-react/src/index.ts +++ b/packages/atom-react/src/index.ts @@ -2,13 +2,13 @@ export * as Registry from '@effect-atom/atom/Registry' export * as Result from '@effect-atom/atom/Result' -export * as Atom from '@effect-atom/Atom/Atom' +export * as Atom from '@effect-atom/atom/Atom' -export * as AtomRef from '@effect-atom/Atom/AtomRef' +export * as AtomRef from '@effect-atom/atom/AtomRef' -export * as AtomRpc from '@effect-atom/Atom/AtomRpc' +export * as AtomRpc from '@effect-atom/atom/AtomRpc' -export * as AtomHttpApi from '@effect-atom/Atom/AtomHttpApi' +export * as AtomHttpApi from '@effect-atom/atom/AtomHttpApi' export * from '@xstack/atom-react/rx' diff --git a/packages/atom-react/src/react.ts b/packages/atom-react/src/react.ts index 1576900..aedbeaf 100644 --- a/packages/atom-react/src/react.ts +++ b/packages/atom-react/src/react.ts @@ -1,7 +1,7 @@ import * as Registry from '@effect-atom/atom/Registry' import * as Result from '@effect-atom/atom/Result' -import * as Atom from '@effect-atom/Atom/Atom' -import * as AtomRef from '@effect-atom/Atom/AtomRef' +import * as Atom from '@effect-atom/atom/Atom' +import * as AtomRef from '@effect-atom/atom/AtomRef' import { identity } from 'effect/Function' import * as Cause from 'effect/Cause' import * as Exit from 'effect/Exit' diff --git a/packages/atom-react/src/rx.ts b/packages/atom-react/src/rx.ts index 419f8ec..2f06f4c 100644 --- a/packages/atom-react/src/rx.ts +++ b/packages/atom-react/src/rx.ts @@ -1,6 +1,6 @@ import * as Registry from '@effect-atom/atom/Registry' import * as Result from '@effect-atom/atom/Result' -import * as Atom from '@effect-atom/Atom/Atom' +import * as Atom from '@effect-atom/atom/Atom' import { defaultRegistry, useAtom, diff --git a/packages/event-log/src/EventLogPlatformEffectsNative.ts b/packages/event-log/src/EventLogPlatformEffectsNative.ts index c266173..e35edb2 100644 --- a/packages/event-log/src/EventLogPlatformEffectsNative.ts +++ b/packages/event-log/src/EventLogPlatformEffectsNative.ts @@ -27,9 +27,11 @@ export const Live = Layer.effect( function* () { // Get internal SQL database size const internalDbPath = yield* sql.extra.getDbPath() - const internalDbSize = yield* Effect.promise(() => - ExpoFileSystem.getInfoAsync(internalDbPath).then((info) => (info.exists ? info.size : 0)), - ) + const internalDbSize = yield* Effect.sync(() => { + const file = new ExpoFileSystem.File(internalDbPath); + const info = file.info(); + return info.exists ? info.size??0 : 0; + }) // Get external database size const externalDbSize = yield* Effect.allSuccesses(externalStorage.get).pipe( @@ -40,7 +42,7 @@ export const Live = Layer.effect( const totalDbSize = internalDbSize + externalDbSize // Get free disk storage - const freeStorage = yield* Effect.promise(() => ExpoFileSystem.getFreeDiskStorageAsync()) + const freeStorage = yield* Effect.sync(() => ExpoFileSystem.Paths.availableDiskSpace) return EventLogPlatformEffects.LocalStorageStats.fromExpo(totalDbSize, freeStorage) }, diff --git a/packages/event-log/src/IdentityStorageNative.ts b/packages/event-log/src/IdentityStorageNative.ts index 3658203..9c9530a 100644 --- a/packages/event-log/src/IdentityStorageNative.ts +++ b/packages/event-log/src/IdentityStorageNative.ts @@ -35,14 +35,11 @@ const makeNativeStorage = Effect.gen(function* () { const db = (storage as unknown as { db: DB }).db const dbPath = db.getDbPath() - return Effect.promise(() => - ExpoFileSystem.getInfoAsync(dbPath).then((info) => { - if (info.exists) { - return info.size - } - return 0 - }), - ) + return Effect.sync(() => { + const file = new ExpoFileSystem.File(dbPath); + const info = file.info(); + return info.exists ? info.size??0 : 0; + }) }), Effect.scoped, ), diff --git a/packages/internal-kit/src/api.ts b/packages/internal-kit/src/api.ts index 6b110bd..91c77d6 100644 --- a/packages/internal-kit/src/api.ts +++ b/packages/internal-kit/src/api.ts @@ -10,7 +10,7 @@ export class InternalApi extends HttpApiGroup.make('internal') .annotateContext( OpenApi.annotations({ title: 'Internal API', - description: 'Internal API for Verx', + description: 'Internal', version: '0.0.1', }), ) {} diff --git a/packages/preset-react-native/src/context.ts b/packages/preset-react-native/src/context.ts index c200c5e..142e6a0 100644 --- a/packages/preset-react-native/src/context.ts +++ b/packages/preset-react-native/src/context.ts @@ -61,7 +61,7 @@ export const getCurrentLogLevel = () => { return LogLevel.Info } -const LoggerLive = Logger.prettyLoggerDefault.pipe( +const LoggerLive = Logger.withConsoleLog(Logger.stringLogger).pipe( Logger.filterLogLevel((level) => LogLevel.lessThanEqual(getCurrentLogLevel(), level)), ) @@ -101,7 +101,7 @@ export const ConfigProviderLive = Layer.suspend(() => { const envRecord = { NAMESPACE: 'template', SYNC: { - URL: 'http://192.168.10.125:8300/sync', + URL: 'http://localhost:8300/sync', STORAGE_LOCATION: ExpoFileSystem.Paths.document.uri ?? '/', }, } @@ -110,7 +110,6 @@ export const ConfigProviderLive = Layer.suspend(() => { // .filter(([key]) => key.startsWith("VITE_")) // .map(([key, value]) => [key.replace("VITE_", ""), value]), ) - // @ts-ignore const _map = globalThis.patchEnv?.(envMap) ?? envMap @@ -170,7 +169,7 @@ export const make = ( ) // @ts-ignore - globalThis.patchEnv = options.envPatch ?? (() => {}) + globalThis.patchEnv = options.envPatch ?? (() => { }) }), ) @@ -197,12 +196,12 @@ export const make = ( // Convert RequestInit to FetchRequestInit const fetchInit = init ? { - body: init.body, - credentials: init.credentials, - headers: init.headers, - method: init.method, - signal: init.signal, - } + body: init.body, + credentials: init.credentials, + headers: init.headers, + method: init.method, + signal: init.signal, + } : undefined return ExpoFetch(url, fetchInit as any) as unknown as Promise @@ -310,7 +309,7 @@ export const make = ( } } -const Test = Layer.effectDiscard( +const Test = Layer.scopedDiscard( Effect.gen(function* () { const identity = yield* Identity.Identity @@ -321,6 +320,7 @@ const Test = Layer.effectDiscard( yield* identity.importFromMnemonic( Redacted.make('motor royal future decade cousin modify phone roast empty village treat modify'), - ) + ).pipe( + Effect.forkScoped) }), ) diff --git a/packages/purchase/package.json b/packages/purchase/package.json new file mode 100644 index 0000000..9e2ee8f --- /dev/null +++ b/packages/purchase/package.json @@ -0,0 +1,6 @@ +{ + "name": "@xstack/purchase", + "type": "module", + "version": "0.0.0", + "sideEffects": false +} diff --git a/packages/purchase/project.json b/packages/purchase/project.json new file mode 100644 index 0000000..6e5f969 --- /dev/null +++ b/packages/purchase/project.json @@ -0,0 +1,32 @@ +{ + "name": "pkgs-purchase", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/purchase/src", + "projectType": "library", + "targets": { + "test": { + "executor": "nx:run-commands", + "outputs": [ + "{workspaceRoot}/node_modules/.vitest/packages/purchase", + "{workspaceRoot}/coverage/packages/purchase" + ], + "options": { + "command": "pnpm xdev test --project pkgs-purchase" + } + }, + "madge": { + "executor": "nx:run-commands", + "options": { + "command": "madge --ts-config ./tsconfig.lib.json --circular --no-color --no-spinner --extensions ts,tsx ./src", + "cwd": "packages/purchase" + } + }, + "typecheck": { + "executor": "nx:run-commands", + "options": { + "command": "tsgo -p packages/purchase/tsconfig.check.json" + } + } + }, + "tags": [] +} diff --git a/packages/purchase/src/api/schema.ts b/packages/purchase/src/api/schema.ts new file mode 100644 index 0000000..7cb0d51 --- /dev/null +++ b/packages/purchase/src/api/schema.ts @@ -0,0 +1,229 @@ +import { AppNamespace } from '@xstack/purchase/constants' +import { + BillingCycle, + BillingPeriod, + CustomerEmail, + CustomerId, + ErrorCode, + PaymentAttemptStatus, + PaymentCard, + PaymentMethodType, + PriceId, + PriceName, + PriceQuantity, + ProductId, + ProductName, + SubscriptionStatus, + TransactionId, + TrialPeriod, + UnitPrice, +} from '@xstack/purchase/schema' +import * as Schema from 'effect/Schema' + +// ----- Project ----- + +export class ProjectPrice extends Schema.Struct({ + /** + * price id + */ + id: PriceId, + /** + * product id + */ + productId: ProductId, + /** + * price name + */ + name: PriceName, + /** + * price + */ + unitPrice: UnitPrice, + /** + * billing cycle + */ + billingCycle: Schema.optional(BillingCycle), + /** + * trial period + */ + trialPeriod: Schema.optional(TrialPeriod), + /** + * price quantity + */ + quantity: PriceQuantity, + active: Schema.Boolean, +}) {} + +export class ProjectProduct extends Schema.Class('ProjectProduct')({ + /** + * product id + */ + id: ProductId, + /** + * product name + */ + name: ProductName, + active: Schema.Boolean, + /** + * product prices + */ + prices: Schema.Array(ProjectPrice), + /** + * product description + */ + description: Schema.String, +}) { + static decode = Schema.decodeUnknown(this) + static encode = Schema.encodeUnknown(this) +} + +export class ProjectSubscription extends Schema.Class('ProjectSubscription')({ + /** + * Subscription id + */ + id: Schema.String, + /** + * Subscription name + */ + name: Schema.String, + /** + * Subscription description + */ + description: Schema.String, + /** + * Subscription product id + */ + productId: ProductId, + /** + * Subscription price id + */ + priceId: PriceId, + /** + * Subscription price + */ + price: UnitPrice, + /** + * Subscription status + */ + status: SubscriptionStatus, + /** + * Subscription started at + */ + startedAt: Schema.optional(Schema.Date), + /** + * Subscription first billed at + */ + firstBilledAt: Schema.optional(Schema.Date), + /** + * Subscription next billed at + */ + nextBilledAt: Schema.optional(Schema.Date), + /** + * Subscription paused at + */ + pausedAt: Schema.optional(Schema.Date), + /** + * Subscription canceled at + */ + canceledAt: Schema.optional(Schema.Date), + /** + * Subscription billing cycle + */ + billingCycle: Schema.optional(BillingCycle), + /** + * Subscription billing period + */ + billingPeriod: Schema.optional(BillingPeriod), + /** + * Subscription trial period + */ + trialDates: Schema.optional(BillingPeriod), + /** + * Subscription management urls + */ + managementUrls: Schema.Struct({ + updatePaymentMethod: Schema.optional(Schema.String), + cancel: Schema.optional(Schema.String), + }), + /** + * Latest payment + */ + latestPayment: Schema.optional( + Schema.Struct({ + status: PaymentAttemptStatus, + price: UnitPrice, + error: Schema.optional(ErrorCode), + type: Schema.optional(PaymentMethodType), + card: Schema.optional(PaymentCard), + }), + ), +}) { + static decode = Schema.decodeUnknown(this) + static encode = Schema.encodeUnknown(this) +} + +export class ProjectTransaction extends Schema.Class('ProjectTransaction')({ + id: Schema.String, + name: Schema.String, + price: Schema.optional(UnitPrice), + invoiceId: Schema.optional(Schema.String), + reason: Schema.String, + status: Schema.Literal('completed', 'pending', 'failed'), + createdAt: Schema.Date, +}) {} + +// ----- Query ----- + +const PerPage = Schema.NumberFromString.pipe( + Schema.optional, + Schema.withConstructorDefault(() => 10), +) + +const Page = Schema.NumberFromString.pipe( + Schema.optional, + Schema.withConstructorDefault(() => 1), +) + +export class ProductsQuery extends Schema.Struct({ + namespace: AppNamespace, + active: Schema.optional(Schema.BooleanFromString), +}) {} + +export class ProductQuery extends Schema.Struct({ + namespace: AppNamespace, + productId: ProductId, +}) {} + +const ID = Schema.Union(CustomerId, CustomerEmail) + +export class CustomerQuery extends Schema.Struct({ + namespace: AppNamespace, + customerId: ID, +}) {} + +export class SubscriptionQuery extends Schema.Struct({ + namespace: AppNamespace, + customerId: ID, +}) {} + +export class SubscriptionCancelQuery extends Schema.Struct({ + namespace: AppNamespace, + customerId: ID, + subscriptionId: Schema.String, +}) {} + +export class TransactionsQuery extends Schema.Struct({ + namespace: AppNamespace, + customerId: ID, +}) {} + +export class TransactionsParams extends Schema.Struct({ + perPage: PerPage, + page: Page, +}) {} + +export class TransactionQuery extends Schema.Struct({ + namespace: AppNamespace, + customerId: ID, + transactionId: TransactionId, +}) {} diff --git a/packages/purchase/src/constants.ts b/packages/purchase/src/constants.ts new file mode 100644 index 0000000..535be28 --- /dev/null +++ b/packages/purchase/src/constants.ts @@ -0,0 +1,19 @@ +import type { CustomerId } from '@xstack/purchase/schema' +import * as Schema from 'effect/Schema' + +export const AppNamespace = Schema.String +export type AppNamespace = typeof AppNamespace.Type + +export const KV_PRODUCTS_KEY = '__products' +export const getProductsCacheKey = (namespace: string) => `${KV_PRODUCTS_KEY}::${namespace}` + +export const KV_CUSTOMER_KEY = '__customer' +export const getCustomerCacheKey = (customerId: CustomerId) => `${KV_CUSTOMER_KEY}::${customerId}` + +export const KV_SUBSCRIPTION_KEY = '__subscription' +export const getSubscriptionCacheKey = (namespace: string, customerId: CustomerId) => + `${KV_SUBSCRIPTION_KEY}::${namespace}::${customerId}` + +export const KV_TRANSACTIONS_KEY = '__transactions' +export const getTransactionsCacheKey = (namespace: string, customerId: CustomerId) => + `${KV_TRANSACTIONS_KEY}::${namespace}::${customerId}` diff --git a/packages/purchase/src/errors.ts b/packages/purchase/src/errors.ts new file mode 100644 index 0000000..808ceb8 --- /dev/null +++ b/packages/purchase/src/errors.ts @@ -0,0 +1,56 @@ +import * as HttpApiSchema from '@effect/platform/HttpApiSchema' +import * as Schema from 'effect/Schema' + +export class CustomerNotFound extends Schema.TaggedError()( + 'CustomerNotFound', + {}, + HttpApiSchema.annotations({ + status: 404, + }), +) {} + +export class ProductNotFound extends Schema.TaggedError()( + 'ProductNotFound', + {}, + HttpApiSchema.annotations({ + status: 404, + }), +) {} + +export class SubscriptionCancelError extends Schema.TaggedError()('SubscriptionCancelError', { + message: Schema.String, +}) {} + +export class SubscriptionNotFound extends Schema.TaggedError()( + 'SubscriptionNotFound', + { + message: Schema.String.pipe( + Schema.propertySignature, + Schema.withConstructorDefault(() => 'Subscription not found'), + ), + }, + HttpApiSchema.annotations({ + status: 404, + }), +) {} + +export class TransactionNotFound extends Schema.TaggedError()( + 'TransactionNotFound', + { + message: Schema.String.pipe( + Schema.propertySignature, + Schema.withConstructorDefault(() => 'Transaction not found'), + ), + }, + HttpApiSchema.annotations({ + status: 404, + }), +) {} + +export class InvoiceNotFound extends Schema.TaggedError()( + 'InvoiceNotFound', + {}, + HttpApiSchema.annotations({ + status: 404, + }), +) {} diff --git a/packages/purchase/src/http-api.ts b/packages/purchase/src/http-api.ts new file mode 100644 index 0000000..58de50a --- /dev/null +++ b/packages/purchase/src/http-api.ts @@ -0,0 +1,103 @@ +import * as HttpApi from '@effect/platform/HttpApi' +import * as HttpApiEndpoint from '@effect/platform/HttpApiEndpoint' +import * as HttpApiGroup from '@effect/platform/HttpApiGroup' +import * as OpenApi from '@effect/platform/OpenApi' +import { InvoiceNotFound, ProductNotFound, SubscriptionNotFound } from '@xstack/purchase/errors' +import { + ProductQuery, + ProductsQuery, + ProjectProduct, + ProjectSubscription, + ProjectTransaction, + SubscriptionCancelQuery, + SubscriptionQuery, + TransactionQuery, + TransactionsParams, + TransactionsQuery, +} from '@xstack/purchase/api/schema' +import { SubscriptionInfo } from '@xstack/purchase/schema' +import * as Schema from 'effect/Schema' + +class ProductsApi extends HttpApiGroup.make('products') + .add(HttpApiEndpoint.get('list', '/').setPath(ProductsQuery).addSuccess(Schema.Array(ProjectProduct))) + .add( + HttpApiEndpoint.get('get', '/:productId') + .setPath(ProductQuery) + .addSuccess(ProjectProduct) + .addError(ProductNotFound), + ) + .annotateContext( + OpenApi.annotations({ + title: 'Products Api', + description: 'Products Api', + }), + ) + .prefix('/:namespace/products') {} + +class SubscriptionApi extends HttpApiGroup.make('subscriptions') + .add( + HttpApiEndpoint.get('info', '/') + .setPath(SubscriptionQuery) + .addSuccess(SubscriptionInfo) + .addError(SubscriptionNotFound), + ) + .add( + HttpApiEndpoint.get('details', '/details') + .setPath(SubscriptionQuery) + .addSuccess(ProjectSubscription) + .addError(SubscriptionNotFound), + ) + .add( + HttpApiEndpoint.post('cancel', '/cancel/:subscriptionId') + .setPath(SubscriptionCancelQuery) + .addSuccess(Schema.Void) + .addError(SubscriptionNotFound), + ) + .annotateContext( + OpenApi.annotations({ + title: 'Subscriptions Api', + description: 'Subscriptions Api', + }), + ) + .prefix('/:namespace/subscriptions/:customerId') {} + +class TransactionsApi extends HttpApiGroup.make('transactions') + .add( + HttpApiEndpoint.get('list', '/') + .setPath(TransactionsQuery) + .setUrlParams(TransactionsParams) + .addSuccess(Schema.Struct({ items: Schema.Array(ProjectTransaction), isLast: Schema.Boolean })), + ) + .add( + HttpApiEndpoint.get('invoiceGeneratePDF', '/invoice-generate-pdf/:transactionId') + .setPath(TransactionQuery) + .addSuccess(Schema.String) + .addError(InvoiceNotFound), + ) + .annotateContext( + OpenApi.annotations({ + title: 'Transactions Api', + description: 'Transactions Api', + }), + ) + .prefix('/:namespace/transactions/:customerId') {} + +export class PurchaseApi extends HttpApi.make('purchase-api') + .add(ProductsApi) + .add(SubscriptionApi) + .add(TransactionsApi) + .annotateContext( + OpenApi.annotations({ + title: 'Purchase Api', + description: 'Purchase Api', + }), + ) {} + +export class MyHttpApi extends HttpApi.make('api') + .addHttpApi(PurchaseApi) + .annotateContext( + OpenApi.annotations({ + title: 'Payment Api', + description: 'Payment Api', + }), + ) {} diff --git a/packages/purchase/src/payment.ts b/packages/purchase/src/payment.ts new file mode 100644 index 0000000..53f46c1 --- /dev/null +++ b/packages/purchase/src/payment.ts @@ -0,0 +1,59 @@ +import type { PaymentClient } from '@xstack/purchase/payment/payment-client' +import { PaymentImpl, PaymentTags } from '@xstack/purchase/payment/payment-impl' +import type { PaymentProviderTag } from '@xstack/purchase/payment/type' +import * as Context from 'effect/Context' +import * as Effect from 'effect/Effect' +import { pipe } from 'effect/Function' +import * as Layer from 'effect/Layer' + +export * from '@xstack/purchase/payment/type' + +export { Paddle } from '@xstack/purchase/payment/paddle' + +export { Stripe } from '@xstack/purchase/payment/stripe' + +export class Payment extends Context.Tag('@purchase:payment')< + Payment, + { + readonly use: (tag: PaymentProviderTag) => Effect.Effect + } +>() { + static Default = Layer.effect( + this, + Effect.gen(function* () { + const tags = yield* PaymentTags + const get = (tag: PaymentProviderTag) => PaymentImpl.pipe(Effect.provide(tags.providers[tag])) + + return { + use: (tag) => + pipe( + get(tag), + Effect.flatMap((_) => _.make), + Effect.orDie, + ), + } + }), + ) + + static FromTag = ( + tag: T, + layerRecord: Record>, + ) => + Layer.effect( + this, + Effect.gen(function* () { + const tags = yield* PaymentTags + const get = tags.providers[tag] + const impl = yield* PaymentImpl.pipe(Effect.provide(get)) + + return { + use: () => impl.make.pipe(Effect.orDie), + } + }), + ).pipe(Layer.provide(PaymentTags.FromRecords(layerRecord))) + + static FromTags = (layerRecord: Record>) => + Payment.Default.pipe(Layer.provide(PaymentTags.FromRecords(layerRecord))) + + static client = Payment.pipe(Effect.flatMap((_) => _.use('any' as PaymentProviderTag))) +} diff --git a/packages/purchase/src/payment/internal/paddle-schema.ts b/packages/purchase/src/payment/internal/paddle-schema.ts new file mode 100644 index 0000000..668fe96 --- /dev/null +++ b/packages/purchase/src/payment/internal/paddle-schema.ts @@ -0,0 +1,391 @@ +import { AppNamespace } from '@xstack/purchase/constants' +import * as Schema from 'effect/Schema' + +export type PaddleEventName = + | 'address.created' + | 'address.updated' + | 'address.imported' + | 'adjustment.created' + | 'adjustment.updated' + | 'business.created' + | 'business.imported' + | 'business.updated' + | 'customer.created' + | 'customer.updated' + | 'customer.imported' + | 'discount.created' + | 'discount.updated' + | 'discount.imported' + | 'payment_method.deleted' + | 'payment_method.saved' + | 'payout.created' + | 'payout.paid' + | 'price.created' + | 'price.updated' + | 'price.imported' + | 'product.created' + | 'product.updated' + | 'product.imported' + | 'subscription.activated' + | 'subscription.canceled' + | 'subscription.imported' + | 'subscription.created' + | 'subscription.past_due' + | 'subscription.paused' + | 'subscription.resumed' + | 'subscription.trialing' + | 'subscription.updated' + | 'transaction.billed' + | 'transaction.canceled' + | 'transaction.completed' + | 'transaction.paid' + | 'transaction.created' + | 'transaction.past_due' + | 'transaction.payment_failed' + | 'transaction.ready' + | 'transaction.updated' + | 'transaction.revised' + | 'report.created' + | 'report.updated' + +const PaddleProductType = Schema.Literal('custom', 'standard') + +const PaddleTaxCategory = Schema.Literal( + 'digital-goods', + 'ebooks', + 'implementation-services', + 'professional-services', + 'saas', + 'software-programming-services', + 'standard', + 'training-services', + 'website-hosting', +) + +const PaddleObjectStatus = Schema.Literal('active', 'archived') + +const PaddleTaxMode = Schema.Literal('account_setting', 'external', 'internal') + +const PaddlePeriodInterval = Schema.Literal('day', 'week', 'month', 'year') + +const PaddleSubscriptionStatus = Schema.Literal('active', 'canceled', 'past_due', 'paused', 'trialing') + +const PaddleTransactionStatus = Schema.Literal('draft', 'ready', 'billed', 'paid', 'completed', 'canceled', 'past_due') + +const UnitPrice = Schema.Struct({ + amount: Schema.String, + currency_code: Schema.String, +}) + +const Quantity = Schema.Struct({ + minimum: Schema.Number, + maximum: Schema.Number, +}) + +const BillingCycle = Schema.Struct({ + interval: PaddlePeriodInterval, + frequency: Schema.Number, +}) + +const CustomData = Schema.Record({ key: Schema.String, value: Schema.Any }).pipe( + Schema.optionalWith({ exact: true, nullable: true, default: () => ({}) }), +) + +const DetailsTotals = Schema.Struct({ + subtotal: Schema.String, + tax: Schema.String, + discount: Schema.String, + total: Schema.String, + grand_total: Schema.String, + fee: Schema.String.pipe(Schema.optionalWith({ exact: true, nullable: true })), + credit: Schema.String, + credit_to_balance: Schema.String, + balance: Schema.String, + earnings: Schema.String.pipe(Schema.optionalWith({ exact: true, nullable: true })), + currency_code: Schema.String, +}) + +const UnitTotals = Schema.Struct({ + subtotal: Schema.String, + discount: Schema.String, + tax: Schema.String, + total: Schema.String, +}) + +const AdjustedTotals = Schema.Struct({ + subtotal: Schema.String, + tax: Schema.String, + total: Schema.String, + grand_total: Schema.String, + fee: Schema.String.pipe(Schema.optionalWith({ exact: true, nullable: true })), + earnings: Schema.String.pipe(Schema.optionalWith({ exact: true, nullable: true })), + currency_code: Schema.String, +}) + +const Checkout = Schema.Struct({ + url: Schema.String, +}) + +const TaxRatesUsed = Schema.Struct({ + tax_rate: Schema.String, + totals: UnitTotals, +}) + +export const PaddleProductCustomData = Schema.Struct({ + namespace: AppNamespace, +}) + +export class PaddleProduct extends Schema.Class('PaddleProduct')({ + id: Schema.String, + name: Schema.String, + tax_category: PaddleTaxCategory, + type: PaddleProductType, + description: Schema.String, + image_url: Schema.String.pipe(Schema.optionalWith({ exact: true, nullable: true })), + custom_data: PaddleProductCustomData, + status: PaddleObjectStatus, + import_meta: CustomData, + created_at: Schema.Date, + updated_at: Schema.Date, +}) {} + +const LineItem = Schema.Struct({ + id: Schema.String, + price_id: Schema.String, + quantity: Schema.Number, + totals: UnitTotals, + product: PaddleProduct, + tax_rate: Schema.String, + unit_totals: UnitTotals, +}) + +const PaymentAttemptStatus = Schema.Literal( + 'canceled', + 'authorized', + 'authorized_flagged', + 'captured', + 'error', + 'action_required', + 'pending_no_action_required', + 'created', + 'unknown', + 'dropped', +) + +const PaymentMethodType = Schema.String + +const PaymentCardType = Schema.String + +const PaymentCard = Schema.Struct({ + type: PaymentCardType, + last4: Schema.String, + expiryMonth: Schema.Number, + expiryYear: Schema.Number, + cardholderName: Schema.String, +}) + +const PaymentAttempt = Schema.Struct({ + id: Schema.String.pipe(Schema.optionalWith({ exact: true, nullable: true })), + amount: Schema.String, + status: PaymentAttemptStatus, + error: Schema.optional(Schema.String), + details: Schema.optionalWith( + Schema.Struct({ + type: PaymentMethodType, + card: Schema.optionalWith(PaymentCard, { exact: true, nullable: true }), + }), + { nullable: true }, + ), + created_at: Schema.Date, + captured_at: Schema.optionalWith(Schema.Date, { exact: true, nullable: true }), +}) + +export const PaddlePriceCustomData = Schema.Struct({ + namespace: AppNamespace, +}) + +export class PaddlePrice extends Schema.Class('PaddlePrice')({ + id: Schema.String, + product_id: Schema.String, + type: PaddleProductType, + description: Schema.String, + name: Schema.String.pipe(Schema.optionalWith({ exact: true, nullable: true })), + billing_cycle: BillingCycle.pipe(Schema.optionalWith({ exact: true, nullable: true })), + trial_period: Schema.Struct({ + interval: PaddlePeriodInterval, + frequency: Schema.Number, + }).pipe(Schema.optionalWith({ exact: true, nullable: true })), + tax_mode: PaddleTaxMode, + unit_price: UnitPrice, + unit_price_overrides: Schema.Array( + Schema.Struct({ + country_codes: Schema.Array(Schema.String), + unit_price: UnitPrice, + }), + ), + custom_data: PaddlePriceCustomData.pipe(Schema.optionalWith({ exact: true, nullable: true })), + status: PaddleObjectStatus, + quantity: Quantity, + import_meta: CustomData, + created_at: Schema.Date, + updated_at: Schema.Date, +}) {} + +export const PaddleCustomerCustomData = Schema.Struct({ + namespace: AppNamespace, + userId: Schema.String, +}) + +export class PaddleCustomer extends Schema.Class('PaddleCustomer')({ + id: Schema.String, + status: PaddleObjectStatus, + custom_data: CustomData, + name: Schema.String.pipe(Schema.optionalWith({ exact: true, nullable: true })), + email: Schema.String, + marketing_consent: Schema.Boolean, + locale: Schema.String, + created_at: Schema.Date, + updated_at: Schema.Date, + import_meta: CustomData, +}) {} + +const BillingPeriod = Schema.Struct({ + starts_at: Schema.Date, + ends_at: Schema.Date, +}) + +const BillingDetails = Schema.Struct({ + payment_terms: Schema.Struct({ + interval: PaddlePeriodInterval, + frequency: Schema.Number, + }), + enable_checkout: Schema.Boolean, + purchase_order_number: Schema.String, + additional_information: Schema.String.pipe(Schema.optionalWith({ exact: true, nullable: true })), +}) + +const SubscriptionItem = Schema.Struct({ + status: Schema.String, + quantity: Schema.Number, + recurring: Schema.Boolean, + price: PaddlePrice, + product: PaddleProduct, +}) + +export class PaddleSubscription extends Schema.Class('PaddleSubscription')({ + id: Schema.String, + status: PaddleSubscriptionStatus, + customer_id: Schema.String, + address_id: Schema.String, + business_id: Schema.String.pipe(Schema.optionalWith({ exact: true, nullable: true })), + currency_code: Schema.String, + created_at: Schema.Date, + updated_at: Schema.Date, + started_at: Schema.Date, + first_billed_at: Schema.Date.pipe(Schema.optionalWith({ exact: true, nullable: true })), + next_billed_at: Schema.Date.pipe(Schema.optionalWith({ exact: true, nullable: true })), + paused_at: Schema.Date.pipe(Schema.optionalWith({ exact: true, nullable: true })), + canceled_at: Schema.Date.pipe(Schema.optionalWith({ exact: true, nullable: true })), + collection_mode: Schema.String, + billing_details: BillingDetails.pipe(Schema.optionalWith({ exact: true, nullable: true })), + current_billing_period: BillingPeriod.pipe(Schema.optionalWith({ exact: true, nullable: true })), + billing_cycle: BillingCycle, + scheduled_change: Schema.Struct({ + action: Schema.Literal('cancel', 'pause', 'resume'), + effective_at: Schema.Date, + resume_at: Schema.Date.pipe(Schema.optionalWith({ exact: true, nullable: true })), + }).pipe(Schema.optionalWith({ exact: true, nullable: true })), + items: Schema.Array(SubscriptionItem), + next_transaction: Schema.Struct({ + billing_period: BillingPeriod, + details: Schema.Struct({ + tax_rates_used: Schema.Array(TaxRatesUsed), + line_items: Schema.Array(LineItem), + totals: Schema.Struct({ + subtotal: Schema.String, + discount: Schema.String, + tax: Schema.String, + total: Schema.String, + credit: Schema.String, + creditToBalance: Schema.String, + balance: Schema.String, + grandTotal: Schema.String, + fee: Schema.optionalWith(Schema.String, { exact: true, nullable: true }), + earnings: Schema.optionalWith(Schema.String, { exact: true, nullable: true }), + currencyCode: Schema.String, + }), + }), + }).pipe(Schema.optionalWith({ exact: true, nullable: true })), + custom_data: PaddleCustomerCustomData.pipe(Schema.optionalWith({ exact: true, nullable: true })), + management_urls: Schema.Struct({ + update_payment_method: Schema.String, + cancel: Schema.String, + }), + discount: Schema.Struct({ + id: Schema.String, + starts_at: Schema.Date, + ends_at: Schema.Date, + }).pipe(Schema.optionalWith({ exact: true, nullable: true })), + import_meta: CustomData, +}) {} + +const TransactionItem = Schema.Struct({ + quantity: Schema.Number, + price: PaddlePrice, + proration: Schema.Struct({ + rate: Schema.String, + billing_period: BillingPeriod, + }).pipe(Schema.optionalWith({ exact: true, nullable: true })), +}) + +export class PaddleTransaction extends Schema.Class('PaddleTransaction')({ + id: Schema.String, + status: PaddleTransactionStatus, + customer_id: Schema.String, + address_id: Schema.String.pipe(Schema.optionalWith({ exact: true, nullable: true })), + business_id: Schema.String.pipe(Schema.optionalWith({ exact: true, nullable: true })), + custom_data: PaddleCustomerCustomData.pipe(Schema.optionalWith({ exact: true, nullable: true })), + origin: Schema.String, + collection_mode: Schema.String, + subscription_id: Schema.String.pipe(Schema.optionalWith({ exact: true, nullable: true })), + invoice_id: Schema.String.pipe(Schema.optionalWith({ exact: true, nullable: true })), + invoice_number: Schema.String.pipe(Schema.optionalWith({ exact: true, nullable: true })), + billing_details: BillingDetails.pipe(Schema.optionalWith({ exact: true, nullable: true })), + billing_period: BillingPeriod.pipe(Schema.optionalWith({ exact: true, nullable: true })), + currency_code: Schema.String, + discount_id: Schema.String.pipe(Schema.optionalWith({ exact: true, nullable: true })), + created_at: Schema.Date, + updated_at: Schema.Date, + billed_at: Schema.Date.pipe(Schema.optionalWith({ exact: true, nullable: true })), + revised_at: Schema.Date.pipe(Schema.optionalWith({ exact: true, nullable: true })), + items: Schema.Array(TransactionItem), + details: Schema.Struct({ + tax_rates_used: Schema.Array(TaxRatesUsed), + totals: DetailsTotals, + adjusted_totals: AdjustedTotals, + payout_totals: Schema.Struct({ + subtotal: Schema.String, + tax: Schema.String, + discount: Schema.String, + total: Schema.String, + credit: Schema.String, + credit_to_balance: Schema.String, + balance: Schema.String, + }).pipe(Schema.optionalWith({ exact: true, nullable: true })), + adjusted_payout_totals: Schema.Struct({ + subtotal: Schema.String, + tax: Schema.String, + total: Schema.String, + fee: Schema.String, + chargeback_fee: Schema.Struct({ + amount: Schema.String, + original: Schema.String.pipe(Schema.optionalWith({ exact: true, nullable: true })), + }), + earnings: Schema.String.pipe(Schema.optionalWith({ exact: true, nullable: true })), + currency_code: Schema.String, + }).pipe(Schema.optionalWith({ exact: true, nullable: true })), + line_items: Schema.Array(LineItem), + }), + payments: Schema.Array(PaymentAttempt), + checkout: Checkout, +}) {} diff --git a/packages/purchase/src/payment/internal/paddle-sdk.ts b/packages/purchase/src/payment/internal/paddle-sdk.ts new file mode 100644 index 0000000..f7ba110 --- /dev/null +++ b/packages/purchase/src/payment/internal/paddle-sdk.ts @@ -0,0 +1,591 @@ +import * as FetchHttpClient from '@effect/platform/FetchHttpClient' +import * as HttpBody from '@effect/platform/HttpBody' +import * as HttpClient from '@effect/platform/HttpClient' +import * as HttpClientError from '@effect/platform/HttpClientError' +import * as HttpClientRequest from '@effect/platform/HttpClientRequest' +import * as HttpClientResponse from '@effect/platform/HttpClientResponse' +import { + PaddleCustomer, + PaddlePrice, + PaddleProduct, + PaddleSubscription, + PaddleTransaction, +} from '@xstack/purchase/payment/internal/paddle-schema' +import { CustomerAlreadyExistsError, CustomerNotFoundError, WebhookUnmarshalError } from '@xstack/purchase/schema' +import type { IEvents } from '@paddle/paddle-node-sdk' +import * as Config from 'effect/Config' +import * as Context from 'effect/Context' +import * as Effect from 'effect/Effect' +import { pipe } from 'effect/Function' +import * as Layer from 'effect/Layer' +import * as Option from 'effect/Option' +import * as Redacted from 'effect/Redacted' +import * as Schema from 'effect/Schema' + +const PaddleConfig_ = Config.all({ + apiToken: Config.redacted('PADDLE_API_TOKEN').pipe(Config.withDefault(Redacted.make(''))), + webhookToken: Config.redacted('PADDLE_WEBHOOK_TOKEN').pipe(Config.withDefault(Redacted.make(''))), + environment: Config.literal('sandbox', 'production')('PADDLE_ENVIRONMENT').pipe(Config.withDefault('sandbox')), +}) +export type PaddleConfig = Config.Config.Success +export const PaddleConfig = Context.GenericTag('@purchase:payment-paddle-config') + +export const PaddleConfigFromEnv = Layer.effect(PaddleConfig, PaddleConfig_) + +export const PaddleConfigFromRecord = (config: PaddleConfig) => Layer.succeed(PaddleConfig, config) + +class ProductNotFoundError extends Schema.TaggedError()('ProductNotFoundError', { + productId: Schema.String, +}) {} + +class PriceNotFoundError extends Schema.TaggedError()('PriceNotFoundError', { + priceId: Schema.String, +}) {} + +class TransactionNotFoundError extends Schema.TaggedError()('TransactionNotFoundError', { + transactionId: Schema.String, +}) {} + +class SubscriptionNotFoundError extends Schema.TaggedError()('SubscriptionNotFoundError', { + subscriptionId: Schema.String, +}) {} + +const PaddleError = Schema.Struct({ + error: Schema.Struct({ + type: Schema.Literal('request_error', 'api_error'), + code: Schema.String, + detail: Schema.String, + documentation_url: Schema.String, + }), +}) + +export class PaddleSdk extends Effect.Service()('PaddleSdk', { + effect: Effect.gen(function* () { + const config = yield* PaddleConfig + const { apiToken, environment } = config + + const apiUrl = environment === 'sandbox' ? 'https://sandbox-api.paddle.com' : 'https://api.paddle.com' + + const client = (yield* HttpClient.HttpClient.pipe(Effect.provide(FetchHttpClient.layer))).pipe( + HttpClient.mapRequest((request) => + request.pipe( + HttpClientRequest.prependUrl(apiUrl), + HttpClientRequest.bearerToken(Redacted.value(apiToken)), + HttpClientRequest.acceptJson, + ), + ), + ) + + const unexpectedStatus = ( + request: HttpClientRequest.HttpClientRequest, + response: HttpClientResponse.HttpClientResponse, + ) => + Effect.flatMap( + Effect.all([ + Effect.orElseSucceed(response.text, () => 'Unexpected status code'), + Effect.orElseSucceed(Schema.decodeUnknown(PaddleError)(response.json), () => {}), + ]), + ([description, json]) => + Effect.fail( + new HttpClientError.ResponseError({ + request, + response, + reason: 'StatusCode', + description: json ? json.error.detail : description, + cause: json ? json.error : undefined, + }), + ), + ) + + const clientOK = HttpClient.filterOrElse( + client, + (self) => { + return self.status >= 200 && self.status < 300 + }, + (response) => unexpectedStatus(response.request, response), + ) + + const prices = { + list: Effect.fn(function* ( + args: { + recurring?: boolean | undefined + status?: string[] | undefined + productId?: string[] | undefined + type?: string[] | undefined + after?: string | undefined + perPage?: number | undefined + } = {}, + ) { + const status = args.status ?? ['active', 'archived'] + const res = yield* clientOK.get('/prices', { + urlParams: { + recurring: args.recurring, + after: args.after, + status, + product_id: args.productId, + per_page: args.perPage, + }, + }) + + const result = yield* pipe( + res, + HttpClientResponse.schemaBodyJson(Schema.Struct({ data: Schema.Array(PaddlePrice) })), + Effect.map(({ data }) => data), + Effect.catchTag('ParseError', Effect.die), + ) + + return result + }), + get: Effect.fn(function* (args: { priceId: string }) { + const res = yield* client.get(`/prices/${args.priceId}`) + + const result = yield* pipe( + res, + HttpClientResponse.matchStatus({ + 200: (response) => HttpClientResponse.schemaBodyJson(Schema.Struct({ data: PaddlePrice }))(response), + 404: (response) => Effect.fail(new PriceNotFoundError({ priceId: response.request.url })), + orElse: (response) => unexpectedStatus(response.request, response), + }), + Effect.map(({ data }) => Option.fromNullable(data)), + Effect.catchTags({ + ParseError: Effect.die, + PriceNotFoundError: () => Effect.succeed(Option.none()), + }), + ) + + return result + }), + } + + const products = { + list: Effect.fn(function* ( + args: { + after?: string | undefined + status?: string[] | undefined + perPage?: number | undefined + orderBy?: string | undefined + } = {}, + ) { + const res = yield* clientOK.get('/products', { + urlParams: { + status: args.status, + after: args.after, + per_page: args.perPage, + order_by: args.orderBy, + }, + }) + + const result = yield* pipe( + res, + HttpClientResponse.schemaBodyJson( + Schema.Struct({ + data: Schema.Array(PaddleProduct), + }), + ), + Effect.map(({ data }) => data), + Effect.catchTag('ParseError', Effect.die), + ) + + return result + }), + + get: Effect.fn(function* (args: { productId: string }) { + const res = yield* client.get(`/products/${args.productId}`) + + const result = yield* pipe( + res, + HttpClientResponse.matchStatus({ + 200: (response) => HttpClientResponse.schemaBodyJson(Schema.Struct({ data: PaddleProduct }))(response), + 404: (response) => Effect.fail(new ProductNotFoundError({ productId: response.request.url })), + orElse: (response) => unexpectedStatus(response.request, response), + }), + Effect.map(({ data }) => Option.fromNullable(data)), + Effect.catchTags({ + ParseError: Effect.die, + ProductNotFoundError: () => Effect.succeed(Option.none()), + }), + ) + + return result + }), + } + + const customers = { + list: Effect.fn(function* ( + args: { + active?: boolean | undefined + after?: string | undefined + perPage?: number | undefined + } = {}, + ) { + const active = args.active ?? true + const res = yield* clientOK.get('/customers', { + urlParams: { + status: active ? 'active' : 'archived', + after: args.after, + per_page: args.perPage, + }, + }) + + const result = yield* pipe( + res, + HttpClientResponse.schemaBodyJson(Schema.Struct({ data: Schema.Array(PaddleCustomer) })), + Effect.map(({ data }) => data), + Effect.catchTag('ParseError', Effect.die), + ) + + return result + }), + + get: Effect.fn(function* (args: { customerId: string }) { + const res = yield* client.get(`/customers/${args.customerId}`) + + const result = yield* pipe( + res, + HttpClientResponse.matchStatus({ + 200: (response) => HttpClientResponse.schemaBodyJson(Schema.Struct({ data: PaddleCustomer }))(response), + 404: (response) => Effect.fail(new CustomerNotFoundError({ customerId: response.request.url })), + orElse: (response) => unexpectedStatus(response.request, response), + }), + Effect.map(({ data }) => Option.fromNullable(data)), + Effect.catchTags({ + ParseError: Effect.die, + CustomerNotFoundError: () => Effect.succeed(Option.none()), + }), + ) + + return result + }), + + find: Effect.fn(function* ( + args: { + id?: string[] | undefined + email?: string[] | undefined + perPage?: number | undefined + } = {}, + ) { + const res = yield* clientOK.get('/customers', { + urlParams: { + id: args.id, + email: args.email, + per_page: args.perPage, + }, + }) + + const result = yield* pipe( + res, + HttpClientResponse.schemaBodyJson(Schema.Struct({ data: Schema.Array(PaddleCustomer) })), + Effect.map(({ data }) => data), + Effect.catchTag('ParseError', Effect.die), + ) + + return result + }), + + create: Effect.fn(function* (args: { + email: string + userId: string + name?: string | undefined + locale?: string | undefined + }) { + const res = yield* client.post('/customers', { + acceptJson: true, + body: HttpBody.unsafeJson({ + email: args.email, + name: args.name, + custom_data: { + userId: args.userId, + }, + locale: args.locale, + }), + }) + + const result = yield* pipe( + res, + HttpClientResponse.matchStatus({ + 201: (response) => HttpClientResponse.schemaBodyJson(Schema.Struct({ data: PaddleCustomer }))(response), + 409: () => + Effect.fail( + new CustomerAlreadyExistsError({ + email: args.email, + userId: args.userId, + }), + ), + orElse: (response) => unexpectedStatus(response.request, response), + }), + Effect.map(({ data }) => data), + Effect.catchTag('ParseError', Effect.die), + ) + + return result + }), + + update: Effect.fn(function* (args: { + customerId: string + email?: string | undefined + name?: string | undefined + locale?: string | undefined + }) { + const res = yield* clientOK.patch(`/customers/${args.customerId}`, { + body: HttpBody.unsafeJson({ + name: args.name, + locale: args.locale, + email: args.email, + }), + }) + + const result = yield* pipe( + res, + HttpClientResponse.schemaBodyJson(Schema.Struct({ data: PaddleCustomer })), + Effect.map(({ data }) => data), + Effect.catchTag('ParseError', Effect.die), + ) + + return result + }), + } + + const subscriptions = { + list: Effect.fn(function* (args: { + customerId?: string | undefined + status?: string[] | undefined + after?: string | undefined + perPage?: number | undefined + orderBy?: string | undefined + }) { + const res = yield* clientOK.get('/subscriptions', { + urlParams: { + customer_id: args.customerId ? [args.customerId] : undefined, + status: args.status, + after: args.after, + per_page: args.perPage, + order_by: args.orderBy, + }, + }) + + const result = yield* pipe( + res, + HttpClientResponse.schemaBodyJson(Schema.Struct({ data: Schema.Array(PaddleSubscription) })), + Effect.map(({ data }) => data), + Effect.catchTag('ParseError', Effect.die), + ) + + return result + }), + + get: Effect.fn(function* (args: { subscriptionId: string }) { + const res = yield* client.get(`/subscriptions/${args.subscriptionId}`) + + const result = yield* pipe( + res, + HttpClientResponse.matchStatus({ + 200: (response) => HttpClientResponse.schemaBodyJson(Schema.Struct({ data: PaddleSubscription }))(response), + 404: (response) => + Effect.fail( + new SubscriptionNotFoundError({ + subscriptionId: response.request.url, + }), + ), + orElse: (response) => unexpectedStatus(response.request, response), + }), + Effect.map(({ data }) => Option.fromNullable(data)), + Effect.catchTags({ + ParseError: Effect.die, + SubscriptionNotFoundError: () => Effect.succeed(Option.none()), + }), + ) + + return result + }), + + cancel: Effect.fn(function* (args: { subscriptionId: string; immediate?: boolean }) { + const immediate = args.immediate ?? false + + yield* clientOK.post(`/subscriptions/${args.subscriptionId}/cancel`, { + body: HttpBody.unsafeJson({ + effective_from: immediate ? 'immediately' : 'next_billing_period', + }), + }) + }), + } + + const transactions = { + list: Effect.fn(function* (args: { + customerId?: string | undefined + include?: string[] | undefined + status?: string[] | undefined + after?: string | undefined + perPage?: number | undefined + orderBy?: string | undefined + }) { + const res = yield* clientOK.get('/transactions', { + urlParams: { + customer_id: typeof args.customerId !== 'undefined' ? [args.customerId] : undefined, + status: args.status, + after: args.after, + per_page: args.perPage, + order_by: args.orderBy, + }, + }) + + const result = yield* pipe( + res, + HttpClientResponse.schemaBodyJson(Schema.Struct({ data: Schema.Array(PaddleTransaction) })), + Effect.map(({ data }) => data), + Effect.catchTag('ParseError', Effect.die), + ) + + return result + }), + + get: Effect.fn(function* (args: { transactionId: string }) { + const res = yield* client.get(`/transactions/${args.transactionId}`) + + const result = yield* pipe( + res, + HttpClientResponse.matchStatus({ + 200: (response) => HttpClientResponse.schemaBodyJson(Schema.Struct({ data: PaddleTransaction }))(response), + 404: (response) => + Effect.fail( + new TransactionNotFoundError({ + transactionId: response.request.url, + }), + ), + orElse: (response) => unexpectedStatus(response.request, response), + }), + Effect.map(({ data }) => Option.fromNullable(data)), + Effect.catchTags({ + ParseError: Effect.die, + TransactionNotFoundError: () => Effect.succeed(Option.none()), + }), + ) + + return result + }), + + generateInvoicePDF: Effect.fn(function* (args: { transactionId: string }) { + const res = yield* clientOK.get(`/transactions/${args.transactionId}/invoice`) + + const result = yield* pipe( + res, + HttpClientResponse.schemaBodyJson(Schema.Struct({ url: Schema.String })), + Effect.map(({ url }) => url), + Effect.catchTag('ParseError', Effect.die), + ) + + return result + }), + } + + const webhooksUnmarshal = Effect.fn(function* (requestBody: string, secretKey: string, signature: string) { + yield* Effect.tryPromise(() => new Webhooks().isValidSignature(requestBody, secretKey, signature)).pipe( + Effect.filterOrFail( + (isSignatureValid) => isSignatureValid, + () => new WebhookUnmarshalError({ error: 'Invalid signature' }), + ), + Effect.catchAll((error) => + Effect.fail( + new WebhookUnmarshalError({ + error: 'Invalid request body', + cause: error, + }), + ), + ), + ) + + return Webhooks.fromJson(requestBody) + }) + + return { + config, + prices, + products, + customers, + subscriptions, + transactions, + webhooksUnmarshal, + } as const + }), + dependencies: [], +}) {} + +interface ParsedHeaders { + ts: number + h1: string +} + +class Webhooks { + private static readonly MAX_VALID_TIME_DIFFERENCE = 5 + + private extractHeader(header: string): ParsedHeaders { + const parts = header.split(';') + let ts = '' + let h1 = '' + for (const part of parts) { + const [key, value] = part.split('=') + if (value) { + if (key === 'ts') { + ts = value + } else if (key === 'h1') { + h1 = value + } + } + } + if (ts && h1) { + return { ts: Number.parseInt(ts), h1 } + } + throw new Error('[Paddle] Invalid webhook signature') + } + + private async computeHmac(payload: string, secret: string): Promise { + const byteHexMapping = Array.from({ length: 256 }) + for (let i = 0; i < byteHexMapping.length; i++) { + byteHexMapping[i] = i.toString(16).padStart(2, '0') + } + const encoder = new TextEncoder() + + const key = await crypto.subtle.importKey( + 'raw', + encoder.encode(secret), + { + name: 'HMAC', + hash: { name: 'SHA-256' }, + }, + false, + ['sign'], + ) + + const signatureBuffer = await crypto.subtle.sign('hmac', key, encoder.encode(payload)) + + // crypto.subtle returns the signature in base64 format. This must be + // encoded in hex to match the CryptoProvider contract. We map each byte in + // the buffer to its corresponding hex octet and then combine into a string. + const signatureBytes = new Uint8Array(signatureBuffer) + const signatureHexCodes = Array.from({ length: signatureBytes.length }) + + for (let i = 0; i < signatureBytes.length; i++) { + if (signatureBytes[i] !== undefined && signatureBytes[i] !== null) { + signatureHexCodes[i] = byteHexMapping[signatureBytes[i]!] + } + } + + return signatureHexCodes.join('') + } + + public async isValidSignature(requestBody: string, secretKey: string, signature: string) { + const headers = this.extractHeader(signature) + const payloadWithTime = `${headers.ts}:${requestBody}` + + // [FIXME] 暂时关闭时间戳验证 + // if (new Date().getTime() > new Date((headers.ts + Webhooks.MAX_VALID_TIME_DIFFERENCE) * 1000).getTime()) { + // return false + // } + + const computedHash = await this.computeHmac(payloadWithTime, secretKey) + return computedHash === headers.h1 + } + + public static fromJson(parsedRequest: any) { + return JSON.parse(parsedRequest) as IEvents + } +} diff --git a/packages/purchase/src/payment/internal/stripe-sdk.ts b/packages/purchase/src/payment/internal/stripe-sdk.ts new file mode 100644 index 0000000..8fa7095 --- /dev/null +++ b/packages/purchase/src/payment/internal/stripe-sdk.ts @@ -0,0 +1,30 @@ +import * as Config from 'effect/Config' +import * as Context from 'effect/Context' +import * as Effect from 'effect/Effect' +import * as Layer from 'effect/Layer' +import * as Stripe from 'stripe' + +export const StripeConfig_ = Config.all({ + apiKey: Config.redacted('STRIPE_API_KEY').pipe(Config.withDefault('')), +}) +export type StripeConfig = Config.Config.Success +export const StripeConfig = Context.GenericTag('@purchase:payment-stripe-config') + +export const StripeConfigFromEnv = Layer.effect( + StripeConfig, + Effect.gen(function* () { + const { apiKey } = yield* StripeConfig_ + + return StripeConfig.of({ apiKey }) + }), +) + +export const StripeConfigFromRecord = (config: StripeConfig) => Layer.succeed(StripeConfig, config) + +export class StripeSdk extends Effect.Service()('StripeSdk', { + effect: Effect.gen(function* () { + const _stripe = new Stripe.Stripe('') + + return {} as any + }), +}) {} diff --git a/packages/purchase/src/payment/paddle.ts b/packages/purchase/src/payment/paddle.ts new file mode 100644 index 0000000..02f2e92 --- /dev/null +++ b/packages/purchase/src/payment/paddle.ts @@ -0,0 +1,663 @@ +import type { + PaddleCustomer, + PaddlePrice, + PaddleProduct, + PaddleSubscription, + PaddleTransaction, +} from '@xstack/purchase/payment/internal/paddle-schema' +import { + type PaddleConfig, + PaddleConfigFromEnv, + PaddleConfigFromRecord, + PaddleSdk, +} from '@xstack/purchase/payment/internal/paddle-sdk' +import type { PaddleImpl, PaymentClient } from '@xstack/purchase/payment/payment-client' +import { PaymentImpl } from '@xstack/purchase/payment/payment-impl' +import type { PaymentProviderTag } from '@xstack/purchase/payment/type' +import { + Customer, + type CustomerId, + type CustomerProviderId, + InvoiceNotFoundError, + type Price, + Product, + type ProductId, + Subscription, + type SubscriptionId, + Transaction, + type TransactionId, +} from '@xstack/purchase/schema' +import * as Chunk from 'effect/Chunk' +import * as Context from 'effect/Context' +import * as Effect from 'effect/Effect' +import { pipe } from 'effect/Function' +import * as Layer from 'effect/Layer' +import * as Option from 'effect/Option' +import * as Redacted from 'effect/Redacted' +import * as Stream from 'effect/Stream' + +function getPaymentReason(origin: string) { + if (origin === 'web' || origin === 'subscription_charge') { + return 'New' + } + return 'Renewal of ' +} + +const formatTransactionStatus = (status: typeof PaddleTransaction.Type.status): (typeof Transaction.Type)['status'] => { + return status +} + +const formatSubscriptionStatus = ( + status: typeof PaddleSubscription.Type.status, +): (typeof Subscription.Type)['status'] => { + return status +} + +const formatCustomer = (_: PaddleCustomer): typeof Customer.Encoded => { + return { + id: _.id, + email: _.email, + name: _.name || _.email, + metadata: _.custom_data, + } satisfies typeof Customer.Encoded +} + +const formatPrices = (_: PaddlePrice): typeof Price.Encoded => { + return { + id: _.id, + name: _.name || 'unknown', + productId: _.product_id, + unitPrice: { + amount: _.unit_price.amount, + currencyCode: _.unit_price.currency_code, + }, + unitPriceOverride: _.unit_price_overrides.map((_) => { + return { + countryCodes: _.country_codes, + unitPrice: { + amount: _.unit_price.amount, + currencyCode: _.unit_price.currency_code, + }, + } + }), + billingCycle: _.billing_cycle || null, + trialPeriod: _.trial_period || null, + active: _.status === 'active', + createdAt: _.created_at.toISOString(), + updatedAt: _.updated_at.toISOString(), + quantity: _.quantity, + metadata: _.custom_data || {}, + } satisfies typeof Price.Encoded +} + +const formatProduct = (_: PaddleProduct, prices: readonly PaddlePrice[]): typeof Product.Encoded => { + const currentPrices = prices.filter((price) => price.product_id === _.id) + + return { + id: _.id, + active: _.status === 'active', + name: _.name, + description: _.description, + metadata: _.custom_data, + prices: currentPrices.map(formatPrices), + } satisfies typeof Product.Encoded +} + +const formatSubscription = (_: PaddleSubscription): typeof Subscription.Encoded => { + const item = _.items[0] + return { + id: _.id, + status: formatSubscriptionStatus(_.status), + product: { + id: item.product.id, + name: item.product.name, + description: item.product.description, + }, + price: { + id: item.price.id, + name: item.price.name || '', + unitPrice: { + amount: item.price.unit_price.amount, + currencyCode: item.price.unit_price.currency_code, + }, + }, + addressId: _.address_id, + currencyCode: _.currency_code, + createdAt: _.created_at.toISOString(), + updatedAt: _.updated_at.toISOString(), + startedAt: _.started_at.toISOString(), + firstBilledAt: _.first_billed_at?.toISOString() || null, + nextBilledAt: _.next_billed_at?.toISOString() || null, + pausedAt: _.paused_at?.toISOString() || null, + canceledAt: _.canceled_at?.toISOString() || null, + currentBillingPeriod: _.current_billing_period + ? { + startsAt: _.current_billing_period.starts_at.toISOString(), + endsAt: _.current_billing_period.ends_at.toISOString(), + } + : null, + billingCycle: _.billing_cycle, + scheduledChange: _.scheduled_change + ? { + action: _.scheduled_change.action, + effectiveAt: _.scheduled_change.effective_at.toISOString(), + resumeAt: _.scheduled_change.resume_at?.toISOString(), + } + : null, + managementUrls: { + updatePaymentMethod: _.management_urls.update_payment_method, + cancel: _.management_urls.cancel, + }, + metadata: _.custom_data ?? {}, + items: _.items.map((_) => ({ + quantity: _.quantity, + recurring: _.recurring, + price: { + id: _.price.id, + unitPrice: { + amount: _.price.unit_price.amount, + currencyCode: _.price.unit_price.currency_code, + }, + name: _.price.name || 'unknown name', + description: _.price.description || 'unknown description', + }, + product: { + id: _.product.id, + name: _.product.name || 'unknown name', + description: _.product.description || 'unknown description', + }, + })), + nextTransaction: _.next_transaction + ? { + billingPeriod: { + endsAt: _.next_transaction.billing_period.ends_at?.toISOString(), + startsAt: _.next_transaction.billing_period.starts_at?.toISOString(), + }, + taxRatesUsed: _.next_transaction.details.tax_rates_used.map((_) => { + return { + taxRate: _.tax_rate, + totals: _.totals, + } + }), + totals: _.next_transaction.details.totals, + items: _.next_transaction.details.line_items.map((_) => ({ + priceId: _.price_id, + quantity: _.quantity, + taxRate: _.tax_rate, + totals: _.totals, + unitTotals: _.unit_totals, + product: { + id: _.product.id, + name: _.product.name || '', + description: _.product.description || '', + }, + })), + } + : null, + } satisfies typeof Subscription.Encoded +} + +const formatTransaction = (_: PaddleTransaction): typeof Transaction.Encoded => { + return { + id: _.id, + reason: getPaymentReason(_.origin), + status: formatTransactionStatus(_.status), + invoiceId: _.invoice_id, + currencyCode: _.currency_code, + createdAt: _.created_at.toISOString(), + billedAt: _.billed_at?.toISOString(), + updatedAt: _.updated_at?.toISOString(), + discount: _.discount_id, + billingPeriod: _.billing_period + ? { + startsAt: _.billing_period.starts_at.toISOString(), + endsAt: _.billing_period.ends_at.toISOString(), + } + : null, + items: _.items.map((item) => { + return { + name: item.price.name || 'unknown', + productId: item.price.product_id, + priceId: item.price.id, + unitPrice: { + amount: item.price.unit_price.amount, + currencyCode: item.price.unit_price.currency_code, + }, + quantity: item.quantity, + } + }), + payments: _.payments.map((_) => { + return { + id: _.id || '', + amount: _.amount, + status: _.status, + error: _.error || undefined, + details: _.details + ? { + type: _.details.type, + card: _.details.card, + } + : undefined, + createdAt: _.created_at.toISOString(), + capturedAt: _.captured_at?.toISOString(), + } + }), + } satisfies typeof Transaction.Encoded +} + +export class Paddle extends Context.Tag('@purchase:payment-paddle')() { + static readonly _tag: PaymentProviderTag = 'paddle' + + static make = Effect.gen(function* () { + const paddle = yield* PaddleSdk + const config = paddle.config + + const webhooksUnmarshal = ({ signature, payload }: Parameters[0]) => + paddle.webhooksUnmarshal(payload, Redacted.value(config.webhookToken), signature) + + const subscriptionStream = (args: { + namespace?: string | undefined + providerId?: CustomerProviderId | undefined + status?: string[] | undefined + after?: string | undefined + perPage?: number | undefined + orderBy?: string | undefined + }) => + Stream.unwrap( + Effect.gen(function* () { + const get = (after: string | undefined) => + paddle.subscriptions + .list({ + customerId: args.providerId, + after, + status: args.status, + perPage: args.perPage ?? 10, + orderBy: args.orderBy, + }) + .pipe( + Effect.flatMap((transactions) => + Subscription.decodeMany( + transactions + .filter((_) => { + if (!args.namespace) return _ + + return _.custom_data?.namespace === args.namespace + }) + .map(formatSubscription), + ), + ), + Effect.orDie, + ) + + return Stream.paginateChunkEffect(args.after, (after) => + Effect.map(get(after), (results) => [ + Chunk.fromIterable(results), + results.length === 0 ? Option.none() : Option.some(results[results.length - 1].id), + ]), + ) + }), + ) + + const transactionStream = (args: { + namespace?: string | undefined + providerId?: CustomerProviderId | undefined + status?: string[] | undefined + after?: string | undefined + perPage?: number | undefined + orderBy?: string | undefined + }) => + Stream.unwrap( + Effect.gen(function* () { + const get = (after: string | undefined) => + paddle.transactions + .list({ + customerId: args.providerId, + after, + status: args.status ?? ['completed', 'canceled', 'past_due'], + perPage: args.perPage, + orderBy: args.orderBy, + }) + .pipe( + Effect.flatMap((transactions) => + Transaction.decodeMany( + transactions + .filter((_) => { + if (!args.namespace) return _ + + return _.custom_data?.namespace === args.namespace + }) + .map(formatTransaction), + ), + ), + Effect.orDie, + ) + + return Stream.paginateChunkEffect(args.after, (after) => + Effect.map(get(after), (results) => [ + Chunk.fromIterable(results), + results.length === 0 ? Option.none() : Option.some(results[results.length - 1].id), + ]), + ) + }), + ) + + const productsStream = ({ + namespace, + status, + after, + perPage, + orderBy, + }: { + namespace?: string | undefined + status?: string[] | undefined + after?: string | undefined + perPage?: number | undefined + orderBy?: string | undefined + } = {}) => + Stream.unwrap( + Effect.gen(function* () { + const findStatus = status ?? ['active', 'archived'] + const prices = yield* paddle.prices + .list({ + type: ['standard'], + status: findStatus, + perPage: perPage ?? 50, + }) + .pipe( + Effect.map((list) => { + if (namespace) return list.filter((_) => _.custom_data?.namespace === namespace) + return list + }), + Effect.orDie, + ) + + const get = (after: string | undefined) => + paddle.products.list({ status: findStatus, after, perPage, orderBy }).pipe( + Effect.map((products) => { + if (namespace) return products.filter((_) => _.custom_data?.namespace === namespace) + return products + }), + Effect.flatMap((products) => + Product.decodeMany(products.map((product) => formatProduct(product, prices))), + ), + Effect.orDie, + ) + + const products = Stream.paginateChunkEffect(after, (after) => + pipe( + Effect.map(get(after), (results) => [ + Chunk.fromIterable(results), + results.length === 0 ? Option.none() : Option.some(results[results.length - 1].id), + ]), + ), + ) + + return products + }), + ) + const methods = { + _tag: Paddle._tag, + + paddleHi: Effect.succeed('hi'), + + webhooksUnmarshal, + + products: { + list: Effect.fn(function* ({ + namespace, + after, + perPage, + }: { + namespace: string + after?: string | undefined + perPage?: number | undefined + }) { + return yield* productsStream({ namespace, after, status: ['active'], perPage }).pipe( + Stream.take(perPage ?? 10), + Stream.runCollect, + Effect.map(Chunk.toReadonlyArray), + ) + }), + + get: Effect.fn(function* ({ productId }: { productId: ProductId }) { + const [paddleProduct, productPrices] = yield* Effect.all( + [paddle.products.get({ productId }), paddle.prices.list({ productId: [productId] })], + { concurrency: 'unbounded' }, + ).pipe(Effect.orDie) + + return yield* Option.match(paddleProduct, { + onNone: () => Effect.succeed(Option.none()), + onSome: (product) => + pipe(Product.decode(formatProduct(product, productPrices)), Effect.map(Option.some), Effect.orDie), + }) + }), + + stream: productsStream, + }, + + customers: { + get: Effect.fn(function* ({ + customerId, + providerId, + }: { + customerId: CustomerId + providerId: CustomerProviderId + }) { + const paddleCustomer = yield* paddle.customers + .get({ customerId: providerId }) + .pipe(Effect.map(Option.map(formatCustomer)), Effect.orDie) + + return yield* Option.match(paddleCustomer, { + onNone: () => Effect.succeed(Option.none()), + onSome: (customer) => Customer.decode(customer).pipe(Effect.map(Option.some), Effect.orDie), + }) + }), + + create: Effect.fn(function* (input: { + userId: string + email: string + name?: string | undefined + locale?: string | undefined + }) { + const paddleCustomer = yield* paddle.customers.create(input).pipe(Effect.orDie) + + const customerEncoded = formatCustomer(paddleCustomer) + + return yield* Customer.decode(customerEncoded).pipe(Effect.orDie) + }), + + update: Effect.fn(function* (input: { + providerId: CustomerProviderId + email?: string | undefined + name?: string | undefined + locale?: string | undefined + }) { + const paddleCustomer = yield* paddle.customers + .update({ + customerId: input.providerId, + email: input.email, + name: input.name, + locale: input.locale, + }) + .pipe(Effect.orDie) + + const customerEncoded = formatCustomer(paddleCustomer) + + return yield* Customer.decode(customerEncoded).pipe(Effect.orDie) + }), + }, + + subscriptions: { + list: Effect.fn(function* ({ + namespace, + customerId, + providerId, + after, + perPage, + limit, + orderBy, + }: { + namespace: string + customerId: CustomerId + providerId: CustomerProviderId + after?: string | undefined + perPage?: number | undefined + limit?: number | undefined + orderBy?: string | undefined + }) { + return yield* subscriptionStream({ namespace, providerId, after, orderBy, perPage }).pipe( + Stream.take(limit ?? 10), + Stream.runCollect, + Effect.map(Chunk.toReadonlyArray), + ) + }), + + get: Effect.fn(function* ({ + customerId, + subscriptionId, + }: { + customerId: CustomerId + subscriptionId: SubscriptionId + }) { + const paddleSubscription = yield* paddle.subscriptions + .get({ subscriptionId }) + .pipe(Effect.map(Option.map(formatSubscription)), Effect.orDie) + + const subscription = yield* Option.match(paddleSubscription, { + onNone: () => Effect.succeed(Option.none()), + onSome: (subscription) => + Subscription.decode(subscription).pipe(Effect.map(Option.fromNullable), Effect.orDie), + }) + + return subscription + }), + + latest: Effect.fn(function* ({ + namespace, + customerId, + providerId, + }: { + namespace: string + customerId: CustomerId + providerId: CustomerProviderId + }) { + return yield* subscriptionStream({ namespace, providerId }).pipe(Stream.take(1), Stream.runHead) + }), + + cancel: Effect.fn(function* ({ + subscriptionId, + effectiveFrom, + }: { + subscriptionId: SubscriptionId + effectiveFrom?: 'immediately' | 'next_billing_period' | undefined + }) { + return yield* paddle.subscriptions + .cancel({ subscriptionId, immediate: effectiveFrom === 'immediately' }) + .pipe(Effect.orDie) + }), + + stream: subscriptionStream, + }, + + transactions: { + list: Effect.fn(function* ({ + namespace, + customerId, + providerId, + after, + perPage, + limit, + }: { + namespace: string + customerId: CustomerId + providerId: CustomerProviderId + after?: string | undefined + perPage?: number | undefined + limit?: number | undefined + }) { + return yield* transactionStream({ + namespace, + providerId, + after, + perPage, + }).pipe(Stream.take(limit ?? 10), Stream.runCollect, Effect.map(Chunk.toReadonlyArray)) + }), + + latest: Effect.fn(function* ({ + namespace, + customerId, + providerId, + }: { + namespace: string + customerId: CustomerId + providerId: CustomerProviderId + }) { + return yield* transactionStream({ + namespace, + status: ['completed'], + providerId: providerId, + perPage: 10, + // orderBy: "created_at[desc]", + }).pipe(Stream.take(1), Stream.runHead) + }), + + get: Effect.fn(function* ({ + customerId, + transactionId, + }: { + customerId: CustomerId + transactionId: TransactionId + }) { + const paddleTransaction = yield* paddle.transactions + .get({ transactionId }) + .pipe(Effect.map(Option.map(formatTransaction)), Effect.orDie) + + return yield* Option.match(paddleTransaction, { + onNone: () => Effect.succeed(Option.none()), + onSome: (transaction) => Transaction.decode(transaction).pipe(Effect.map(Option.some), Effect.orDie), + }) + }), + + stream: transactionStream, + + generateInvoicePDF: ({ transactionId }: { transactionId: TransactionId }) => + paddle.transactions + .generateInvoicePDF({ transactionId }) + .pipe(Effect.mapError(() => new InvoiceNotFoundError())), + }, + } satisfies Partial + + return { + ...methods, + + isPaddle: true, + + isStripe: false, + + is: (tag, effect) => { + if (tag === Paddle._tag) { + return effect(methods as any) as any + } + + return Effect.void as any + }, + } satisfies PaddleImpl as unknown as PaddleImpl + }) + + static FromConfig = (config: PaddleConfig) => + Layer.succeed( + PaymentImpl, + PaymentImpl.of({ + _tag: Paddle._tag, + make: Paddle.make.pipe(Effect.provide(pipe(PaddleSdk.Default, Layer.provide(PaddleConfigFromRecord(config))))), + }), + ) + + static Live = Layer.succeed( + PaymentImpl, + PaymentImpl.of({ + _tag: Paddle._tag, + make: Paddle.make.pipe(Effect.provide(pipe(PaddleSdk.Default, Layer.provide(PaddleConfigFromEnv), Layer.orDie))), + }), + ) +} diff --git a/packages/purchase/src/payment/payment-client.ts b/packages/purchase/src/payment/payment-client.ts new file mode 100644 index 0000000..6d0c2e1 --- /dev/null +++ b/packages/purchase/src/payment/payment-client.ts @@ -0,0 +1,160 @@ +import type { PaymentProviderTag } from '@xstack/purchase/payment/type' +import type { + Customer, + CustomerAlreadyExistsError, + CustomerId, + CustomerNotFoundError, + CustomerProviderId, + InvoiceNotFoundError, + Product, + ProductId, + Subscription, + SubscriptionId, + Transaction, + TransactionId, + WebhookUnmarshalError, +} from '@xstack/purchase/schema' +import type * as Effect from 'effect/Effect' +import type * as Option from 'effect/Option' +import type * as Stream from 'effect/Stream' + +export interface PaymentClient { + readonly _tag: PaymentProviderTag + + readonly isPaddle: boolean + + readonly isStripe: boolean + + readonly is: ( + tag: T, + effect: ( + _: T extends 'paddle' + ? Omit + : Omit, + ) => Effect.Effect, + ) => Effect.Effect + + readonly webhooksUnmarshal: ({ + payload, + signature, + }: { + payload: string + signature: string + }) => Effect.Effect + + readonly products: { + list: (options: { + namespace: string + after?: string | undefined + perPage?: number | undefined + }) => Effect.Effect + + get: ({ productId }: { productId: ProductId }) => Effect.Effect, never, never> + + stream: (options?: { + namespace?: string | undefined + status?: string[] | undefined + after?: string | undefined + perPage?: number | undefined + orderBy?: string | undefined + }) => Stream.Stream + } + + readonly customers: { + get: (options: { + customerId: CustomerId + providerId: CustomerProviderId + }) => Effect.Effect, never, never> + + create: (options: { + userId: string + email: string + name?: string | undefined + locale?: string | undefined + }) => Effect.Effect + + update: (options: { + providerId: CustomerProviderId + email?: string | undefined + name?: string | undefined + locale?: string | undefined + }) => Effect.Effect + } + + readonly subscriptions: { + list: (options: { + namespace: string + customerId: CustomerId + providerId: CustomerProviderId + after?: string | undefined + perPage?: number | undefined + orderBy?: string | undefined + }) => Effect.Effect + + get: (options: { + customerId: CustomerId + subscriptionId: SubscriptionId + }) => Effect.Effect, never, never> + + latest: (options: { + namespace: string + customerId: CustomerId + providerId: CustomerProviderId + }) => Effect.Effect, never, never> + + cancel: (options: { + subscriptionId: SubscriptionId + effectiveFrom?: 'immediately' | 'next_billing_period' | undefined + }) => Effect.Effect + + stream: (options: { + namespace?: string | undefined + customerId?: CustomerId | undefined + providerId?: CustomerProviderId | undefined + after?: string | undefined + perPage?: number | undefined + }) => Stream.Stream + } + + readonly transactions: { + list: (options: { + namespace: string + customerId: CustomerId + providerId: CustomerProviderId + after?: string | undefined + perPage?: number | undefined + }) => Effect.Effect + + get: (options: { + customerId: CustomerId + providerId?: CustomerProviderId + transactionId: TransactionId + }) => Effect.Effect, never, never> + + latest: (options: { + namespace: string + customerId: CustomerId + providerId: CustomerProviderId + }) => Effect.Effect, never, never> + + stream: (options: { + namespace?: string | undefined + customerId?: CustomerId | undefined + providerId?: CustomerProviderId | undefined + after?: string | undefined + perPage?: number | undefined + }) => Stream.Stream + + generateInvoicePDF: (options: { + transactionId: TransactionId + }) => Effect.Effect + } +} + +export interface PaddleImpl extends PaymentClient { + readonly paddleHi: Effect.Effect +} + +export interface StripeImpl extends PaymentClient { + readonly stripeHi: Effect.Effect +} diff --git a/packages/purchase/src/payment/payment-impl.ts b/packages/purchase/src/payment/payment-impl.ts new file mode 100644 index 0000000..a41b199 --- /dev/null +++ b/packages/purchase/src/payment/payment-impl.ts @@ -0,0 +1,21 @@ +import type { PaymentClient } from '@xstack/purchase/payment/payment-client' +import type { PaymentProviderTag } from '@xstack/purchase/payment/type' +import * as Context from 'effect/Context' +import type * as Effect from 'effect/Effect' +import * as Layer from 'effect/Layer' + +export interface PaymentImpl { + readonly _tag: PaymentProviderTag + readonly make: Effect.Effect +} +export const PaymentImpl = Context.GenericTag('@purchase:payment-impl') + +export class PaymentTags extends Context.Tag('@purchase:payment-tags')< + PaymentTags, + { + providers: Record> + } +>() { + static FromRecords = (providers: Record>) => + Layer.succeed(this, { providers }) +} diff --git a/packages/purchase/src/payment/stripe.ts b/packages/purchase/src/payment/stripe.ts new file mode 100644 index 0000000..b83e3b0 --- /dev/null +++ b/packages/purchase/src/payment/stripe.ts @@ -0,0 +1,53 @@ +import type { StripeConfig } from '@xstack/purchase/payment/internal/stripe-sdk' +import { StripeConfigFromEnv, StripeConfigFromRecord } from '@xstack/purchase/payment/internal/stripe-sdk' +import type { StripeImpl } from '@xstack/purchase/payment/payment-client' +import { PaymentImpl } from '@xstack/purchase/payment/payment-impl' +import type { PaymentProviderTag } from '@xstack/purchase/payment/type' +import * as Context from 'effect/Context' +import * as Effect from 'effect/Effect' +import * as Layer from 'effect/Layer' + +export class Stripe extends Context.Tag('@purchase:payment-stripe')() { + static readonly _tag: PaymentProviderTag = 'stripe' + + static make = Effect.gen(function* () { + const methods = { + _tag: Stripe._tag, + + stripeHi: Effect.succeed('hi'), + } satisfies Partial + + return { + ...methods, + + isPaddle: false, + + isStripe: true, + + is: (tag, effect) => { + if (tag === Stripe._tag) { + return effect(methods as any) as any + } + + return Effect.void as any + }, + } as StripeImpl + }) + + static FromConfig = (config: StripeConfig) => + Layer.succeed( + PaymentImpl, + PaymentImpl.of({ + _tag: Stripe._tag, + make: Stripe.make.pipe(Effect.provide(StripeConfigFromRecord(config))), + }), + ) + + static Live = Layer.succeed( + PaymentImpl, + PaymentImpl.of({ + _tag: Stripe._tag, + make: Stripe.make.pipe(Effect.provide(StripeConfigFromEnv.pipe(Layer.orDie))), + }), + ) +} diff --git a/packages/purchase/src/payment/type.ts b/packages/purchase/src/payment/type.ts new file mode 100644 index 0000000..89f002b --- /dev/null +++ b/packages/purchase/src/payment/type.ts @@ -0,0 +1,7 @@ +import * as Schema from 'effect/Schema' + +export const PaymentProviderTag = Schema.Literal('paddle', 'stripe') +export type PaymentProviderTag = typeof PaymentProviderTag.Type + +export const PaymentEnvironmentTag = Schema.Literal('sandbox', 'production') +export type PaymentEnvironmentTag = typeof PaymentEnvironmentTag.Type diff --git a/packages/purchase/src/schema.ts b/packages/purchase/src/schema.ts new file mode 100644 index 0000000..e585df0 --- /dev/null +++ b/packages/purchase/src/schema.ts @@ -0,0 +1,513 @@ +import * as Schema from 'effect/Schema' + +export const ProductId = Schema.NonEmptyString.pipe( + Schema.brand('productId'), + Schema.annotations({ + description: 'Product unique identifier', + }), +) +export type ProductId = typeof ProductId.Type + +export const PriceId = Schema.NonEmptyString.pipe( + Schema.brand('PriceId'), + Schema.annotations({ + description: 'Price unique identifier', + }), +) +export type PriceId = typeof PriceId.Type + +export const PriceName = Schema.String.pipe( + Schema.brand('PriceName'), + Schema.annotations({ + description: 'Price name', + }), +) +export type PriceName = typeof PriceName.Type + +export const ProductName = Schema.String.pipe( + Schema.brand('ProductName'), + Schema.annotations({ + description: 'Product name', + }), +) +export type ProductName = typeof ProductName.Type + +export const CustomerId = Schema.String.pipe( + Schema.brand('CustomerId'), + Schema.annotations({ + description: 'Customer unique identifier', + }), +) +export type CustomerId = typeof CustomerId.Type + +export const CustomerEmail = Schema.String.pipe( + Schema.compose(Schema.Lowercase), + Schema.brand('CustomerEmail'), + Schema.annotations({ + description: 'Customer email address, unique', + }), +) +export type CustomerEmail = typeof CustomerEmail.Type + +export const CustomerProviderId = Schema.NonEmptyString.pipe( + Schema.brand('CustomerProviderId'), + Schema.annotations({ + description: 'Customer provider unique identifier', + }), +) +export type CustomerProviderId = typeof CustomerProviderId.Type + +export const SubscriptionId = Schema.String.pipe( + Schema.brand('SubscriptionId'), + Schema.annotations({ + description: 'Subscription unique identifier', + }), +) +export type SubscriptionId = typeof SubscriptionId.Type + +export const TransactionId = Schema.NonEmptyString.pipe( + Schema.brand('TransactionId'), + Schema.annotations({ + description: 'Transaction unique identifier', + }), +) +export type TransactionId = typeof TransactionId.Type + +export const BillingInterval = Schema.Literal('day', 'week', 'month', 'year') + +export const CurrencyCode = Schema.String + +export const CountryCode = Schema.String + +export const UnitPrice = Schema.Struct({ + amount: Schema.String, + currencyCode: Schema.String, +}) + +export const BillingCycle = Schema.Struct({ + interval: BillingInterval, + frequency: Schema.Number, +}) + +export const TrialPeriod = Schema.Struct({ + interval: BillingInterval, + frequency: Schema.Number, +}) + +export const PriceQuantity = Schema.Struct({ + minimum: Schema.Number, + maximum: Schema.Number, +}) + +export const Metadata = Schema.Record({ key: Schema.String, value: Schema.Any }).pipe( + Schema.optionalWith({ exact: true, nullable: true }), +) + +/** + * 订阅状态 + * - active: 活跃的付费订阅 + * - paused: 暂停状态 + * - canceled: 已取消 + * - past_due: 付款逾期 + * - trialing: 试用期内 + */ +export const SubscriptionStatus = Schema.Literal('active', 'paused', 'past_due', 'trialing', 'canceled') + +export const BillingPeriod = Schema.Struct({ + startsAt: Schema.Date, + endsAt: Schema.Date, +}) + +export const NextSubscriptionTransaction = Schema.Struct({ + billingPeriod: BillingPeriod, + taxRatesUsed: Schema.Array( + Schema.Struct({ + taxRate: Schema.String, + totals: Schema.optionalWith( + Schema.Struct({ + subtotal: Schema.String, + discount: Schema.String, + tax: Schema.String, + total: Schema.String, + }), + { exact: true, nullable: true }, + ), + }), + ), + totals: Schema.Struct({ + subtotal: Schema.String, + discount: Schema.String, + tax: Schema.String, + total: Schema.String, + credit: Schema.String, + creditToBalance: Schema.String, + balance: Schema.String, + grandTotal: Schema.String, + fee: Schema.optionalWith(Schema.String, { exact: true, nullable: true }), + earnings: Schema.optionalWith(Schema.String, { exact: true, nullable: true }), + currencyCode: CurrencyCode, + }), + items: Schema.Array( + Schema.Struct({ + priceId: PriceId, + quantity: Schema.Number, + taxRate: Schema.String, + unitTotals: Schema.Struct({ + subtotal: Schema.String, + discount: Schema.String, + tax: Schema.String, + total: Schema.String, + }), + totals: Schema.Struct({ + subtotal: Schema.String, + discount: Schema.String, + tax: Schema.String, + total: Schema.String, + }), + product: Schema.Struct({ + id: ProductId, + name: Schema.String, + description: Schema.String, + }), + }), + ), +}) + +export const SubscriptionItem = Schema.Struct({ + /** + * 数量 + */ + quantity: Schema.Number, + /** + * 是否是周期性订阅 + */ + recurring: Schema.Boolean, + /** + * 下次计费时间 + */ + nextBilledAt: Schema.optionalWith(Schema.Date, { nullable: true }), + /** + * 价格 + */ + price: Schema.Struct({ + id: PriceId, + name: PriceName, + description: Schema.String, + unitPrice: UnitPrice, + }), + /** + * 产品 + */ + product: Schema.Struct({ + id: ProductId, + name: ProductName, + description: Schema.String, + }), + /** + * 试用时间 + */ + trialDates: Schema.optional(BillingPeriod), +}) + +export const SubscriptionScheduledChange = Schema.Struct({ + action: Schema.Literal('cancel', 'pause', 'resume'), + effectiveAt: Schema.Date, + resumeAt: Schema.optionalWith(Schema.Date, { nullable: true }), +}) + +export const TransactionStatus = Schema.Literal('draft', 'ready', 'billed', 'paid', 'completed', 'canceled', 'past_due') + +export const TransactionOrigin = Schema.Literal( + 'api', + 'subscription_charge', + 'subscription_payment_method_change', + 'subscription_recurring', + 'subscription_update', + 'web', +) + +export const PaymentAttemptStatus = Schema.Literal( + 'canceled', + 'authorized', + 'authorized_flagged', + 'captured', + 'error', + 'action_required', + 'pending_no_action_required', + 'created', + 'unknown', + 'dropped', +) + +export const PaymentMethodType = Schema.String + +export const PaymentCardType = Schema.String + +export const ErrorCode = Schema.String + +export const PaymentCard = Schema.Struct({ + type: PaymentCardType, + last4: Schema.String, + expiryMonth: Schema.Number, + expiryYear: Schema.Number, + cardholderName: Schema.String, +}) + +export const PaymentAttempt = Schema.Struct({ + id: Schema.String, + amount: Schema.String, + status: PaymentAttemptStatus, + error: Schema.optional(ErrorCode), + details: Schema.optionalWith( + Schema.Struct({ + type: PaymentMethodType, + card: Schema.optionalWith(PaymentCard, { nullable: true }), + }), + { nullable: true }, + ), + createdAt: Schema.Date, + capturedAt: Schema.optionalWith(Schema.Date, { nullable: true }), +}) + +// ----- Common Model ----- + +export class Customer extends Schema.Class('Customer')({ + id: CustomerProviderId, + email: CustomerEmail, + name: Schema.optionalWith(Schema.String, { exact: true, nullable: true, default: () => '' }), + metadata: Metadata, +}) { + static decode = Schema.decode(Customer) + + static decodeMany = Schema.decode(Schema.Array(Customer)) +} + +export class Price extends Schema.Class('Price')({ + id: PriceId, + name: Schema.optionalWith(PriceName, { exact: true, nullable: true, default: () => PriceName.make('') }), + productId: ProductId, + unitPrice: UnitPrice, + unitPriceOverride: Schema.Array( + Schema.Struct({ + countryCodes: Schema.Array(CountryCode), + unitPrice: UnitPrice, + }), + ), + billingCycle: Schema.optionalWith(BillingCycle, { exact: true, nullable: true }), + trialPeriod: Schema.optionalWith(TrialPeriod, { exact: true, nullable: true }), + active: Schema.Boolean, + createdAt: Schema.Date, + updatedAt: Schema.Date, + quantity: PriceQuantity, + metadata: Metadata, +}) {} + +export class Product extends Schema.Class('Product')({ + id: ProductId, + name: ProductName, + description: Schema.optionalWith(Schema.String, { exact: true, nullable: true, default: () => '' }), + active: Schema.Boolean, + metadata: Metadata, + prices: Schema.Array(Price), +}) { + static decode = Schema.decode(Product) + + static decodeMany = Schema.decode(Schema.Array(Product)) +} + +export class Subscription extends Schema.Class('Subscription')({ + /** + * 订阅ID + */ + id: SubscriptionId, + /** + * 订阅状态 + */ + status: SubscriptionStatus, + /** + * 产品 + */ + product: Schema.Struct({ + id: ProductId, + name: ProductName, + description: Schema.String, + }), + /** + * 单价 + */ + price: Schema.Struct({ + id: PriceId, + name: Schema.String, + unitPrice: UnitPrice, + }), + /** + * 地址ID + */ + addressId: Schema.String, + /** + * 货币代码 + */ + currencyCode: CurrencyCode, + /** + * 创建时间 + */ + createdAt: Schema.Date, + /** + * 更新时间 + */ + updatedAt: Schema.Date, + /** + * 开始时间 + */ + startedAt: Schema.optionalWith(Schema.Date, { exact: true, nullable: true }), + /** + * 首次计费时间 + */ + firstBilledAt: Schema.optionalWith(Schema.Date, { exact: true, nullable: true }), + /** + * 下次计费时间 + */ + nextBilledAt: Schema.optionalWith(Schema.Date, { exact: true, nullable: true }), + /** + * 暂停时间 + */ + pausedAt: Schema.optionalWith(Schema.Date, { exact: true, nullable: true }), + /** + * 取消时间 + */ + canceledAt: Schema.optionalWith(Schema.Date, { exact: true, nullable: true }), + /** + * 当前计费周期 + */ + currentBillingPeriod: Schema.optionalWith(BillingPeriod, { exact: true, nullable: true }), + /** + * 计费周期 + */ + billingCycle: Schema.optionalWith(BillingCycle, { exact: true, nullable: true }), + /** + * 计划变更 + */ + scheduledChange: Schema.optionalWith(SubscriptionScheduledChange, { exact: true, nullable: true }), + /** + * 管理 URL + */ + managementUrls: Schema.Struct({ + updatePaymentMethod: Schema.optionalWith(Schema.String, { exact: true, nullable: true }), + cancel: Schema.optional(Schema.String), + }), + /** + * 订阅项 + */ + items: Schema.Array(SubscriptionItem), + /** + * 下一个交易 + */ + nextTransaction: Schema.optionalWith(NextSubscriptionTransaction, { exact: true, nullable: true }), + /** + * 元数据 + */ + metadata: Metadata, +}) { + static decode = Schema.decode(Subscription) + + static decodeMany = Schema.decode(Schema.Array(Subscription)) +} + +const TransactionItem = Schema.Struct({ + name: Schema.String, + productId: ProductId, + priceId: PriceId, + unitPrice: UnitPrice, + quantity: Schema.Number, +}) + +export class Transaction extends Schema.Class('Transaction')({ + id: Schema.String, + reason: Schema.String, + status: TransactionStatus, + invoiceId: Schema.optionalWith(Schema.String, { nullable: true }), + currencyCode: CurrencyCode, + discount: Schema.optionalWith(Schema.String, { nullable: true }), + billingPeriod: Schema.optionalWith(BillingPeriod, { nullable: true }), + items: Schema.Array(TransactionItem), + payments: Schema.Array(PaymentAttempt), + createdAt: Schema.Date, + updatedAt: Schema.Date, + billedAt: Schema.optionalWith(Schema.Date, { nullable: true }), +}) { + static decode = Schema.decode(Transaction) + + static decodeMany = Schema.decode(Schema.Array(Transaction)) +} + +export class Invoice extends Schema.Class('Invoice')({}) {} + +// ----------------- + +export class SubscriptionInfo extends Schema.Class('SubscriptionInfo')({ + id: Schema.String, + /** + * Subscription name + */ + name: Schema.String, + /** + * Subscription description + */ + description: Schema.String, + /** + * Subscription status + */ + status: SubscriptionStatus, + /** + * 当前计费周期 + */ + billingPeriod: Schema.optional(BillingPeriod), + /** + * 是否在当前周期结束时取消订阅 + */ + cancelAtPeriodEnd: Schema.Boolean, + /** + * 试用周期 + */ + trialPeriod: Schema.optional(BillingPeriod), + /** + * Subscription updated at + */ + updatedAt: Schema.Date, +}) { + /** + * 是否有效 + */ + get isActive() { + return this.status === 'active' || this.status === 'trialing' + } +} + +export const ProductsJSON = Schema.parseJson(Schema.Array(Product)) +export const decodeProductsJSON = Schema.decodeUnknown(ProductsJSON) +export const encodeProductsJSON = Schema.encodeUnknown(ProductsJSON) + +export const TransactionsJSON = Schema.parseJson(Schema.Array(Transaction)) +export const decodeTransactionsJSON = Schema.decodeUnknown(TransactionsJSON) +export const encodeTransactionsJSON = Schema.encodeUnknown(TransactionsJSON) + +// ----- Errors ----- + +export class WebhookUnmarshalError extends Schema.TaggedError()('WebhookUnmarshalError', { + error: Schema.String, + cause: Schema.optional(Schema.Unknown), +}) {} + +export class CustomerNotFoundError extends Schema.TaggedError()('CustomerNotFoundError', { + customerId: Schema.String, +}) {} + +export class CustomerAlreadyExistsError extends Schema.TaggedError()( + 'CustomerAlreadyExistsError', + { + email: Schema.String, + userId: Schema.String, + }, +) {} + +export class InvoiceNotFoundError extends Schema.TaggedError()('InvoiceNotFoundError', {}) {} diff --git a/packages/purchase/tests/payment/internal/paddle-sdk.test.ts b/packages/purchase/tests/payment/internal/paddle-sdk.test.ts new file mode 100644 index 0000000..5b3a655 --- /dev/null +++ b/packages/purchase/tests/payment/internal/paddle-sdk.test.ts @@ -0,0 +1,149 @@ +import { describe, it } from '@effect/vitest' +import * as Effect from 'effect/Effect' +import * as Layer from 'effect/Layer' +import * as Redacted from 'effect/Redacted' +import * as Paddle from '@xstack/purchase/payment/internal/paddle-sdk' + +const TestPaddle = Paddle.PaddleSdk.Default.pipe( + Layer.provide( + Paddle.PaddleConfigFromRecord({ + apiToken: Redacted.make('c52cdaa9cf9fb54ee768991b85c26eba0259d90e10f4341a4c'), + webhookToken: Redacted.make('pdl_ntfset_01jcabpgj9ft3pxhrhc04svt0b_HaFBjn6n3'), + environment: 'sandbox', + }), + ), +) + +it.layer(TestPaddle)('Paddle SDK', ({ effect }) => { + describe.skip('products', () => { + effect('list products', () => + Effect.gen(function* () { + const sdk = yield* Paddle.PaddleSdk + + const res = yield* sdk.products.list() + console.log(res) + }), + ) + + effect('get product', () => + Effect.gen(function* () { + const sdk = yield* Paddle.PaddleSdk + + const res = yield* sdk.products.get({ productId: 'pro_01je39ksv737pf9gqc17x26shg' }) + console.log(res) + }), + ) + }) + + describe.skip('prices', () => { + effect('list prices', () => + Effect.gen(function* () { + const sdk = yield* Paddle.PaddleSdk + + const res = yield* sdk.prices.list() + console.log(res) + }), + ) + + effect('get price', () => + Effect.gen(function* () { + const sdk = yield* Paddle.PaddleSdk + + const res = yield* sdk.prices.get({ priceId: 'pri_01je3ars7jpk9g35jk1h034rkx' }) + console.log(res) + }), + ) + }) + + describe.skip('customers', () => { + effect('list customers', () => + Effect.gen(function* () { + const sdk = yield* Paddle.PaddleSdk + + const res = yield* sdk.customers.list() + console.log(res) + }), + ) + + effect.skip('get customer', () => + Effect.gen(function* () { + const sdk = yield* Paddle.PaddleSdk + + const res = yield* sdk.customers.get({ customerId: 'ctm_01jc66tchc3fjz46j3nt081fas' }) + console.log(res) + }), + ) + + effect.skip('create customer', () => + Effect.gen(function* () { + const sdk = yield* Paddle.PaddleSdk + + const userId = 'test__' + const email = 'test@test.com' + + const res = yield* sdk.customers.create({ email, userId }) + console.log(res) + }), + ) + + effect('update customer', () => + Effect.gen(function* () { + const sdk = yield* Paddle.PaddleSdk + + const email = 'test2@test.com' + + const res = yield* sdk.customers.update({ customerId: 'ctm_01jp7k30m8xfvsv10y4mq9gxnm', email }) + console.log(res) + }), + ) + }) + + describe.skip('transactions', () => { + effect('list transactions', () => + Effect.gen(function* () { + const sdk = yield* Paddle.PaddleSdk + + const res = yield* sdk.transactions.list({ customerId: 'ctm_01jp7k30m8xfvsv10y4mq9gxnm' }) + console.log(res) + }), + ) + + effect('get transaction', () => + Effect.gen(function* () { + const sdk = yield* Paddle.PaddleSdk + + const res = yield* sdk.transactions.get({ transactionId: 'txn_01jgkdz3wztdj93nq84xaq73fy1' }) + console.log(res) + }), + ) + }) + + describe.skip('subscriptions', () => { + effect('list subscriptions', () => + Effect.gen(function* () { + const sdk = yield* Paddle.PaddleSdk + + const res = yield* sdk.subscriptions.list({ customerId: 'ctm_01jp7k30m8xfvsv10y4mq9gxnm' }) + console.log(res) + }), + ) + + effect('get subscription', () => + Effect.gen(function* () { + const sdk = yield* Paddle.PaddleSdk + + const res = yield* sdk.subscriptions.get({ subscriptionId: 'sub_01jc66wby1xh91gz2azjebwa4a' }) + console.log(res) + }), + ) + + effect('cancel subscription', () => + Effect.gen(function* () { + const sdk = yield* Paddle.PaddleSdk + + const res = yield* sdk.subscriptions.cancel({ subscriptionId: 'sub_01jc66wby1xh91gz2azjebwa4a' }) + console.log(res) + }), + ) + }) +}) diff --git a/packages/purchase/tsconfig.check.json b/packages/purchase/tsconfig.check.json new file mode 100644 index 0000000..a5c94ea --- /dev/null +++ b/packages/purchase/tsconfig.check.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["vite/client"] + }, + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["node_modules", "dist", "build"] +} diff --git a/packages/purchase/tsconfig.json b/packages/purchase/tsconfig.json new file mode 100644 index 0000000..943e0e4 --- /dev/null +++ b/packages/purchase/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.test.json" + } + ], + "include": [], + "files": [] +} diff --git a/packages/purchase/tsconfig.lib.json b/packages/purchase/tsconfig.lib.json new file mode 100644 index 0000000..c32fd87 --- /dev/null +++ b/packages/purchase/tsconfig.lib.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc" + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["node_modules", "dist", "build", "e2e/**/*", "tests/**/*"] +} diff --git a/packages/purchase/tsconfig.test.json b/packages/purchase/tsconfig.test.json new file mode 100644 index 0000000..7ada865 --- /dev/null +++ b/packages/purchase/tsconfig.test.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["vite/client"], + "outDir": "../../dist/out-tsc" + }, + "include": ["**/*.d.ts", "vite.config.ts", "e2e/**/*.ts", "e2e/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"], + "exclude": ["node_modules", "dist", "build"] +} diff --git a/patches/@nx__expo.patch b/patches/@nx__expo.patch new file mode 100644 index 0000000..83a0f3f --- /dev/null +++ b/patches/@nx__expo.patch @@ -0,0 +1,13 @@ +diff --git a/plugins/with-nx-metro.js b/plugins/with-nx-metro.js +index 6a284bd68efc7d03accbf6361b92fdc575eb90f0..4d8c9f803e080aad3853391503c3f0f38f60a555 100644 +--- a/plugins/with-nx-metro.js ++++ b/plugins/with-nx-metro.js +@@ -21,7 +21,7 @@ function getMetroConfig() { + return metroConfig; + } + const metro_resolver_1 = require("./metro-resolver"); +-async function withNxMetro(userConfig, opts = {}) { ++function withNxMetro(userConfig, opts = {}) { + const extensions = ['', 'ts', 'tsx', 'js', 'jsx', 'json']; + if (opts.debug) + process.env.NX_REACT_NATIVE_DEBUG = 'true'; diff --git a/patches/expo-modules-core.patch b/patches/expo-modules-core.patch new file mode 100644 index 0000000..fcb93e8 --- /dev/null +++ b/patches/expo-modules-core.patch @@ -0,0 +1,13 @@ +diff --git a/ios/JSI/EXJSIUtils.h b/ios/JSI/EXJSIUtils.h +index 407f57ff19eb3ac1030db9d1f27493da9e44ca0e..b9014903042e28d16918677e3580cb6f4990d45b 100644 +--- a/ios/JSI/EXJSIUtils.h ++++ b/ios/JSI/EXJSIUtils.h +@@ -7,6 +7,8 @@ + #import + #import + #import ++#import ++#import + #import + + namespace jsi = facebook::jsi; diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 05e2265..b628813 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -9,17 +9,24 @@ packages: ignorePatchFailures: true ignoredBuiltDependencies: + - '@prisma/engines' + - detox + - dtrace-provider + - msgpackr-extract - styled-components + - unrs-resolver onlyBuiltDependencies: - '@discordjs/opus' - '@parcel/watcher' + - '@swc/core' - better-sqlite3 - core-js-pure - esbuild - lefthook - nx - prisma + - react-native-nitro-modules - sharp - workerd @@ -34,7 +41,7 @@ overrides: array.prototype.flatmap: npm:@nolyfill/array.prototype.flatmap@^1 array.prototype.tosorted: npm:@nolyfill/array.prototype.tosorted@^1 clsx: 2.1.1 - cookie: 1.0.2 + cookie: 1.1.1 deep-equal: npm:@nolyfill/deep-equal@^1 effect: 3.19.6 es-iterator-helpers: npm:@nolyfill/es-iterator-helpers@^1 @@ -49,7 +56,6 @@ overrides: is-generator-function: npm:@nolyfill/is-generator-function@^1 is-typed-array: npm:@nolyfill/is-typed-array@^1 isarray: npm:@nolyfill/isarray@^1 - metro-runtime: 0.83.3 object.assign: npm:@nolyfill/object.assign@^1 object.entries: npm:@nolyfill/object.entries@^1 object.fromentries: npm:@nolyfill/object.fromentries@^1 @@ -59,6 +65,7 @@ overrides: react: 19.2.0 react-dom: 19.2.0 react-is: 19.2.0 + react-native: 0.83.0-rc.3 safe-buffer: npm:@nolyfill/safe-buffer@^1 safer-buffer: npm:@nolyfill/safer-buffer@^1 scheduler: 0.27.0 @@ -75,11 +82,12 @@ overrides: patchedDependencies: '@babel/plugin-transform-react-jsx': patches/@babel__plugin-transform-react-jsx.patch + '@nx/expo': patches/@nx__expo.patch '@paddle/paddle-js': patches/@paddle__paddle-js.patch + expo-modules-core: patches/expo-modules-core.patch react-dev-inspector: patches/react-dev-inspector.patch react-dom: patches/react-dom.patch react-image-previewer@1.1.6: patches/react-image-previewer@1.1.6.patch - react-native: patches/react-native.patch rolldown-vite: patches/rolldown-vite.patch unicorn-magic@0.3.0: patches/unicorn-magic@0.3.0.patch vite-plugin-checker: patches/vite-plugin-checker.patch @@ -101,7 +109,9 @@ peerDependencyRules: - '@glideapps/glide-data-grid' publicHoistPattern: - - '*expo*' + - '@babel/*' + - 'expo' + - 'expo-*' - '@expo/*' - react-native* - '@react-native*' diff --git a/scripts/generate-tsconfig/config.ts b/scripts/generate-tsconfig/config.ts index 404e523..d74144b 100644 --- a/scripts/generate-tsconfig/config.ts +++ b/scripts/generate-tsconfig/config.ts @@ -201,7 +201,7 @@ function buildGlobalAliasPaths({ function addSiblingAliases(items: ProjectItemDeclaration[], paths: TsPathAliasMap): TsPathAliasMap { const siblingPaths: TsPathAliasMap = { ...paths } - const rejectList = new Set(['web', 'mobile']) + const rejectList = new Set(['web', 'rn']) const frontendAppPaths: string[] = [] for (const item of items) { diff --git a/scripts/sort-env.ts b/scripts/sort-env.ts index 075c904..f5d7eb3 100644 --- a/scripts/sort-env.ts +++ b/scripts/sort-env.ts @@ -1,3 +1,5 @@ +#!/usr/bin/env tsx + import { execFile } from 'node:child_process' import { promises as fs } from 'node:fs' import * as path from 'node:path' diff --git a/scripts/sync-github-secrets.ts b/scripts/sync-github-secrets.ts index 5f309f8..a557f69 100644 --- a/scripts/sync-github-secrets.ts +++ b/scripts/sync-github-secrets.ts @@ -1,3 +1,5 @@ +#!/usr/bin/env tsx + import { execFile } from 'node:child_process' import { Buffer } from 'node:buffer' import { resolve } from 'node:path' diff --git a/tsconfig.base.json b/tsconfig.base.json index 8d2b614..7f50832 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -35,11 +35,6 @@ "skipDefaultLibCheck": true, "skipLibCheck": true, "paths": { - "@infra/ratelimiter/*": ["./infra/ratelimiter/*"], - "@infra/otel-exporter/*": ["./infra/otel-exporter/*"], - "@infra/emailer/*": ["./infra/emailer/*"], - "@infra/purchase-webhook/*": ["./infra/purchase-webhook/*"], - "@infra/purchase/*": ["./infra/purchase/*"], "@xstack/server-testing/*": ["./packages/server-testing/src/*"], "@xstack/testing/*": ["./packages/testing/src/*"], "@xstack/internal-kit/*": ["./packages/internal-kit/src/*"], @@ -75,6 +70,7 @@ "@xstack/toaster": ["./packages/toaster/src/"], "@xstack/router/*": ["./packages/router/src/*"], "@xstack/router": ["./packages/router/src/"], + "@xstack/purchase/*": ["./packages/purchase/src/*"], "@xstack/emails/*": ["./packages/emails/src/*"], "@xstack/form/*": ["./packages/form/src/*"], "@xstack/i18n/*": ["./packages/i18n/src/*"],