diff --git a/bun.lock b/bun.lock index 41ae78fe8..ad2439c91 100644 --- a/bun.lock +++ b/bun.lock @@ -253,6 +253,18 @@ "typescript-eslint": "^8.26.0", }, }, + "packages/stripe-sync": { + "name": "@autumn/stripe-sync", + "version": "1.0.0", + "dependencies": { + "@supabase/stripe-sync-engine": "^0.48.5", + "pg": "^8.20.0", + "stripe": "catalog:", + }, + "devDependencies": { + "@types/pg": "^8.20.0", + }, + }, "scripts": { "name": "@autumn/scripts", "version": "1.0.0", @@ -282,6 +294,7 @@ "@anthropic-ai/sdk": "^0.32.1", "@autumn/ksuid": "workspace:*", "@autumn/shared": "workspace:*", + "@autumn/stripe-sync": "workspace:*", "@aws-sdk/client-s3": "^3.1017.0", "@aws-sdk/client-scheduler": "^3.1004.0", "@aws-sdk/client-sqs": "^3.958.0", @@ -604,6 +617,8 @@ "@autumn/shared": ["@autumn/shared@workspace:shared"], + "@autumn/stripe-sync": ["@autumn/stripe-sync@workspace:packages/stripe-sync"], + "@autumn/vite": ["@autumn/vite@workspace:vite"], "@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="], @@ -1178,11 +1193,11 @@ "@mermaid-js/parser": ["@mermaid-js/parser@1.1.0", "", { "dependencies": { "langium": "^4.0.0" } }, "sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw=="], - "@mintlify/cli": ["@mintlify/cli@4.0.1107", "", { "dependencies": { "@inquirer/prompts": "7.9.0", "@mintlify/common": "1.0.847", "@mintlify/link-rot": "3.0.1022", "@mintlify/prebuild": "1.0.989", "@mintlify/previewing": "4.0.1050", "@mintlify/validation": "0.1.662", "adm-zip": "0.5.16", "chalk": "5.2.0", "color": "4.2.3", "detect-port": "1.5.1", "front-matter": "4.0.2", "fs-extra": "11.2.0", "ink": "6.3.0", "inquirer": "12.3.0", "js-yaml": "4.1.0", "mdast-util-mdx-jsx": "3.2.0", "open": "^8.4.2", "openid-client": "^6.8.2", "posthog-node": "5.17.2", "react": "19.2.3", "semver": "7.7.2", "unist-util-visit": "5.0.0", "yargs": "17.7.1", "zod": "^4.3.6" }, "optionalDependencies": { "keytar": "^7.9.0" }, "bin": { "mint": "bin/index.js", "mintlify": "bin/index.js" } }, "sha512-kOj8kFyebIzPkitgiCy7eWiJnCzPALOzEccQCQcaYHA7GUtXcMi6qK1IfssYaWbKs4Nbv9GmyodWPTCZEDFYzg=="], + "@mintlify/cli": ["@mintlify/cli@4.0.1109", "", { "dependencies": { "@inquirer/prompts": "7.9.0", "@mintlify/common": "1.0.849", "@mintlify/link-rot": "3.0.1024", "@mintlify/prebuild": "1.0.991", "@mintlify/previewing": "4.0.1052", "@mintlify/validation": "0.1.663", "adm-zip": "0.5.16", "chalk": "5.2.0", "color": "4.2.3", "detect-port": "1.5.1", "front-matter": "4.0.2", "fs-extra": "11.2.0", "ink": "6.3.0", "inquirer": "12.3.0", "js-yaml": "4.1.0", "mdast-util-mdx-jsx": "3.2.0", "open": "^8.4.2", "openid-client": "^6.8.2", "posthog-node": "5.17.2", "react": "19.2.3", "semver": "7.7.2", "unist-util-visit": "5.0.0", "yargs": "17.7.1", "zod": "^4.3.6" }, "optionalDependencies": { "keytar": "^7.9.0" }, "bin": { "mint": "bin/index.js", "mintlify": "bin/index.js" } }, "sha512-pw3XejzgnZLhMbcEUjMpS5+muIOWsxbpKVhikK3G3cmzq2FXtZgHpfH5JkA0uHPoNhacrPN2b3I3Aj2LyNKc/A=="], - "@mintlify/common": ["@mintlify/common@1.0.847", "", { "dependencies": { "@asyncapi/parser": "3.4.0", "@asyncapi/specs": "6.8.1", "@mintlify/mdx": "^3.0.4", "@mintlify/models": "0.0.290", "@mintlify/openapi-parser": "^0.0.8", "@mintlify/validation": "0.1.662", "@sindresorhus/slugify": "2.2.0", "@types/mdast": "4.0.4", "acorn": "8.11.2", "acorn-jsx": "5.3.2", "color-blend": "4.0.0", "estree-util-to-js": "2.0.0", "estree-walker": "3.0.3", "front-matter": "4.0.2", "hast-util-from-html": "2.0.3", "hast-util-to-html": "9.0.4", "hast-util-to-text": "4.0.2", "hex-rgb": "5.0.0", "ignore": "7.0.5", "js-yaml": "4.1.0", "lodash": "4.17.21", "mdast-util-from-markdown": "2.0.2", "mdast-util-gfm": "3.0.0", "mdast-util-mdx": "3.0.0", "mdast-util-mdx-jsx": "3.1.3", "micromark-extension-gfm": "3.0.0", "micromark-extension-mdx-jsx": "3.0.1", "micromark-extension-mdxjs": "3.0.0", "openapi-types": "12.1.3", "postcss": "8.5.6", "rehype-stringify": "10.0.1", "remark": "15.0.1", "remark-frontmatter": "5.0.0", "remark-gfm": "4.0.0", "remark-math": "6.0.0", "remark-mdx": "3.1.0", "remark-parse": "11.0.0", "remark-rehype": "11.1.1", "remark-stringify": "11.0.0", "sucrase": "^3.34.0", "tailwindcss": "^3.4.17", "unified": "11.0.5", "unist-builder": "4.0.0", "unist-util-map": "4.0.0", "unist-util-remove": "4.0.0", "unist-util-remove-position": "5.0.0", "unist-util-visit": "5.0.0", "unist-util-visit-parents": "6.0.1", "vfile": "6.0.3", "xss": "1.0.15" } }, "sha512-6MNx0xn5M4KLSyWwbyUc3jpHUZ9vJFjRmRzo0n06Dt5OcpUFt89NwXYLQBbjNxFinklw9JN2+02vudoZsAdtnA=="], + "@mintlify/common": ["@mintlify/common@1.0.849", "", { "dependencies": { "@asyncapi/parser": "3.4.0", "@asyncapi/specs": "6.8.1", "@mintlify/mdx": "^3.0.4", "@mintlify/models": "0.0.290", "@mintlify/openapi-parser": "^0.0.8", "@mintlify/validation": "0.1.663", "@sindresorhus/slugify": "2.2.0", "@types/mdast": "4.0.4", "acorn": "8.11.2", "acorn-jsx": "5.3.2", "color-blend": "4.0.0", "estree-util-to-js": "2.0.0", "estree-walker": "3.0.3", "front-matter": "4.0.2", "hast-util-from-html": "2.0.3", "hast-util-to-html": "9.0.4", "hast-util-to-text": "4.0.2", "hex-rgb": "5.0.0", "ignore": "7.0.5", "js-yaml": "4.1.0", "lodash": "4.18.1", "mdast-util-from-markdown": "2.0.2", "mdast-util-gfm": "3.0.0", "mdast-util-mdx": "3.0.0", "mdast-util-mdx-jsx": "3.1.3", "micromark-extension-gfm": "3.0.0", "micromark-extension-mdx-jsx": "3.0.1", "micromark-extension-mdxjs": "3.0.0", "openapi-types": "12.1.3", "postcss": "8.5.6", "rehype-stringify": "10.0.1", "remark": "15.0.1", "remark-frontmatter": "5.0.0", "remark-gfm": "4.0.0", "remark-math": "6.0.0", "remark-mdx": "3.1.0", "remark-parse": "11.0.0", "remark-rehype": "11.1.1", "remark-stringify": "11.0.0", "sucrase": "^3.34.0", "tailwindcss": "^3.4.17", "unified": "11.0.5", "unist-builder": "4.0.0", "unist-util-map": "4.0.0", "unist-util-remove": "4.0.0", "unist-util-remove-position": "5.0.0", "unist-util-visit": "5.0.0", "unist-util-visit-parents": "6.0.1", "vfile": "6.0.3", "xss": "1.0.15" } }, "sha512-VGo1tRAImwccC/oyTSxD2rATKj2AQ9HR9mO5pbs4lJQGOTIfpAVtyie7iSohmtnu4WRJOBLRofSv/jgYGnG0zg=="], - "@mintlify/link-rot": ["@mintlify/link-rot@3.0.1022", "", { "dependencies": { "@mintlify/common": "1.0.847", "@mintlify/prebuild": "1.0.989", "@mintlify/previewing": "4.0.1050", "@mintlify/scraping": "4.0.522", "@mintlify/validation": "0.1.662", "fs-extra": "11.1.0", "unist-util-visit": "4.1.2" } }, "sha512-/I0FsmLToYiw6D8XNjWzQaexjsrYYosY0bPcQDO0xvfIYfYRX0jAyEoLJ3n6tGfHZYi5C666kT7ti5evIO37zg=="], + "@mintlify/link-rot": ["@mintlify/link-rot@3.0.1024", "", { "dependencies": { "@mintlify/common": "1.0.849", "@mintlify/prebuild": "1.0.991", "@mintlify/previewing": "4.0.1052", "@mintlify/scraping": "4.0.522", "@mintlify/validation": "0.1.663", "fs-extra": "11.1.0", "unist-util-visit": "4.1.2" } }, "sha512-+0AeKU24PvdAguipUUmmGq17CzOq7X2B3FLUNig63Zc4v47t5DeSQ/WSOo36sNv6LD3c1//RJKBRO2gfa9XTnw=="], "@mintlify/mdx": ["@mintlify/mdx@3.0.4", "", { "dependencies": { "@shikijs/transformers": "^3.11.0", "@shikijs/twoslash": "^3.12.2", "arktype": "^2.1.26", "hast-util-to-string": "^3.0.1", "mdast-util-from-markdown": "^2.0.2", "mdast-util-gfm": "^3.1.0", "mdast-util-mdx-jsx": "^3.2.0", "mdast-util-to-hast": "^13.2.0", "next-mdx-remote-client": "^1.0.3", "rehype-katex": "^7.0.1", "remark-gfm": "^4.0.0", "remark-math": "^6.0.0", "remark-smartypants": "^3.0.2", "shiki": "^3.11.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0" }, "peerDependencies": { "@radix-ui/react-popover": "^1.1.15", "react": "^18.3.1", "react-dom": "^18.3.1" } }, "sha512-tJhdpnM5ReJLNJ2fuDRIEr0zgVd6id7/oAIfs26V46QlygiLsc8qx4Rz3LWIX51rUXW/cfakjj0EATxIciIw+g=="], @@ -1190,13 +1205,13 @@ "@mintlify/openapi-parser": ["@mintlify/openapi-parser@0.0.8", "", { "dependencies": { "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "ajv-formats": "^3.0.1", "jsonpointer": "^5.0.1", "leven": "^4.0.0", "yaml": "^2.4.5" } }, "sha512-9MBRq9lS4l4HITYCrqCL7T61MOb20q9IdU7HWhqYMNMM1jGO1nHjXasFy61yZ8V6gMZyyKQARGVoZ0ZrYN48Og=="], - "@mintlify/prebuild": ["@mintlify/prebuild@1.0.989", "", { "dependencies": { "@mintlify/common": "1.0.847", "@mintlify/openapi-parser": "^0.0.8", "@mintlify/scraping": "4.0.711", "@mintlify/validation": "0.1.662", "chalk": "5.3.0", "favicons": "7.2.0", "front-matter": "4.0.2", "fs-extra": "11.1.0", "js-yaml": "4.1.0", "openapi-types": "12.1.3", "sharp": "0.33.5", "sharp-ico": "0.1.5", "unist-util-visit": "4.1.2", "uuid": "11.1.0" } }, "sha512-4ywkAhn2+F0vDEO3MtVmrl89gGx4uG3+2cqHDZgTfI88/VLSyryzT1GJKEI8xDSZtTCJpM/2A18fvhw7kPPxsA=="], + "@mintlify/prebuild": ["@mintlify/prebuild@1.0.991", "", { "dependencies": { "@mintlify/common": "1.0.849", "@mintlify/openapi-parser": "^0.0.8", "@mintlify/scraping": "4.0.713", "@mintlify/validation": "0.1.663", "chalk": "5.3.0", "favicons": "7.2.0", "front-matter": "4.0.2", "fs-extra": "11.1.0", "js-yaml": "4.1.0", "openapi-types": "12.1.3", "sharp": "0.33.5", "sharp-ico": "0.1.5", "unist-util-visit": "4.1.2", "uuid": "11.1.0" } }, "sha512-pyQAWFYVmPnc20jpo6DnItmvHhJytKzqW/Q3GIYDqHsj/6JctcCfv07+Sc2m2bfu84kAd71yl1BIvXyBNFrD1Q=="], - "@mintlify/previewing": ["@mintlify/previewing@4.0.1050", "", { "dependencies": { "@mintlify/common": "1.0.847", "@mintlify/prebuild": "1.0.989", "@mintlify/validation": "0.1.662", "adm-zip": "0.5.16", "better-opn": "3.0.2", "chalk": "5.2.0", "chokidar": "3.5.3", "express": "4.18.2", "front-matter": "4.0.2", "fs-extra": "11.1.0", "got": "13.0.0", "ink": "6.3.0", "ink-spinner": "5.0.0", "is-online": "10.0.0", "js-yaml": "4.1.0", "openapi-types": "12.1.3", "react": "19.2.3", "socket.io": "4.7.2", "tar": "6.1.15", "unist-util-visit": "4.1.2", "yargs": "17.7.1" } }, "sha512-0NHlSkQRIrYKyHBsG6YFcilIVy1/1iEVkpdOlZJ3lSP8Mo40iiYlzT475GmBNuNBWMSd0CcH2PCJUWptWNDsXQ=="], + "@mintlify/previewing": ["@mintlify/previewing@4.0.1052", "", { "dependencies": { "@mintlify/common": "1.0.849", "@mintlify/prebuild": "1.0.991", "@mintlify/validation": "0.1.663", "adm-zip": "0.5.16", "better-opn": "3.0.2", "chalk": "5.2.0", "chokidar": "3.5.3", "express": "4.18.2", "front-matter": "4.0.2", "fs-extra": "11.1.0", "got": "13.0.0", "ink": "6.3.0", "ink-spinner": "5.0.0", "is-online": "10.0.0", "js-yaml": "4.1.0", "openapi-types": "12.1.3", "react": "19.2.3", "socket.io": "4.7.2", "tar": "6.1.15", "unist-util-visit": "4.1.2", "yargs": "17.7.1" } }, "sha512-3d3PkyAk+7ErNQN7zJfIRKjlkpU4iHNVTFQLd38hi5717y6FH6+RpyoVT1iBsM0q6ZqW5ZZeDvdvVTF9obqnCQ=="], "@mintlify/scraping": ["@mintlify/scraping@4.0.522", "", { "dependencies": { "@mintlify/common": "1.0.661", "@mintlify/openapi-parser": "^0.0.8", "fs-extra": "11.1.1", "hast-util-to-mdast": "10.1.0", "js-yaml": "4.1.0", "mdast-util-mdx-jsx": "3.1.3", "neotraverse": "0.6.18", "puppeteer": "22.14.0", "rehype-parse": "9.0.1", "remark-gfm": "4.0.0", "remark-mdx": "3.0.1", "remark-parse": "11.0.0", "remark-stringify": "11.0.0", "unified": "11.0.5", "unist-util-visit": "5.0.0", "yargs": "17.7.1", "zod": "3.21.4" }, "bin": { "mintlify-scrape": "bin/cli.js" } }, "sha512-PL2k52WT5S5OAgnT2K13bP7J2El6XwiVvQlrLvxDYw5KMMV+y34YVJI8ZscKb4trjitWDgyK0UTq2KN6NQgn6g=="], - "@mintlify/validation": ["@mintlify/validation@0.1.662", "", { "dependencies": { "@mintlify/mdx": "^3.0.4", "@mintlify/models": "0.0.290", "arktype": "2.1.27", "js-yaml": "4.1.0", "lcm": "0.0.3", "lodash": "4.17.21", "neotraverse": "0.6.18", "object-hash": "3.0.0", "openapi-types": "12.1.3", "uuid": "11.1.0", "zod": "3.24.0", "zod-to-json-schema": "3.20.4" } }, "sha512-yCKiIDDAZjJ6AQVg8bpJ+jU/8a7pSmqjmJfAjNBuvDoNYi1XhRSnSGDtDTfl+Ubx1mbwF5ryEc4nCEQZ0VShwQ=="], + "@mintlify/validation": ["@mintlify/validation@0.1.663", "", { "dependencies": { "@mintlify/mdx": "^3.0.4", "@mintlify/models": "0.0.290", "arktype": "2.1.27", "js-yaml": "4.1.0", "lcm": "0.0.3", "lodash": "4.18.1", "neotraverse": "0.6.18", "object-hash": "3.0.0", "openapi-types": "12.1.3", "uuid": "11.1.0", "zod": "3.24.0", "zod-to-json-schema": "3.20.4" } }, "sha512-MU2PECIyOJaS0VHokMp4snEERSBzeHdMtGAMteRr9gl76hTlRKmp+i/9GUQL1XxrGfzY9s2D30YJjsMUUq2Lyw=="], "@mishieck/ink-titled-box": ["@mishieck/ink-titled-box@0.3.0", "", { "peerDependencies": { "ink": "^6.0.0", "react": "^19.1.0", "typescript": "^5" } }, "sha512-ugzVH9hixp3hwKfQ8On/qnsrdAxS3y9rTu/aGOFed4zVUvtZyGZNIR4rxAwXult8HKI4vJEh0OM8wib9NPrwUg=="], @@ -1426,7 +1441,7 @@ "@playwright/test": ["@playwright/test@1.58.1", "", { "dependencies": { "playwright": "1.58.1" }, "bin": { "playwright": "cli.js" } }, "sha512-6LdVIUERWxQMmUSSQi0I53GgCBYgM2RpGngCPY7hSeju+VrKjq3lvs7HpJoPbDiY5QM5EYRtRX5fvrinnMAz3w=="], - "@posthog/ai": ["@posthog/ai@7.13.2", "", { "dependencies": { "@anthropic-ai/sdk": "^0.78.0", "@google/genai": "^1.43.0", "@langchain/core": "^1.1.29", "@posthog/core": "1.25.2", "langchain": "^1.2.28", "openai": "^6.25.0", "uuid": "^11.1.0", "zod": "^4.1.13" }, "peerDependencies": { "@ai-sdk/provider": "^2.0.0 || ^3.0.0", "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-trace-otlp-http": "^0.200.0", "posthog-node": "^5.0.0" }, "optionalPeers": ["@ai-sdk/provider", "@opentelemetry/api", "@opentelemetry/exporter-trace-otlp-http"] }, "sha512-1hnHYR7vvdIhg7gRcy77xFSuxRub8/xo9TsZze8q6u4XlHw+dJxyFTj0wKvzTxXZzZRmTXM0uLhgEWeZHuXmeA=="], + "@posthog/ai": ["@posthog/ai@7.14.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.78.0", "@google/genai": "^1.43.0", "@langchain/core": "^1.1.29", "@posthog/core": "1.25.2", "langchain": "^1.2.28", "openai": "^6.25.0", "uuid": "^11.1.0", "zod": "^4.1.13" }, "peerDependencies": { "@ai-sdk/provider": "^2.0.0 || ^3.0.0", "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-trace-otlp-http": "^0.200.0", "posthog-node": "^5.0.0" }, "optionalPeers": ["@ai-sdk/provider", "@opentelemetry/api", "@opentelemetry/exporter-trace-otlp-http"] }, "sha512-gKWim9vwPEtFMg2zrzPyf3UVb7YeLcsWFA8sRWyKXvxh04O92bbpG+v3OR3g6zVmE8wCrmXdtEs14IDgnmCz5g=="], "@posthog/core": ["@posthog/core@1.25.2", "", {}, "sha512-h2FO7ut/BbfwpAXWpwdDHTzQgUo9ibDFEs6ZO+3cI3KPWQt5XwczK1OLAuPprcjm8T/jl0SH8jSFo5XdU4RbTg=="], @@ -1922,6 +1937,8 @@ "@supabase/storage-js": ["@supabase/storage-js@2.103.0", "", { "dependencies": { "iceberg-js": "^0.8.1", "tslib": "2.8.1" } }, "sha512-DHmlvdAXwtOmZNbkIZi4lkobPR3XjIzoOgzoz5duMf6G+sDeY015YrzMJCnqdccuYr7X5x4yYuSwF//RoN2dvQ=="], + "@supabase/stripe-sync-engine": ["@supabase/stripe-sync-engine@0.48.5", "", { "dependencies": { "pg": "^8.20.0", "pg-node-migrations": "0.0.8", "yesql": "^7.0.0" }, "peerDependencies": { "stripe": "> 18" } }, "sha512-+LbtJH8n5Xiu289AL3FuWFdKXd0K7kDF0z4Lm+zMYoImWmOuGd3TgSx9gm/nv4nzLooOmIxGZh6LojoYBcJM+g=="], + "@supabase/supabase-js": ["@supabase/supabase-js@2.103.0", "", { "dependencies": { "@supabase/auth-js": "2.103.0", "@supabase/functions-js": "2.103.0", "@supabase/postgrest-js": "2.103.0", "@supabase/realtime-js": "2.103.0", "@supabase/storage-js": "2.103.0" } }, "sha512-j/6q5+LtXbR/YOLSLhy7Na74RD1cV2v+KwIIuuqMEjk1JpLEEyu0ynwDHpGoxMncDQl+R5FogaVqZm+85lZvtw=="], "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], @@ -1962,13 +1979,13 @@ "@tanstack/devtools-event-client": ["@tanstack/devtools-event-client@0.4.3", "", { "bin": { "intent": "bin/intent.js" } }, "sha512-OZI6QyULw0FI0wjgmeYzCIfbgPsOEzwJtCpa69XrfLMtNXLGnz3d/dIabk7frg0TmHo+Ah49w5I4KC7Tufwsvw=="], - "@tanstack/form-core": ["@tanstack/form-core@1.28.6", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.1", "@tanstack/pacer-lite": "^0.1.1", "@tanstack/store": "^0.9.1" } }, "sha512-4zroxL6VDj5O+w7l3dYZnUeL/h30KtNSV7UWzKAL7cl+8clMFdISPDlDlluS37As7oqvPVKo8B83VlIBvgmRog=="], + "@tanstack/form-core": ["@tanstack/form-core@1.29.0", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.1", "@tanstack/pacer-lite": "^0.1.1", "@tanstack/store": "^0.9.1" } }, "sha512-uyeKEdJBfbj0bkBSwvSYVRtWLOaXvfNX3CeVw1HqGOXVLxpBBGAqWdYLc+UoX/9xcoFwFXrjR9QqMPzvwm2yyQ=="], "@tanstack/pacer-lite": ["@tanstack/pacer-lite@0.1.1", "", {}, "sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w=="], "@tanstack/query-core": ["@tanstack/query-core@5.85.6", "", {}, "sha512-hCj0TktzdCv2bCepIdfwqVwUVWb+GSHm1Jnn8w+40lfhQ3m7lCO7ADRUJy+2unxQ/nzjh2ipC6ye69NDW3l73g=="], - "@tanstack/react-form": ["@tanstack/react-form@1.28.6", "", { "dependencies": { "@tanstack/form-core": "1.28.6", "@tanstack/react-store": "^0.9.1" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-dRxwKeNW3uuJvf0sXsIQ2compFMnIJNk9B436Lx0fqkqK+CBvA1tNmEdX+faoCpuQ5Wua3c8ahVibJ65cpkijA=="], + "@tanstack/react-form": ["@tanstack/react-form@1.29.0", "", { "dependencies": { "@tanstack/form-core": "1.29.0", "@tanstack/react-store": "^0.9.1" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-jj425NNX0QKqbUzqSNiYI3HCPHSk2df47acXCJyXczWOTmG81ECZGkgofgqamFsSU9kMiH6Di5RLUnftrlhWSw=="], "@tanstack/react-query": ["@tanstack/react-query@5.85.6", "", { "dependencies": { "@tanstack/query-core": "5.85.6" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-VUAag4ERjh+qlmg0wNivQIVCZUrYndqYu3/wPCVZd4r0E+1IqotbeyGTc+ICroL/PqbpSaGZg02zSWYfcvxbdA=="], @@ -3798,7 +3815,7 @@ "ksuid": ["ksuid@3.0.0", "", { "dependencies": { "base-convert-int-array": "^1.0.1" } }, "sha512-81CkBGn/06ZVAjGvFZi6fVG8VcPeMH0JpJ4V1Z9VwrMMaGIeAjY4jrVdrIcxhL9I2ZUU6t5uiyswcmkk+KZegA=="], - "kysely": ["kysely@0.28.15", "", {}, "sha512-r2clcf7HLWvDXaVUEvQymXJY4i3bSOIV3xsL/Upy3ZfSv5HeKsk9tsqbBptLvth5qHEIhxeHTA2jNLyQABkLBA=="], + "kysely": ["kysely@0.28.16", "", {}, "sha512-3i5pmOiZvMDj00qhrIVbH0AnioVTx22DMP7Vn5At4yJO46iy+FM8Y/g61ltenLVSo3fiO8h8Q3QOFgf/gQ72ww=="], "langchain": ["langchain@1.3.1", "", { "dependencies": { "@langchain/langgraph": "^1.2.8", "@langchain/langgraph-checkpoint": "^1.0.1", "langsmith": ">=0.5.0 <1.0.0", "uuid": "^11.1.0", "zod": "^3.25.76 || ^4" }, "peerDependencies": { "@langchain/core": "^1.1.39" } }, "sha512-tJu8Ibf3NAuDW8pMT7VIBCc96m8TymjSMqRBpQawlG8zeTGPZK5TKA5gBYRNZvrz0YkJA57O2rcjP+mIxrS0+g=="], @@ -4102,7 +4119,7 @@ "minizlib": ["minizlib@2.1.2", "", { "dependencies": { "minipass": "^3.0.0", "yallist": "^4.0.0" } }, "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg=="], - "mintlify": ["mintlify@4.2.504", "", { "dependencies": { "@mintlify/cli": "4.0.1107" }, "bin": { "mintlify": "index.js" } }, "sha512-N7Upy3DHiuvVzith7MTI8cRRGuzlYgAAKjMgIeWi4Cz8JQVDH94NbJ+icl3gVhef6DRtZkmYHzpeZRpYATASpA=="], + "mintlify": ["mintlify@4.2.506", "", { "dependencies": { "@mintlify/cli": "4.0.1109" }, "bin": { "mintlify": "index.js" } }, "sha512-RpFXl57t0tX5NuPbw+69O7kKW2AFTHf5UdwPWHAd9NKXyPjj4K7FxXB1DlkEHyvp6Yhewr6LTjJigNFp4vrPQw=="], "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="], @@ -4368,6 +4385,8 @@ "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], + "pg-node-migrations": ["pg-node-migrations@0.0.8", "", { "dependencies": { "pg": "^8.6.0", "sql-template-strings": "^2.2.2" }, "bin": { "pg-validate-migrations": "dist/bin/validate.js" } }, "sha512-44cMl9umOmCv0hzZyEcvjEq8Bm8u7mrzggZ06qXTJVSsMMB4j2OsjG+rSp+uzeKWyP2Vu0K9Ye2wKtjFUJwrdw=="], + "pg-pool": ["pg-pool@3.13.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA=="], "pg-protocol": ["pg-protocol@1.13.0", "", {}, "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w=="], @@ -4864,6 +4883,8 @@ "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], + "sql-template-strings": ["sql-template-strings@2.2.2", "", {}, "sha512-UXhXR2869FQaD+GMly8jAMCRZ94nU5KcrFetZfWEMd+LVVG6y0ExgHAhatEcKZ/wk8YcKPdi+hiD2wm75lq3/Q=="], + "sqs-consumer": ["sqs-consumer@6.0.2", "", { "dependencies": { "@aws-sdk/client-sqs": "^3.226.0", "debug": "^4.3.4" } }, "sha512-Y8ztFBc1VPj4q72j9Uji4XR6n2O88n9X83aOUaCvz9zZqjpGJaI4PBHBPnE8RDBgD0EQR8Dh+sQwH/+8mzr+Bg=="], "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], @@ -5374,6 +5395,8 @@ "yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="], + "yesql": ["yesql@7.0.0", "", {}, "sha512-sosfr7agy4ibLM7BvXBkM6BpBmKMGuBO8DUYQEuey+QqaqrgW+2bsSg6D050ocBYIz0PuHxUyehyzEztZTU4pw=="], + "yn": ["yn@3.1.1", "", {}, "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q=="], "yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="], @@ -5754,7 +5777,7 @@ "@mintlify/openapi-parser/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], - "@mintlify/prebuild/@mintlify/scraping": ["@mintlify/scraping@4.0.711", "", { "dependencies": { "@mintlify/common": "1.0.847", "@mintlify/openapi-parser": "^0.0.8", "fs-extra": "11.1.1", "hast-util-to-mdast": "10.1.0", "js-yaml": "4.1.0", "mdast-util-mdx-jsx": "3.1.3", "neotraverse": "0.6.18", "puppeteer": "22.14.0", "rehype-parse": "9.0.1", "remark-gfm": "4.0.0", "remark-mdx": "3.0.1", "remark-parse": "11.0.0", "remark-stringify": "11.0.0", "unified": "11.0.5", "unist-util-visit": "5.0.0", "yargs": "17.7.1", "zod": "3.24.0" }, "bin": { "mintlify-scrape": "bin/cli.js" } }, "sha512-e0tWdbZoxZ+/OKm29stpAoJkevL1hawpCdsTKhV5/9O8o4cTpLhEnAkS6fSlBv5t0k/Ut/FQeW10kIfgXj+Yww=="], + "@mintlify/prebuild/@mintlify/scraping": ["@mintlify/scraping@4.0.713", "", { "dependencies": { "@mintlify/common": "1.0.849", "@mintlify/openapi-parser": "^0.0.8", "fs-extra": "11.1.1", "hast-util-to-mdast": "10.1.0", "js-yaml": "4.1.0", "mdast-util-mdx-jsx": "3.1.3", "neotraverse": "0.6.18", "puppeteer": "22.14.0", "rehype-parse": "9.0.1", "remark-gfm": "4.0.0", "remark-mdx": "3.0.1", "remark-parse": "11.0.0", "remark-stringify": "11.0.0", "unified": "11.0.5", "unist-util-visit": "5.0.0", "yargs": "17.7.1", "zod": "3.24.0" }, "bin": { "mintlify-scrape": "bin/cli.js" } }, "sha512-V0+gUqL3LgJygTL7PunRMaxirHp0oGKCLyFNmAQMJ3gX4WPUBYc+5GLiJ7hQn6lsp6m6wLmztVpeKdX8OwD8GA=="], "@mintlify/prebuild/chalk": ["chalk@5.3.0", "", {}, "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w=="], @@ -5928,20 +5951,30 @@ "@opentelemetry/exporter-zipkin/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.0.1", "", { "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ=="], + "@opentelemetry/instrumentation-amqplib/@opentelemetry/core": ["@opentelemetry/core@2.6.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g=="], + "@opentelemetry/instrumentation-amqplib/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.211.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.211.0", "import-in-the-middle": "^2.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-h0nrZEC/zvI994nhg7EgQ8URIHt0uDTwN90r3qQUdZORS455bbx+YebnGeEuFghUT0HlJSrLF4iHw67f+odY+Q=="], + "@opentelemetry/instrumentation-connect/@opentelemetry/core": ["@opentelemetry/core@2.6.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g=="], + "@opentelemetry/instrumentation-connect/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.211.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.211.0", "import-in-the-middle": "^2.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-h0nrZEC/zvI994nhg7EgQ8URIHt0uDTwN90r3qQUdZORS455bbx+YebnGeEuFghUT0HlJSrLF4iHw67f+odY+Q=="], "@opentelemetry/instrumentation-dataloader/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.211.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.211.0", "import-in-the-middle": "^2.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-h0nrZEC/zvI994nhg7EgQ8URIHt0uDTwN90r3qQUdZORS455bbx+YebnGeEuFghUT0HlJSrLF4iHw67f+odY+Q=="], + "@opentelemetry/instrumentation-express/@opentelemetry/core": ["@opentelemetry/core@2.6.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g=="], + "@opentelemetry/instrumentation-express/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.211.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.211.0", "import-in-the-middle": "^2.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-h0nrZEC/zvI994nhg7EgQ8URIHt0uDTwN90r3qQUdZORS455bbx+YebnGeEuFghUT0HlJSrLF4iHw67f+odY+Q=="], + "@opentelemetry/instrumentation-fs/@opentelemetry/core": ["@opentelemetry/core@2.6.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g=="], + "@opentelemetry/instrumentation-fs/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.211.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.211.0", "import-in-the-middle": "^2.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-h0nrZEC/zvI994nhg7EgQ8URIHt0uDTwN90r3qQUdZORS455bbx+YebnGeEuFghUT0HlJSrLF4iHw67f+odY+Q=="], "@opentelemetry/instrumentation-generic-pool/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.211.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.211.0", "import-in-the-middle": "^2.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-h0nrZEC/zvI994nhg7EgQ8URIHt0uDTwN90r3qQUdZORS455bbx+YebnGeEuFghUT0HlJSrLF4iHw67f+odY+Q=="], "@opentelemetry/instrumentation-graphql/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.211.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.211.0", "import-in-the-middle": "^2.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-h0nrZEC/zvI994nhg7EgQ8URIHt0uDTwN90r3qQUdZORS455bbx+YebnGeEuFghUT0HlJSrLF4iHw67f+odY+Q=="], + "@opentelemetry/instrumentation-hapi/@opentelemetry/core": ["@opentelemetry/core@2.6.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g=="], + "@opentelemetry/instrumentation-hapi/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.211.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.211.0", "import-in-the-middle": "^2.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-h0nrZEC/zvI994nhg7EgQ8URIHt0uDTwN90r3qQUdZORS455bbx+YebnGeEuFghUT0HlJSrLF4iHw67f+odY+Q=="], "@opentelemetry/instrumentation-http/@opentelemetry/core": ["@opentelemetry/core@2.5.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ=="], @@ -5954,18 +5987,24 @@ "@opentelemetry/instrumentation-knex/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.211.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.211.0", "import-in-the-middle": "^2.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-h0nrZEC/zvI994nhg7EgQ8URIHt0uDTwN90r3qQUdZORS455bbx+YebnGeEuFghUT0HlJSrLF4iHw67f+odY+Q=="], + "@opentelemetry/instrumentation-koa/@opentelemetry/core": ["@opentelemetry/core@2.6.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g=="], + "@opentelemetry/instrumentation-koa/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.211.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.211.0", "import-in-the-middle": "^2.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-h0nrZEC/zvI994nhg7EgQ8URIHt0uDTwN90r3qQUdZORS455bbx+YebnGeEuFghUT0HlJSrLF4iHw67f+odY+Q=="], "@opentelemetry/instrumentation-lru-memoizer/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.211.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.211.0", "import-in-the-middle": "^2.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-h0nrZEC/zvI994nhg7EgQ8URIHt0uDTwN90r3qQUdZORS455bbx+YebnGeEuFghUT0HlJSrLF4iHw67f+odY+Q=="], "@opentelemetry/instrumentation-mongodb/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.211.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.211.0", "import-in-the-middle": "^2.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-h0nrZEC/zvI994nhg7EgQ8URIHt0uDTwN90r3qQUdZORS455bbx+YebnGeEuFghUT0HlJSrLF4iHw67f+odY+Q=="], + "@opentelemetry/instrumentation-mongoose/@opentelemetry/core": ["@opentelemetry/core@2.6.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g=="], + "@opentelemetry/instrumentation-mongoose/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.211.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.211.0", "import-in-the-middle": "^2.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-h0nrZEC/zvI994nhg7EgQ8URIHt0uDTwN90r3qQUdZORS455bbx+YebnGeEuFghUT0HlJSrLF4iHw67f+odY+Q=="], "@opentelemetry/instrumentation-mysql/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.211.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.211.0", "import-in-the-middle": "^2.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-h0nrZEC/zvI994nhg7EgQ8URIHt0uDTwN90r3qQUdZORS455bbx+YebnGeEuFghUT0HlJSrLF4iHw67f+odY+Q=="], "@opentelemetry/instrumentation-mysql2/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.211.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.211.0", "import-in-the-middle": "^2.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-h0nrZEC/zvI994nhg7EgQ8URIHt0uDTwN90r3qQUdZORS455bbx+YebnGeEuFghUT0HlJSrLF4iHw67f+odY+Q=="], + "@opentelemetry/instrumentation-pg/@opentelemetry/core": ["@opentelemetry/core@2.6.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g=="], + "@opentelemetry/instrumentation-pg/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.211.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.211.0", "import-in-the-middle": "^2.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-h0nrZEC/zvI994nhg7EgQ8URIHt0uDTwN90r3qQUdZORS455bbx+YebnGeEuFghUT0HlJSrLF4iHw67f+odY+Q=="], "@opentelemetry/instrumentation-pg/@types/pg": ["@types/pg@8.15.6", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ=="], @@ -5974,6 +6013,8 @@ "@opentelemetry/instrumentation-tedious/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.211.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.211.0", "import-in-the-middle": "^2.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-h0nrZEC/zvI994nhg7EgQ8URIHt0uDTwN90r3qQUdZORS455bbx+YebnGeEuFghUT0HlJSrLF4iHw67f+odY+Q=="], + "@opentelemetry/instrumentation-undici/@opentelemetry/core": ["@opentelemetry/core@2.6.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g=="], + "@opentelemetry/instrumentation-undici/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.211.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.211.0", "import-in-the-middle": "^2.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-h0nrZEC/zvI994nhg7EgQ8URIHt0uDTwN90r3qQUdZORS455bbx+YebnGeEuFghUT0HlJSrLF4iHw67f+odY+Q=="], "@opentelemetry/otlp-grpc-exporter-base/@opentelemetry/core": ["@opentelemetry/core@2.0.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw=="], @@ -6014,6 +6055,8 @@ "@opentelemetry/sdk-trace-node/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.0.1", "", { "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ=="], + "@opentelemetry/sql-common/@opentelemetry/core": ["@opentelemetry/core@2.6.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g=="], + "@orpc/openapi/rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], "@orpc/server/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], @@ -6078,8 +6121,14 @@ "@sentry/node/@opentelemetry/context-async-hooks": ["@opentelemetry/context-async-hooks@2.6.1", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-XHzhwRNkBpeP8Fs/qjGrAf9r9PRv67wkJQ/7ZPaBQQ68DYlTBBx5MF9LvPx7mhuXcDessKK2b+DcxqwpgkcivQ=="], + "@sentry/node/@opentelemetry/core": ["@opentelemetry/core@2.6.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g=="], + "@sentry/node/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.211.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.211.0", "import-in-the-middle": "^2.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-h0nrZEC/zvI994nhg7EgQ8URIHt0uDTwN90r3qQUdZORS455bbx+YebnGeEuFghUT0HlJSrLF4iHw67f+odY+Q=="], + "@sentry/node/@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="], + + "@sentry/node/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-r86ut4T1e8vNwB35CqCcKd45yzqH6/6Wzvpk2/cZB8PsPLlZFTvrh8yfOS3CYZYcUmAx4hHTZJ8AO8Dj8nrdhw=="], + "@sentry/node/import-in-the-middle": ["import-in-the-middle@2.0.6", "", { "dependencies": { "acorn": "^8.15.0", "acorn-import-attributes": "^1.9.5", "cjs-module-lexer": "^2.2.0", "module-details-from-path": "^1.0.4" } }, "sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw=="], "@sentry/node-core/import-in-the-middle": ["import-in-the-middle@2.0.6", "", { "dependencies": { "acorn": "^8.15.0", "acorn-import-attributes": "^1.9.5", "cjs-module-lexer": "^2.2.0", "module-details-from-path": "^1.0.4" } }, "sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw=="], @@ -6662,6 +6711,8 @@ "posthog-js/@opentelemetry/exporter-logs-otlp-http": ["@opentelemetry/exporter-logs-otlp-http@0.208.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.208.0", "@opentelemetry/core": "2.2.0", "@opentelemetry/otlp-exporter-base": "0.208.0", "@opentelemetry/otlp-transformer": "0.208.0", "@opentelemetry/sdk-logs": "0.208.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-jOv40Bs9jy9bZVLo/i8FwUiuCvbjWDI+ZW13wimJm4LjnlwJxGgB+N/VWOZUTpM+ah/awXeQqKdNlpLf2EjvYg=="], + "posthog-js/@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="], + "posthog-js/@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.208.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.208.0", "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-QlAyL1jRpOeaqx7/leG1vJMp84g0xKP6gJmfELBpnI4O/9xPX+Hu5m1POk9Kl+veNkyth5t19hRlN6tNY1sjbA=="], "prebuild-install/tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="], @@ -8138,6 +8189,8 @@ "posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.208.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.208.0", "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0", "@opentelemetry/sdk-logs": "0.208.0", "@opentelemetry/sdk-metrics": "2.2.0", "@opentelemetry/sdk-trace-base": "2.2.0", "protobufjs": "^7.3.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-DCFPY8C6lAQHUNkzcNT9R+qYExvsk6C5Bto2pbNxgicpcSWbe2WHShLxkOxIdNcBiYPdVHv/e7vH7K6TI+C+fQ=="], + "posthog-js/@opentelemetry/resources/@opentelemetry/core": ["@opentelemetry/core@2.6.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g=="], + "posthog-js/@opentelemetry/sdk-logs/@opentelemetry/core": ["@opentelemetry/core@2.2.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw=="], "posthog-js/@opentelemetry/sdk-logs/@opentelemetry/resources": ["@opentelemetry/resources@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A=="], diff --git a/docker/Dockerfile b/docker/Dockerfile index 52e86ad61..5692de222 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -16,6 +16,7 @@ COPY packages/atmn/package.json ./packages/atmn/package.json COPY packages/atmn-tests/package.json ./packages/atmn-tests/package.json COPY packages/openapi/package.json ./packages/openapi/package.json COPY packages/ksuid/package.json ./packages/ksuid/package.json +COPY packages/stripe-sync/package.json ./packages/stripe-sync/package.json COPY packages/sdk/package.json ./packages/sdk/package.json COPY apps/sdk-test/package.json ./apps/sdk-test/package.json COPY apps/docs/package.json ./apps/docs/package.json diff --git a/package.json b/package.json index 9e55622d6..340a3a559 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "packages/sdk", "packages/autumn-js", "packages/openapi", - "packages/ksuid" + "packages/ksuid", + "packages/stripe-sync" ], "catalog": { "stripe": "19.3.0-beta.1", @@ -108,7 +109,9 @@ "js:publish": "bun js:build && cd packages/autumn-js && npm publish", "js:publish-beta": "bun js:build && cd packages/autumn-js && npm publish --tag beta", "js:publish-dry": "bun js:build && cd packages/autumn-js && npm publish --dry-run", - "js:version": "git fetch --tags origin && git tag -l 'autumn-js-v*' --sort=-v:refname | head -1" + "js:version": "git fetch --tags origin && git tag -l 'autumn-js-v*' --sort=-v:refname | head -1", + "stripe-sync:migrate": "bun packages/stripe-sync/scripts/migrate.ts", + "stripe-sync:migrate:prod": "NODE_ENV=production infisical run --env=prod -- bun packages/stripe-sync/scripts/migrate.ts" }, "dependencies": { "@aws-sdk/client-sqs": "^3.985.0", diff --git a/packages/stripe-sync/package.json b/packages/stripe-sync/package.json new file mode 100644 index 000000000..0a69102bf --- /dev/null +++ b/packages/stripe-sync/package.json @@ -0,0 +1,24 @@ +{ + "name": "@autumn/stripe-sync", + "version": "1.0.0", + "description": "Stripe-to-Postgres sync engine wrapper for Autumn", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "private": true, + "dependencies": { + "@supabase/stripe-sync-engine": "^0.48.5", + "pg": "^8.20.0", + "stripe": "catalog:" + }, + "devDependencies": { + "@types/pg": "^8.20.0" + } +} diff --git a/packages/stripe-sync/scripts/migrate.ts b/packages/stripe-sync/scripts/migrate.ts new file mode 100644 index 000000000..e80874c6d --- /dev/null +++ b/packages/stripe-sync/scripts/migrate.ts @@ -0,0 +1,26 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { config } from "dotenv"; +import { addAutumnColumns, runStripeSyncMigrations } from "../src/index.js"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(scriptDir, "../../.."); + +config({ path: path.join(repoRoot, "server/.env"), override: true }); + +const databaseUrl = process.env.STRIPE_SYNC_DATABASE_URL; + +if (!databaseUrl) { + console.error("STRIPE_SYNC_DATABASE_URL is required"); + process.exit(1); +} + +console.log("Running stripe sync migrations..."); +await runStripeSyncMigrations({ databaseUrl }); +console.log("Stripe sync migrations complete"); + +console.log( + "Adding Autumn columns (stripe_account_id, org_id) to synced tables...", +); +await addAutumnColumns({ databaseUrl }); +console.log("Autumn columns added"); diff --git a/packages/stripe-sync/src/addAccountIdColumn.ts b/packages/stripe-sync/src/addAccountIdColumn.ts new file mode 100644 index 000000000..fc1cf05b3 --- /dev/null +++ b/packages/stripe-sync/src/addAccountIdColumn.ts @@ -0,0 +1,38 @@ +import pg from "pg"; +import { SYNCED_TABLES } from "./eventTypeToTable.js"; + +/** + * Adds `stripe_account_id` and `org_id` columns + indexes to all synced tables. + * Idempotent -- safe to run multiple times. + */ +export const addAutumnColumns = async ({ + databaseUrl, + schema = "stripe", +}: { + databaseUrl: string; + schema?: string; +}): Promise => { + const client = new pg.Client({ connectionString: databaseUrl }); + await client.connect(); + + try { + for (const table of SYNCED_TABLES) { + await client.query(` + ALTER TABLE "${schema}"."${table}" + ADD COLUMN IF NOT EXISTS stripe_account_id TEXT, + ADD COLUMN IF NOT EXISTS org_id TEXT, + ADD COLUMN IF NOT EXISTS env TEXT + `); + await client.query(` + CREATE INDEX IF NOT EXISTS idx_${table}_stripe_account_id + ON "${schema}"."${table}" (stripe_account_id) + `); + await client.query(` + CREATE INDEX IF NOT EXISTS idx_${table}_org_id + ON "${schema}"."${table}" (org_id) + `); + } + } finally { + await client.end(); + } +}; diff --git a/packages/stripe-sync/src/eventTypeToTable.ts b/packages/stripe-sync/src/eventTypeToTable.ts new file mode 100644 index 000000000..6772935b2 --- /dev/null +++ b/packages/stripe-sync/src/eventTypeToTable.ts @@ -0,0 +1,61 @@ +/** + * Maps a Stripe event type to the sync DB table that stores the object. + * Returns undefined for event types that don't map to a known table. + */ +export const eventTypeToTable = ({ + eventType, +}: { + eventType: string; +}): string | undefined => { + if (eventType.startsWith("charge.dispute.")) return "disputes"; + if (eventType.startsWith("charge.")) return "charges"; + if (eventType.startsWith("checkout.session.")) return "checkout_sessions"; + if (eventType.startsWith("customer.subscription.")) return "subscriptions"; + if (eventType.startsWith("customer.tax_id.")) return "tax_ids"; + if (eventType.startsWith("customer.")) return "customers"; + if (eventType.startsWith("invoice.")) return "invoices"; + if (eventType.startsWith("product.")) return "products"; + if (eventType.startsWith("price.")) return "prices"; + if (eventType.startsWith("plan.")) return "plans"; + if (eventType.startsWith("setup_intent.")) return "setup_intents"; + if (eventType.startsWith("subscription_schedule.")) + return "subscription_schedules"; + if (eventType.startsWith("payment_method.")) return "payment_methods"; + if (eventType.startsWith("payment_intent.")) return "payment_intents"; + if (eventType.startsWith("credit_note.")) return "credit_notes"; + if (eventType.startsWith("radar.early_fraud_warning.")) + return "early_fraud_warnings"; + if (eventType.startsWith("refund.")) return "refunds"; + if (eventType.startsWith("review.")) return "reviews"; + if (eventType === "invoice_payment.paid") return "invoice_payments"; + + return undefined; +}; + +/** All tables in the stripe sync schema that store Stripe objects. */ +export const SYNCED_TABLES = [ + "charges", + "checkout_sessions", + "checkout_session_line_items", + "coupons", + "credit_notes", + "customers", + "disputes", + "early_fraud_warnings", + "events", + "invoices", + "invoice_payments", + "payment_intents", + "payment_methods", + "payouts", + "plans", + "prices", + "products", + "refunds", + "reviews", + "setup_intents", + "subscription_items", + "subscription_schedules", + "subscriptions", + "tax_ids", +] as const; diff --git a/packages/stripe-sync/src/index.ts b/packages/stripe-sync/src/index.ts new file mode 100644 index 000000000..dc23b3719 --- /dev/null +++ b/packages/stripe-sync/src/index.ts @@ -0,0 +1,12 @@ +export { addAutumnColumns } from "./addAccountIdColumn.js"; +export { eventTypeToTable, SYNCED_TABLES } from "./eventTypeToTable.js"; +export { + closeStripeSyncEngine, + getStripeSyncEngine, + processStripeSyncEvent, +} from "./initStripeSync.js"; +export { runStripeSyncMigrations } from "./runStripeSyncMigrations.js"; +export { + isSyncableEvent, + SYNCABLE_EVENT_PREFIXES, +} from "./syncableResources.js"; diff --git a/packages/stripe-sync/src/initStripeSync.ts b/packages/stripe-sync/src/initStripeSync.ts new file mode 100644 index 000000000..5d2626518 --- /dev/null +++ b/packages/stripe-sync/src/initStripeSync.ts @@ -0,0 +1,102 @@ +import { StripeSync } from "@supabase/stripe-sync-engine"; +import type Stripe from "stripe"; +import { eventTypeToTable } from "./eventTypeToTable.js"; + +const SCHEMA = "stripe"; + +let instance: StripeSync | null = null; +let initAttempted = false; + +/** + * Lazily creates a singleton StripeSync instance. + * Returns null if STRIPE_SYNC_DATABASE_URL is not configured or init fails. + */ +export const getStripeSyncEngine = (): StripeSync | null => { + if (instance) return instance; + if (initAttempted) return null; + + initAttempted = true; + + const databaseUrl = process.env.STRIPE_SYNC_DATABASE_URL; + + const stripeSecretKey = + process.env.STRIPE_LIVE_SECRET_KEY || process.env.STRIPE_SANDBOX_SECRET_KEY; + + if (!databaseUrl || !stripeSecretKey) return null; + + try { + instance = new StripeSync({ + poolConfig: { + connectionString: databaseUrl, + max: 5, + keepAlive: true, + connectionTimeoutMillis: 5_000, + idleTimeoutMillis: 30_000, + }, + stripeSecretKey, + stripeWebhookSecret: "unused-processEvent-only", + schema: SCHEMA, + }); + } catch { + instance = null; + } + + return instance; +}; + +/** + * Upserts the Stripe event into the sync DB, then stamps the row + * with the originating Stripe account ID and org ID for multi-tenancy. + * Fully fail-open: any error is swallowed and returns silently. + */ +export const processStripeSyncEvent = async ({ + event, + stripeAccountId, + orgId, + env, +}: { + event: Stripe.Event; + stripeAccountId?: string; + orgId?: string; + env?: string; +}): Promise => { + const engine = getStripeSyncEngine(); + if (!engine) return; + + try { + await engine.processEvent(event); + } catch { + return; + } + + const table = eventTypeToTable({ eventType: event.type }); + if (!table) return; + + const objectId = (event.data.object as { id?: string }).id; + if (!objectId) return; + + const accountId = stripeAccountId ?? event.account ?? null; + + if (!accountId && !orgId && !env) return; + + try { + await engine.postgresClient.pool.query( + `UPDATE "${SCHEMA}"."${table}" SET stripe_account_id = COALESCE($1, stripe_account_id), org_id = COALESCE($2, org_id), env = COALESCE($3, env) WHERE id = $4`, + [accountId, orgId, env, objectId], + ); + } catch { + // Fail-open: metadata stamp is best-effort + } +}; + +/** Gracefully close the sync engine's PG pool (call on server shutdown). */ +export const closeStripeSyncEngine = async (): Promise => { + if (!instance) return; + try { + await instance.close(); + } catch { + // Best-effort cleanup + } + instance = null; + initAttempted = false; +}; diff --git a/packages/stripe-sync/src/runStripeSyncMigrations.ts b/packages/stripe-sync/src/runStripeSyncMigrations.ts new file mode 100644 index 000000000..565c7b95a --- /dev/null +++ b/packages/stripe-sync/src/runStripeSyncMigrations.ts @@ -0,0 +1,15 @@ +import { runMigrations } from "@supabase/stripe-sync-engine"; + +/** + * Run stripe-sync-engine migrations against the sync DB. + * Idempotent -- safe to run multiple times. + */ +export const runStripeSyncMigrations = async ({ + databaseUrl, + schema = "stripe", +}: { + databaseUrl: string; + schema?: string; +}): Promise => { + await runMigrations({ databaseUrl, schema }); +}; diff --git a/packages/stripe-sync/src/syncableResources.ts b/packages/stripe-sync/src/syncableResources.ts new file mode 100644 index 000000000..1317051e5 --- /dev/null +++ b/packages/stripe-sync/src/syncableResources.ts @@ -0,0 +1,16 @@ +export const SYNCABLE_EVENT_PREFIXES = [ + "customer.subscription.", + "payment_intent.", + "invoice.", + "customer.", + "product.", + "price.", +] as const; + +export const isSyncableEvent = ({ + eventType, +}: { + eventType: string; +}): boolean => { + return SYNCABLE_EVENT_PREFIXES.some((prefix) => eventType.startsWith(prefix)); +}; diff --git a/packages/stripe-sync/tsconfig.json b/packages/stripe-sync/tsconfig.json new file mode 100644 index 000000000..6efa41163 --- /dev/null +++ b/packages/stripe-sync/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "strict": true, + "composite": true, + "declaration": true, + "declarationMap": true, + "esModuleInterop": true, + "skipLibCheck": true, + "module": "Preserve", + "moduleResolution": "bundler", + "target": "ES2020", + "outDir": "./dist", + "rootDir": "." + }, + "include": ["src/**/*"] +} diff --git a/server/experiments/explainGetFull.ts b/server/experiments/explainGetFull.ts index 50c7ef5a6..936733786 100644 --- a/server/experiments/explainGetFull.ts +++ b/server/experiments/explainGetFull.ts @@ -24,17 +24,18 @@ const main = async () => { const { db } = initDrizzle(); - const query = getFullCusQuery( - customerId, + const query = getFullCusQuery({ + idOrInternalId: customerId, orgId, env, - RELEVANT_STATUSES, - true, // includeInvoices - true, // withEntities - false, // withTrialsUsed - true, // withSubs - false, // withEvents - ); + inStatuses: RELEVANT_STATUSES, + includeInvoices: true, + withEntities: true, + withTrialsUsed: false, + withSubs: true, + withEvents: false, + cusProductLimit: 15, + }); // Run the actual query to measure wall-clock time console.log("--- Running query ---"); diff --git a/server/experiments/explainListCustomersV2.ts b/server/experiments/explainListCustomersV2.ts index b69e7bcc3..8655787ac 100644 --- a/server/experiments/explainListCustomersV2.ts +++ b/server/experiments/explainListCustomersV2.ts @@ -108,6 +108,7 @@ const main = async () => { limit: config.limit, offset: config.offset, search: config.search, + cusProductLimit: 15, }); const filteredCountQuery = getFilteredCountQuery({ orgId: config.orgId, diff --git a/server/package.json b/server/package.json index c44689d34..79e4e242b 100644 --- a/server/package.json +++ b/server/package.json @@ -46,6 +46,7 @@ "@anthropic-ai/sdk": "^0.32.1", "@autumn/ksuid": "workspace:*", "@autumn/shared": "workspace:*", + "@autumn/stripe-sync": "workspace:*", "@aws-sdk/client-s3": "^3.1017.0", "@aws-sdk/client-scheduler": "^3.1004.0", "@aws-sdk/client-sqs": "^3.958.0", diff --git a/server/register.ts b/server/register.ts index 3a3699a77..df0fc8eec 100644 --- a/server/register.ts +++ b/server/register.ts @@ -1,29 +1,23 @@ import "dotenv/config"; import { loadLocalEnv } from "./src/utils/envUtils"; import Stripe from "stripe"; +import { + MAIN_STRIPE_EVENT_TYPES, + SYNC_STRIPE_EVENT_TYPES, +} from "./src/external/stripe/common/stripeConstants"; loadLocalEnv(); +const allEventTypes = [ + ...new Set([...MAIN_STRIPE_EVENT_TYPES, ...SYNC_STRIPE_EVENT_TYPES]), +] as Stripe.WebhookEndpointCreateParams.EnabledEvent[]; + const main = async () => { const stripe = new Stripe(process.env.STRIPE_SANDBOX_SECRET_KEY || ""); - const result = await stripe.webhookEndpoints.create({ url: `${process.env.STRIPE_WEBHOOK_URL}/webhooks/connect/sandbox`, - enabled_events: [ - "checkout.session.completed", - "customer.subscription.created", - "customer.subscription.updated", - "customer.subscription.deleted", - "customer.discount.deleted", - "invoice.paid", - "invoice.upcoming", - "invoice.created", - "invoice.finalized", - "invoice.updated", - "subscription_schedule.canceled", - "subscription_schedule.updated", - ], + enabled_events: allEventTypes, connect: true, }); diff --git a/server/src/external/aws/s3/adminS3Config.ts b/server/src/external/aws/s3/adminS3Config.ts index f76678ab3..4508d138f 100644 --- a/server/src/external/aws/s3/adminS3Config.ts +++ b/server/src/external/aws/s3/adminS3Config.ts @@ -2,6 +2,7 @@ export const ADMIN_REQUEST_BLOCK_CONFIG_KEY = "admin/request-block-config.json"; export const ADMIN_FEATURE_FLAGS_CONFIG_KEY = "admin/feature-flags-config.json"; export const ADMIN_CUSTOMER_BLOCK_CONFIG_KEY = "admin/customer-block-config.json"; +export const ADMIN_ORG_LIMITS_CONFIG_KEY = "admin/org-limits-config.json"; type AdminS3Target = "dev" | "prod"; diff --git a/server/src/external/stripe/common/stripeConstants.ts b/server/src/external/stripe/common/stripeConstants.ts new file mode 100644 index 000000000..9a9bf261c --- /dev/null +++ b/server/src/external/stripe/common/stripeConstants.ts @@ -0,0 +1,64 @@ +import type Stripe from "stripe"; + +type StripeEventType = Stripe.WebhookEndpointCreateParams.EnabledEvent; + +/** Events Autumn actively handles in its webhook handler. */ +export const MAIN_STRIPE_EVENT_TYPES: StripeEventType[] = [ + "checkout.session.completed", + "customer.subscription.created", + "customer.subscription.updated", + "customer.subscription.deleted", + "customer.discount.deleted", + "invoice.paid", + "invoice.upcoming", + "invoice.created", + "invoice.finalized", + "invoice.updated", + "subscription_schedule.canceled", + "subscription_schedule.updated", +]; + +/** Additional events needed to keep the stripe-sync DB up to date. */ +export const SYNC_STRIPE_EVENT_TYPES: StripeEventType[] = [ + // customers + "customer.created", + "customer.updated", + "customer.deleted", + + // subscriptions (extras beyond main) + "customer.subscription.paused", + "customer.subscription.resumed", + + // subscription schedules (extras beyond main) + "subscription_schedule.created", + "subscription_schedule.completed", + "subscription_schedule.released", + + // payment methods + "payment_method.attached", + "payment_method.detached", + "payment_method.updated", + + // products + "product.created", + "product.updated", + "product.deleted", + + // prices + "price.created", + "price.updated", + "price.deleted", + + // invoices (extras beyond main) + "invoice.deleted", + "invoice.payment_failed", + "invoice.payment_succeeded", + "invoice.voided", + "invoice.marked_uncollectible", + + // payment intents + "payment_intent.created", + "payment_intent.succeeded", + "payment_intent.payment_failed", + "payment_intent.canceled", +]; diff --git a/server/src/external/stripe/stripeOnboardingUtils.ts b/server/src/external/stripe/stripeOnboardingUtils.ts index 70e8b6f4e..5f6f1d43d 100644 --- a/server/src/external/stripe/stripeOnboardingUtils.ts +++ b/server/src/external/stripe/stripeOnboardingUtils.ts @@ -1,6 +1,10 @@ import { type AppEnv, ErrCode } from "@autumn/shared"; import Stripe from "stripe"; import RecaseError from "@/utils/errorUtils.js"; +import { + MAIN_STRIPE_EVENT_TYPES, + SYNC_STRIPE_EVENT_TYPES, +} from "./common/stripeConstants"; export const checkKeyValid = async (apiKey: string) => { const stripe = new Stripe(apiKey); @@ -29,20 +33,22 @@ export const createWebhookEndpoint = async ( const endpoint = await stripe.webhookEndpoints.create({ url: `${webhookBaseUrl}/webhooks/stripe/${orgId}/${env}`, - enabled_events: [ - "customer.subscription.created", - "customer.subscription.updated", - "customer.subscription.deleted", - "checkout.session.completed", - "invoice.paid", - "invoice.upcoming", - "invoice.created", - "invoice.finalized", - "invoice.updated", - "subscription_schedule.canceled", - "subscription_schedule.updated", - "customer.discount.deleted", - ], + enabled_events: [...MAIN_STRIPE_EVENT_TYPES, ...SYNC_STRIPE_EVENT_TYPES], + + // [ + // "customer.subscription.created", + // "customer.subscription.updated", + // "customer.subscription.deleted", + // "checkout.session.completed", + // "invoice.paid", + // "invoice.upcoming", + // "invoice.created", + // "invoice.finalized", + // "invoice.updated", + // "subscription_schedule.canceled", + // "subscription_schedule.updated", + // "customer.discount.deleted", + // ], }); return endpoint; diff --git a/server/src/external/stripe/stripeWebhookRouter.ts b/server/src/external/stripe/stripeWebhookRouter.ts index f1cbe08ac..9a07d41ce 100644 --- a/server/src/external/stripe/stripeWebhookRouter.ts +++ b/server/src/external/stripe/stripeWebhookRouter.ts @@ -4,6 +4,7 @@ import { handleStripeWebhookEvent } from "./handleStripeWebhookEvent.js"; import { stripeConnectSeederMiddleware } from "./webhookMiddlewares/stripeConnectSeederMiddleware.js"; import { stripeIdempotencyMiddleware } from "./webhookMiddlewares/stripeIdempotencyMiddleware.js"; import { stripeLegacySeederMiddleware } from "./webhookMiddlewares/stripeLegacySeederMiddleware.js"; +import { stripeSyncMiddleware } from "./webhookMiddlewares/stripeSyncMiddleware.js"; import { stripeToAutumnCustomerMiddleware } from "./webhookMiddlewares/stripeToAutumnCustomerMiddleware.js"; import type { StripeWebhookHonoEnv } from "./webhookMiddlewares/stripeWebhookContext.js"; import { stripeWebhookRefreshMiddleware } from "./webhookMiddlewares/stripeWebhookRefreshMiddleware.js"; @@ -15,6 +16,7 @@ stripeWebhookRouter.post( "/webhooks/stripe/:orgId/:env", stripeLegacySeederMiddleware, stripeWebhookRefreshMiddleware, + stripeSyncMiddleware, stripeToAutumnCustomerMiddleware, stripeLoggerMiddleware, stripeIdempotencyMiddleware, @@ -26,6 +28,7 @@ stripeWebhookRouter.post( "/webhooks/connect/:env", stripeConnectSeederMiddleware, stripeWebhookRefreshMiddleware, + stripeSyncMiddleware, stripeToAutumnCustomerMiddleware, stripeLoggerMiddleware, stripeIdempotencyMiddleware, diff --git a/server/src/external/stripe/webhookMiddlewares/stripeSyncMiddleware.ts b/server/src/external/stripe/webhookMiddlewares/stripeSyncMiddleware.ts new file mode 100644 index 000000000..410de04ca --- /dev/null +++ b/server/src/external/stripe/webhookMiddlewares/stripeSyncMiddleware.ts @@ -0,0 +1,54 @@ +import { isSyncableEvent, processStripeSyncEvent } from "@autumn/stripe-sync"; +import type { Context, Next } from "hono"; +import { isStripeSyncEnabled } from "@/internal/misc/stripeSync/stripeSyncStore.js"; +import type { + StripeWebhookContext, + StripeWebhookHonoEnv, +} from "./stripeWebhookContext.js"; + +/** + * Post-handler middleware that syncs Stripe events to the sync DB. + * Fire-and-forget -- errors are caught and logged, never propagated. + */ +export const stripeSyncMiddleware = async ( + c: Context, + next: Next, +) => { + await next(); + + const ctx = c.get("ctx") as StripeWebhookContext; + const { logger, org, stripeEvent } = ctx; + + if (!org || !stripeEvent) return; + if ( + process.env.NODE_ENV === "production" && + !isStripeSyncEnabled({ orgId: org.id, orgSlug: org.slug }) + ) + return; + + if (!isSyncableEvent({ eventType: stripeEvent.type })) return; + + try { + const stripeAccountId = stripeEvent.account ?? undefined; + + void processStripeSyncEvent({ + event: stripeEvent, + stripeAccountId, + orgId: org.id, + env: ctx.env, + }).catch((error) => { + logger.error(`Stripe sync failed for event ${stripeEvent.id}: ${error}`, { + error: { + message: error instanceof Error ? error.message : String(error), + }, + data: { + eventId: stripeEvent.id, + eventType: stripeEvent.type, + orgId: org.id, + }, + }); + }); + } catch (error) { + logger.error(`Stripe sync middleware error: ${error}`); + } +}; diff --git a/server/src/init.ts b/server/src/init.ts index 57dd13765..eaab11b4c 100644 --- a/server/src/init.ts +++ b/server/src/init.ts @@ -20,6 +20,9 @@ import { import "./internal/misc/requestBlocks/requestBlockStore.js"; import "./internal/misc/featureFlags/featureFlagStore.js"; import "./internal/misc/customerBlocks/customerBlockStore.js"; +import "./internal/misc/edgeConfig/orgLimitsStore.js"; +import "./internal/misc/stripeSync/stripeSyncStore.js"; +import { closeStripeSyncEngine } from "@autumn/stripe-sync"; import { warmupRegionalRedis } from "./external/redis/initRedis.js"; import { createHonoApp } from "./initHono.js"; import { otelSdk } from "./instrumentation.js"; @@ -117,6 +120,7 @@ async function gracefulShutdown() { client.end(), clientCritical.end(), clientReplica?.end(), + closeStripeSyncEngine(), ]); console.log("Shutdown complete. Exiting process."); process.exit(0); diff --git a/server/src/internal/admin/adminRouter.ts b/server/src/internal/admin/adminRouter.ts index 2ce769eca..683ec1ee9 100644 --- a/server/src/internal/admin/adminRouter.ts +++ b/server/src/internal/admin/adminRouter.ts @@ -2,8 +2,11 @@ import { Hono } from "hono"; import type { HonoEnv } from "../../honoUtils/HonoEnv"; import { handleGetAdminCustomerBlockConfig } from "./handleGetAdminCustomerBlockConfig"; import { handleGetAdminFeatureFlagsConfig } from "./handleGetAdminFeatureFlagsConfig"; +import { handleGetAdminOrgLimitsConfig } from "./handleGetAdminOrgLimitsConfig"; import { handleGetAdminOrgRequestBlock } from "./handleGetAdminOrgRequestBlock"; import { handleGetAdminRequestBlockConfig } from "./handleGetAdminRequestBlockConfig"; +import { handleGetAdminStripeSyncConfig } from "./handleGetAdminStripeSyncConfig"; + import { handleGetInvoiceLineItems } from "./handleGetInvoiceLineItems"; import { handleGetMasterStripeAccount } from "./handleGetMasterStripeAccount"; import { handleGetOrgMember } from "./handleGetOrgMember"; @@ -12,8 +15,10 @@ import { handleListAdminUsers } from "./handleListAdminUsers"; import { handleListOAuthClients } from "./handleListOAuthClients"; import { handleUpsertAdminCustomerBlockConfig } from "./handleUpsertAdminCustomerBlockConfig"; import { handleUpsertAdminFeatureFlagsConfig } from "./handleUpsertAdminFeatureFlagsConfig"; +import { handleUpsertAdminOrgLimitsConfig } from "./handleUpsertAdminOrgLimitsConfig"; import { handleUpsertAdminOrgRequestBlock } from "./handleUpsertAdminOrgRequestBlock"; import { handleUpsertAdminRequestBlockConfig } from "./handleUpsertAdminRequestBlockConfig"; +import { handleUpsertAdminStripeSyncConfig } from "./handleUpsertAdminStripeSyncConfig"; export const honoAdminRouter = new Hono(); @@ -51,6 +56,13 @@ honoAdminRouter.put( "/customer-block-config", ...handleUpsertAdminCustomerBlockConfig, ); +honoAdminRouter.get("/org-limits-config", ...handleGetAdminOrgLimitsConfig); +honoAdminRouter.put("/org-limits-config", ...handleUpsertAdminOrgLimitsConfig); +honoAdminRouter.get("/stripe-sync-config", ...handleGetAdminStripeSyncConfig); +honoAdminRouter.put( + "/stripe-sync-config", + ...handleUpsertAdminStripeSyncConfig, +); honoAdminRouter.get("/org-member", ...handleGetOrgMember); honoAdminRouter.get("/master-stripe-account", ...handleGetMasterStripeAccount); honoAdminRouter.get("/oauth-clients", ...handleListOAuthClients); diff --git a/server/src/internal/admin/handleGetAdminOrgLimitsConfig.ts b/server/src/internal/admin/handleGetAdminOrgLimitsConfig.ts new file mode 100644 index 000000000..e7a980a1f --- /dev/null +++ b/server/src/internal/admin/handleGetAdminOrgLimitsConfig.ts @@ -0,0 +1,20 @@ +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { + getOrgLimitsConfigFromSource, + getRuntimeOrgLimitsStatus, +} from "@/internal/misc/edgeConfig/orgLimitsStore.js"; + +export const handleGetAdminOrgLimitsConfig = createRoute({ + handler: async (c) => { + const status = getRuntimeOrgLimitsStatus(); + const config = await getOrgLimitsConfigFromSource(); + + return c.json({ + ...config, + configHealthy: status.healthy, + configConfigured: status.configured, + lastSuccessAt: status.lastSuccessAt ?? null, + error: status.error ?? null, + }); + }, +}); diff --git a/server/src/internal/admin/handleGetAdminStripeSyncConfig.ts b/server/src/internal/admin/handleGetAdminStripeSyncConfig.ts new file mode 100644 index 000000000..d7c08ac91 --- /dev/null +++ b/server/src/internal/admin/handleGetAdminStripeSyncConfig.ts @@ -0,0 +1,20 @@ +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { + getRuntimeStripeSyncStatus, + getStripeSyncConfigFromSource, +} from "@/internal/misc/stripeSync/stripeSyncStore.js"; + +export const handleGetAdminStripeSyncConfig = createRoute({ + handler: async (c) => { + const status = getRuntimeStripeSyncStatus(); + const config = await getStripeSyncConfigFromSource(); + + return c.json({ + ...config, + configHealthy: status.healthy, + configConfigured: status.configured, + lastSuccessAt: status.lastSuccessAt ?? null, + error: status.error ?? null, + }); + }, +}); diff --git a/server/src/internal/admin/handleUpsertAdminOrgLimitsConfig.ts b/server/src/internal/admin/handleUpsertAdminOrgLimitsConfig.ts new file mode 100644 index 000000000..2c6264e36 --- /dev/null +++ b/server/src/internal/admin/handleUpsertAdminOrgLimitsConfig.ts @@ -0,0 +1,14 @@ +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { OrgLimitsConfigSchema } from "@/internal/misc/edgeConfig/orgLimitsSchemas.js"; +import { updateFullOrgLimitsConfig } from "@/internal/misc/edgeConfig/orgLimitsStore.js"; + +export const handleUpsertAdminOrgLimitsConfig = createRoute({ + body: OrgLimitsConfigSchema, + handler: async (c) => { + const body = c.req.valid("json"); + + await updateFullOrgLimitsConfig({ config: body }); + + return c.json({ success: true }); + }, +}); diff --git a/server/src/internal/admin/handleUpsertAdminStripeSyncConfig.ts b/server/src/internal/admin/handleUpsertAdminStripeSyncConfig.ts new file mode 100644 index 000000000..b2425aea1 --- /dev/null +++ b/server/src/internal/admin/handleUpsertAdminStripeSyncConfig.ts @@ -0,0 +1,14 @@ +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { StripeSyncConfigSchema } from "@/internal/misc/stripeSync/stripeSyncSchemas.js"; +import { updateFullStripeSyncConfig } from "@/internal/misc/stripeSync/stripeSyncStore.js"; + +export const handleUpsertAdminStripeSyncConfig = createRoute({ + body: StripeSyncConfigSchema, + handler: async (c) => { + const body = c.req.valid("json"); + + await updateFullStripeSyncConfig({ config: body }); + + return c.json({ success: true }); + }, +}); diff --git a/server/src/internal/customers/CusBatchService.ts b/server/src/internal/customers/CusBatchService.ts index 1b2457496..d9a449386 100644 --- a/server/src/internal/customers/CusBatchService.ts +++ b/server/src/internal/customers/CusBatchService.ts @@ -11,6 +11,7 @@ import { } from "@autumn/shared"; import * as Sentry from "@sentry/bun"; import type { AutumnContext, RequestContext } from "@/honoUtils/HonoEnv.js"; +import { getOrgCusProductLimit } from "../misc/edgeConfig/orgLimitsStore.js"; import { triggerBatchResetCustomerEntitlements } from "./actions/resetCustomerEntitlements/triggerBatchResetCustomerEntitlements.js"; import { getApiCustomerBase } from "./cusUtils/apiCusUtils/getApiCustomerBase.js"; import { getPaginatedFullCusQuery } from "./getFullCusQuery.js"; @@ -24,6 +25,11 @@ export class CusBatchService { internalCustomerIds: string[]; }) { const { org, env, db } = ctx; + const cusProductLimit = getOrgCusProductLimit({ + orgId: ctx.org.id, + orgSlug: ctx.org.slug, + }); + const query = getPaginatedFullCusQuery({ orgId: ctx.org.id, env: ctx.env, @@ -34,6 +40,7 @@ export class CusBatchService { limit: internalCustomerIds.length || 100, offset: 0, internalCustomerIds, + cusProductLimit, }); const results = await db.execute(query); const fullCustomers = results as unknown as FullCustomer[]; @@ -66,6 +73,10 @@ export class CusBatchService { const { limit, offset, plans, subscription_status, search } = query; + const cusProductLimit = getOrgCusProductLimit({ + orgId: ctx.org.id, + orgSlug: ctx.org.slug, + }); const sqlQuery = getPaginatedFullCusQuery({ orgId: ctx.org.id, env: ctx.env, @@ -80,6 +91,7 @@ export class CusBatchService { offset, search, plans, + cusProductLimit, }); const results = await ctx.db.execute(sqlQuery); const finals = []; diff --git a/server/src/internal/customers/CusSearchService.ts b/server/src/internal/customers/CusSearchService.ts index 5b0231984..c6439cd16 100644 --- a/server/src/internal/customers/CusSearchService.ts +++ b/server/src/internal/customers/CusSearchService.ts @@ -8,6 +8,7 @@ import { import { and, + asc, desc, eq, gt, @@ -21,6 +22,7 @@ import { } from "drizzle-orm"; import { alias } from "drizzle-orm/pg-core"; import type { DrizzleCli } from "@/db/initDrizzle.js"; +import { getOrgCusProductLimit } from "../misc/edgeConfig/orgLimitsStore.js"; // Create alias for subquery const customerProductsAlias = alias(customerProducts, "cp_alias"); @@ -47,6 +49,7 @@ const productFields = { id: products.id, name: products.name, version: products.version, + is_add_on: products.is_add_on, }; interface SearchFilters { @@ -60,25 +63,21 @@ export class CusSearchService { static async searchByProduct({ db, orgId, + orgSlug, env, search, filters, pageSize = 50, pageNumber, - // lastItem, }: { db: DrizzleCli; orgId: string; + orgSlug?: string; env: AppEnv; search: string; filters: SearchFilters; pageSize?: number; pageNumber: number; - // lastItem?: { - // internal_id: string; - // created_at?: string; - // name?: string; - // } | null; }) { // If we have a lastItem with only internal_id, fetch the full customer data for cursor pagination // let resolvedLastItem = lastItem; @@ -304,14 +303,14 @@ export class CusSearchService { const offset = (pageNumber - 1) * pageSize; productQueryResult = buildQuery() .where(whereClause) - .orderBy(desc(customers.internal_id)) + .orderBy(desc(customers.internal_id), asc(products.is_add_on)) .offset(offset) .limit(pageSize); } else { // Use cursor-based pagination productQueryResult = buildQuery() .where(whereClause) - .orderBy(desc(customers.internal_id)) + .orderBy(desc(customers.internal_id), asc(products.is_add_on)) .limit(pageSize); } @@ -375,7 +374,14 @@ export class CusSearchService { } } + const cusProductLimit = getOrgCusProductLimit({ orgId, orgSlug }); const processedData = Array.from(customerMap.values()); + for (const customer of processedData) { + customer.customer_products = customer.customer_products.slice( + 0, + cusProductLimit, + ); + } const totalCount = totalCountResult[0]?.totalCount || 0; @@ -495,9 +501,7 @@ export class CusSearchService { return sql`(${customers.processors}->>'vercel' IS NOT NULL)`; return undefined; }) - .filter( - (c): c is NonNullable => c !== undefined, - ), + .filter((c): c is NonNullable => c !== undefined), ) : undefined, ), @@ -510,6 +514,7 @@ export class CusSearchService { static async search({ db, orgId, + orgSlug, env, search, pageSize = 50, @@ -519,6 +524,7 @@ export class CusSearchService { }: { db: DrizzleCli; orgId: string; + orgSlug?: string; env: AppEnv; search: string; lastItem?: { @@ -570,7 +576,6 @@ export class CusSearchService { search, filters, pageSize, - // lastItem: resolvedLastItem, pageNumber, }); } @@ -583,11 +588,11 @@ export class CusSearchService { return await CusSearchService.searchByProduct({ db, orgId, + orgSlug, env, search, filters, pageSize, - // lastItem: resolvedLastItem, pageNumber, }); } @@ -685,7 +690,7 @@ export class CusSearchService { products, eq(customerProducts.internal_product_id, products.internal_id), ) - .orderBy(desc(baseQuery.internal_id)), + .orderBy(desc(baseQuery.internal_id), asc(products.is_add_on)), totalCountQuery, ]); @@ -718,7 +723,14 @@ export class CusSearchService { } } + const cusProductLimit = getOrgCusProductLimit({ orgId, orgSlug }); const finalResults = Array.from(customerMap.values()); + for (const customer of finalResults) { + customer.customer_products = customer.customer_products.slice( + 0, + cusProductLimit, + ); + } return { data: finalResults, count: totalCount }; } diff --git a/server/src/internal/customers/CusService.ts b/server/src/internal/customers/CusService.ts index 9ed9a3771..51d9b1428 100644 --- a/server/src/internal/customers/CusService.ts +++ b/server/src/internal/customers/CusService.ts @@ -6,6 +6,7 @@ import { CustomerNotFoundError, customerProducts, customers, + type Entity, type EntityExpand, ErrCode, type FullCusProduct, @@ -33,6 +34,7 @@ import { executeWithHealthTracking } from "@/db/pgHealthMonitor.js"; import type { RepoContext } from "@/db/repoContext.js"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; import { withSpan } from "../analytics/tracer/spanUtils.js"; +import { getOrgCusProductLimit } from "../misc/edgeConfig/orgLimitsStore.js"; import { resetCustomerEntitlements } from "./actions/resetCustomerEntitlements/resetCustomerEntitlements.js"; import { ACTIVE_STATUSES, @@ -86,7 +88,11 @@ export class CusService { withSubs, }, fn: async () => { - const query = getFullCusQuery( + const cusProductLimit = getOrgCusProductLimit({ + orgId, + orgSlug: org.slug, + }); + const query = getFullCusQuery({ idOrInternalId, orgId, env, @@ -97,7 +103,8 @@ export class CusService { withSubs, withEvents, entityId, - ); + cusProductLimit, + }); if (explain) { const explainQuery = sql`EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) ${query}`; @@ -144,10 +151,18 @@ export class CusService { .slice(0, 5); } - // Skip reset when reading from replica — it writes to primary, - // and replica data is stale anyway. When degraded WITHOUT a replica - // (falls back to primary), the reset should still run. + if (orgId === "GG6tnmO7cHb40PNhwYBTZtxQdeL74NHF") { + fullCus.customer_products = ( + fullCus.customer_products as FullCusProduct[] + ) + .sort((a, b) => b.customer_prices.length - a.customer_prices.length) + .slice(0, 5); + + fullCus.entities = (fullCus.entities as Entity[]).slice(0, 50); + } if (!usedReplica && !skipReset) { + // Skip reset only when executeWithHealthTracking explicitly chose the + // replica. Lazy reset writes themselves go through dbGeneral. await resetCustomerEntitlements({ fullCus, ctx, @@ -318,22 +333,21 @@ export class CusService { ? [query.subscription_status as CusProductStatus] : ACTIVE_STATUSES; - const processorFilter = - query?.processors?.length - ? or( - ...query.processors - .map((proc) => { - if (proc === "stripe") - return sql`(${customers.processor}->>'id' IS NOT NULL)`; - if (proc === "revenuecat") - return sql`(${customers.processors}->>'revenuecat' IS NOT NULL)`; - if (proc === "vercel") - return sql`(${customers.processors}->>'vercel' IS NOT NULL)`; - return undefined; - }) - .filter((c): c is NonNullable => c !== undefined), - ) - : undefined; + const processorFilter = query?.processors?.length + ? or( + ...query.processors + .map((proc) => { + if (proc === "stripe") + return sql`(${customers.processor}->>'id' IS NOT NULL)`; + if (proc === "revenuecat") + return sql`(${customers.processors}->>'revenuecat' IS NOT NULL)`; + if (proc === "vercel") + return sql`(${customers.processors}->>'vercel' IS NOT NULL)`; + return undefined; + }) + .filter((c): c is NonNullable => c !== undefined), + ) + : undefined; if (!query?.plans?.length) { const [result] = await ctx.db diff --git a/server/src/internal/customers/cancel/handleCancelV2.ts b/server/src/internal/customers/cancel/handleCancelV2.ts index 930368e3f..61427da8b 100644 --- a/server/src/internal/customers/cancel/handleCancelV2.ts +++ b/server/src/internal/customers/cancel/handleCancelV2.ts @@ -8,10 +8,27 @@ import { executeBillingPlan } from "@/internal/billing/v2/execute/executeBilling import { evaluateStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan"; import { logStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/logs/logStripeBillingPlan"; import { logStripeBillingResult } from "@/internal/billing/v2/providers/stripe/logs/logStripeBillingResult"; +import { buildBillingLockKey } from "@/internal/billing/v2/utils/billingLock/buildBillingLockKey"; import { logAutumnBillingPlan } from "@/internal/billing/v2/utils/logs/logAutumnBillingPlan"; export const handleCancelV2 = createRoute({ - // body: CancelBodySchema, + lock: + process.env.NODE_ENV !== "development" + ? { + ttlMs: 120000, + errorMessage: + "Cancel already in progress for this customer, try again in a few seconds", + getKey: (c) => { + const ctx = c.get("ctx"); + if (!ctx.customerId) return null; + return buildBillingLockKey({ + orgId: ctx.org.id, + env: ctx.env, + customerId: ctx.customerId, + }); + }, + } + : undefined, handler: async (c) => { const ctx = c.get("ctx"); diff --git a/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getCachedFullCustomer.ts b/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getCachedFullCustomer.ts index 7e64122a7..dddd7e513 100644 --- a/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getCachedFullCustomer.ts +++ b/server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getCachedFullCustomer.ts @@ -149,6 +149,10 @@ export const getCachedFullCustomer = async ({ data: JSON.parse(cached), }); + // if (fullCustomer.entities?.[0] === null) { + // fullCustomer.entities = fullCustomer.entities?.filter((e) => e !== null); + // } + if (entityId) { fullCustomer.entity = fullCustomer.entities?.find((e) => e.id === entityId); } else { diff --git a/server/src/internal/customers/getFullCusQuery.ts b/server/src/internal/customers/getFullCusQuery.ts index 3873ae4ff..8e257fcb7 100644 --- a/server/src/internal/customers/getFullCusQuery.ts +++ b/server/src/internal/customers/getFullCusQuery.ts @@ -6,7 +6,13 @@ import { } from "@autumn/shared"; import { type SQL, sql } from "drizzle-orm"; -const buildOptimizedCusProductsCTE = (inStatuses?: CusProductStatus[]) => { +const buildOptimizedCusProductsCTE = ({ + inStatuses, + cusProductLimit, +}: { + inStatuses?: CusProductStatus[]; + cusProductLimit: number; +}) => { const withStatusFilter = () => { return inStatuses ? sql`AND cp.status = ANY(ARRAY[${sql.join( @@ -86,8 +92,8 @@ const buildOptimizedCusProductsCTE = (inStatuses?: CusProductStatus[]) => { ) ft_data ON true WHERE cp.internal_customer_id = (SELECT internal_id FROM customer_record) ${withStatusFilter()} - ORDER BY ${relevantStatusFirst}, cp.created_at DESC - LIMIT 15 + ORDER BY ${relevantStatusFirst}, prod.is_add_on ASC, cp.created_at DESC + LIMIT ${cusProductLimit} ) `; }; @@ -101,7 +107,7 @@ const buildEntitiesCTE = (withEntities: boolean) => { customer_entities AS ( SELECT COALESCE( - json_agg(row_to_json(e) ORDER BY e.internal_id DESC), + json_agg(row_to_json(e) ORDER BY e.internal_id DESC) FILTER (WHERE e.internal_id IS NOT NULL), '[]'::json ) AS entities FROM ( @@ -266,18 +272,31 @@ const buildInvoicesCTE = (hasEntityCTE: boolean) => { `; }; -export const getFullCusQuery = ( - idOrInternalId: string, - orgId: string, - env: AppEnv, - inStatuses: CusProductStatus[], - includeInvoices: boolean, - withEntities: boolean, - withTrialsUsed: boolean, - withSubs: boolean, - withEvents: boolean, - entityId?: string, -) => { +export const getFullCusQuery = ({ + idOrInternalId, + orgId, + env, + inStatuses, + includeInvoices, + withEntities, + withTrialsUsed, + withSubs, + withEvents, + entityId, + cusProductLimit, +}: { + idOrInternalId: string; + orgId: string; + env: AppEnv; + inStatuses: CusProductStatus[]; + includeInvoices: boolean; + withEntities: boolean; + withTrialsUsed: boolean; + withSubs: boolean; + withEvents: boolean; + entityId?: string; + cusProductLimit: number; +}) => { const sqlChunks: SQL[] = []; // Step 1: Get customer record @@ -309,7 +328,7 @@ export const getFullCusQuery = ( // Add customer products CTE sqlChunks.push(sql`, `); // sqlChunks.push(buildCusProductsCTE(inStatuses)); - sqlChunks.push(buildOptimizedCusProductsCTE(inStatuses)); + sqlChunks.push(buildOptimizedCusProductsCTE({ inStatuses, cusProductLimit })); // Conditionally add trials used CTE if (withTrialsUsed) { @@ -429,6 +448,7 @@ export const getPaginatedFullCusQuery = ({ internalCustomerIds, plans, search, + cusProductLimit, }: { orgId: string; env: AppEnv; @@ -444,6 +464,7 @@ export const getPaginatedFullCusQuery = ({ internalCustomerIds?: string[]; plans?: ListCustomersV2Params["plans"]; search?: string; + cusProductLimit: number; }) => { const withStatusFilter = () => { return inStatuses?.length @@ -539,8 +560,8 @@ export const getPaginatedFullCusQuery = ({ FROM customer_products cp WHERE cp.internal_customer_id = cr.internal_id ${withStatusFilter()} - ORDER BY cp.created_at DESC - LIMIT 15 + ORDER BY (SELECT p.is_add_on FROM products p WHERE p.internal_id = cp.internal_product_id) ASC, cp.created_at DESC + LIMIT ${cusProductLimit} ) cp ON true JOIN products prod ON cp.internal_product_id = prod.internal_id LEFT JOIN LATERAL ( @@ -659,7 +680,7 @@ export const getPaginatedFullCusQuery = ({ SELECT cr.internal_id AS internal_customer_id, COALESCE( - json_agg(row_to_json(e) ORDER BY e.internal_id DESC), + json_agg(row_to_json(e) ORDER BY e.internal_id DESC) FILTER (WHERE e.internal_id IS NOT NULL), '[]'::json ) AS entities FROM customer_records cr diff --git a/server/src/internal/customers/internalHandlers/handleSearchCustomers.ts b/server/src/internal/customers/internalHandlers/handleSearchCustomers.ts index 7a1281982..21daa5f8a 100644 --- a/server/src/internal/customers/internalHandlers/handleSearchCustomers.ts +++ b/server/src/internal/customers/internalHandlers/handleSearchCustomers.ts @@ -24,6 +24,7 @@ export const handleSearchCustomers = createRoute({ const { data: customers, count } = await CusSearchService.search({ db, orgId: org.id, + orgSlug: org.slug, env, search: search ?? "", filters, diff --git a/server/src/internal/misc/edgeConfig/orgLimitsSchemas.ts b/server/src/internal/misc/edgeConfig/orgLimitsSchemas.ts new file mode 100644 index 000000000..d69ab51a8 --- /dev/null +++ b/server/src/internal/misc/edgeConfig/orgLimitsSchemas.ts @@ -0,0 +1,14 @@ +import { z } from "zod/v4"; + +export const OrgLimitsConfigSchema = z.object({ + orgs: z + .record( + z.string(), + z.object({ + maxCusProducts: z.number().min(1).optional(), + }), + ) + .default({}), +}); + +export type OrgLimitsConfig = z.infer; diff --git a/server/src/internal/misc/edgeConfig/orgLimitsStore.ts b/server/src/internal/misc/edgeConfig/orgLimitsStore.ts new file mode 100644 index 000000000..399a90d8a --- /dev/null +++ b/server/src/internal/misc/edgeConfig/orgLimitsStore.ts @@ -0,0 +1,65 @@ +import { ADMIN_ORG_LIMITS_CONFIG_KEY } from "@/external/aws/s3/adminS3Config.js"; +import { registerEdgeConfig } from "@/internal/misc/edgeConfig/edgeConfigRegistry.js"; +import { createEdgeConfigStore } from "@/internal/misc/edgeConfig/edgeConfigStore.js"; +import { + type OrgLimitsConfig, + OrgLimitsConfigSchema, +} from "./orgLimitsSchemas.js"; + +export const DEFAULT_CUS_PRODUCT_LIMIT = 15; + +const store = createEdgeConfigStore({ + s3Key: ADMIN_ORG_LIMITS_CONFIG_KEY, + schema: OrgLimitsConfigSchema, + defaultValue: () => ({ orgs: {} }), + pollIntervalMs: 30_000, +}); + +registerEdgeConfig({ store }); + +export const getRuntimeOrgLimitsStatus = () => store.getStatus(); + +export const getOrgCusProductLimit = ({ + orgId, + orgSlug, +}: { + orgId?: string; + orgSlug?: string; +}): number => { + const orgs = store.get().orgs; + const orgConfig = + (orgId ? orgs[orgId] : undefined) ?? + (orgSlug ? orgs[orgSlug] : undefined); + return orgConfig?.maxCusProducts ?? DEFAULT_CUS_PRODUCT_LIMIT; +}; + +export const getOrgLimitsConfigFromSource = async () => { + return await store.readFromSource(); +}; + +export const updateOrgLimitsInSource = async ({ + orgId, + maxCusProducts, +}: { + orgId: string; + maxCusProducts?: number; +}) => { + const config = await store.readFromSource(); + + if (maxCusProducts === undefined) { + delete config.orgs[orgId]; + } else { + config.orgs[orgId] = { maxCusProducts }; + } + + await store.writeToSource({ config }); + return config.orgs[orgId]; +}; + +export const updateFullOrgLimitsConfig = async ({ + config, +}: { + config: OrgLimitsConfig; +}) => { + await store.writeToSource({ config }); +}; diff --git a/server/src/internal/misc/stripeSync/stripeSyncSchemas.ts b/server/src/internal/misc/stripeSync/stripeSyncSchemas.ts new file mode 100644 index 000000000..1a91f653f --- /dev/null +++ b/server/src/internal/misc/stripeSync/stripeSyncSchemas.ts @@ -0,0 +1,7 @@ +import { z } from "zod/v4"; + +export const StripeSyncConfigSchema = z.object({ + enabledOrgIds: z.array(z.string()).default([]), +}); + +export type StripeSyncConfig = z.infer; diff --git a/server/src/internal/misc/stripeSync/stripeSyncStore.ts b/server/src/internal/misc/stripeSync/stripeSyncStore.ts new file mode 100644 index 000000000..21a9f0ab6 --- /dev/null +++ b/server/src/internal/misc/stripeSync/stripeSyncStore.ts @@ -0,0 +1,39 @@ +import { registerEdgeConfig } from "@/internal/misc/edgeConfig/edgeConfigRegistry.js"; +import { createEdgeConfigStore } from "@/internal/misc/edgeConfig/edgeConfigStore.js"; +import { + type StripeSyncConfig, + StripeSyncConfigSchema, +} from "./stripeSyncSchemas.js"; + +const store = createEdgeConfigStore({ + s3Key: "admin/stripe-sync-config.json", + schema: StripeSyncConfigSchema, + defaultValue: () => ({ enabledOrgIds: [] }), + pollIntervalMs: 60_000, +}); + +registerEdgeConfig({ store }); + +/** Pure in-memory lookup -- zero I/O, sync. Matches on org ID or slug. */ +export const isStripeSyncEnabled = ({ + orgId, + orgSlug, +}: { + orgId: string; + orgSlug?: string; +}): boolean => { + const enabled = store.get().enabledOrgIds; + return enabled.includes(orgId) || (!!orgSlug && enabled.includes(orgSlug)); +}; + +export const getRuntimeStripeSyncStatus = () => store.getStatus(); + +export const getStripeSyncConfigFromSource = async () => store.readFromSource(); + +export const updateFullStripeSyncConfig = async ({ + config, +}: { + config: StripeSyncConfig; +}) => { + await store.writeToSource({ config }); +}; diff --git a/server/tests/_temp/temp2.test.ts b/server/tests/_temp/temp2.test.ts index e846fc0c9..edced424c 100644 --- a/server/tests/_temp/temp2.test.ts +++ b/server/tests/_temp/temp2.test.ts @@ -1,67 +1,106 @@ -import { expect, test } from "bun:test"; +import { test } from "bun:test"; +import type { AttachParamsV1Input } from "@autumn/shared"; +import { + applySubscriptionDiscount, + createPercentCoupon, + getStripeSubscription, +} from "@tests/integration/billing/utils/discounts/discountTestUtils"; +import { TestFeature } from "@tests/setup/v2Features.js"; import { items } from "@tests/utils/fixtures/items"; import { products } from "@tests/utils/fixtures/products"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; import chalk from "chalk"; -import { CusService } from "@/internal/customers/CusService"; -test(`${chalk.yellowBright("check where default payment method lives after attach")}`, async () => { - const customerId = "pm-location-check"; +/** + * Repro for Mintlify multi-entity discount bug. + * + * Production sequence (from Axiom logs): + * 1. Entity 1 attaches pro (upgrade from Hobby) — creates subscription + * 2. Discount applied to subscription + * 3. Entity 1 cancels pro (end_of_cycle) — creates subscription schedule + * 4. Entity 1 uncancels pro — schedule modified + * 5. Entity 2 attaches pro — subscription update succeeds but schedule + * creation/update fails: "Discount di_xxx has exceeded its maximum + * number of applications and cannot be reused" + * + * The error is in stripeDiscountsToPhaseDiscounts which passes + * { discount: "di_xxx" } to schedule phases — but the discount is bound + * to the subscription, not the schedule. + */ +test(`${chalk.yellowBright("bug repro: entity 2 attach after cancel+uncancel with discount on sub")}`, async () => { + const customerId = "multi-ent-cancel-uncancel"; const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ - id: "pro-pm-check", + id: "pro", items: [messagesItem], }); - const { ctx } = await initScenario({ + // Step 1: Entity 1 attaches pro + const { autumnV2_2, autumnV1, entities } = await initScenario({ customerId, setup: [ s.customer({ paymentMethod: "success" }), s.products({ list: [pro] }), + s.entities({ count: 2, featureId: TestFeature.Users }), + ], + actions: [ + s.billing.attach({ productId: pro.id, entityIndex: 0 }), ], - actions: [s.attach({ productId: pro.id })], }); - const fullCustomer = await CusService.getFull({ - ctx, - idOrInternalId: customerId, + // Step 2: Apply a discount to entity 1's subscription + const { stripeCli, subscription } = await getStripeSubscription({ + customerId, }); - const stripeCustomerId = fullCustomer.processor?.id; - expect(stripeCustomerId).toBeDefined(); - - const subs = await ctx.stripeCli.subscriptions.list({ - customer: stripeCustomerId!, - status: "all", + const coupon = await createPercentCoupon({ + stripeCli, + percentOff: 10, }); - const subscription = subs.data.find( - (sub) => sub.status === "active" || sub.status === "trialing", - ); - expect(subscription).toBeDefined(); + await applySubscriptionDiscount({ + stripeCli, + subscriptionId: subscription.id, + couponIds: [coupon.id], + }); - const subDefaultPm = subscription!.default_payment_method; - console.log(`Subscription default_payment_method: ${JSON.stringify(subDefaultPm)}`); + // Step 3: Entity 1 cancels pro (end_of_cycle) — creates subscription schedule + console.log("Canceling entity 1 pro (end of cycle)..."); + await autumnV2_2.subscriptions.update({ + customer_id: customerId, + entity_id: entities[0].id, + plan_id: pro.id, + cancel_action: "cancel_end_of_cycle", + }); - const stripeCustomer = await ctx.stripeCli.customers.retrieve(stripeCustomerId!); - const customerDefaultPm = "deleted" in stripeCustomer - ? null - : stripeCustomer.invoice_settings?.default_payment_method; - console.log(`Customer invoice_settings.default_payment_method: ${JSON.stringify(customerDefaultPm)}`); + await new Promise((resolve) => setTimeout(resolve, 3000)); - const customerDefaultSource = "deleted" in stripeCustomer - ? null - : stripeCustomer.default_source; - console.log(`Customer default_source: ${JSON.stringify(customerDefaultSource)}`); + // Step 4: Entity 1 uncancels pro — schedule released/modified + console.log("Uncanceling entity 1 pro..."); + await autumnV2_2.subscriptions.update({ + customer_id: customerId, + entity_id: entities[0].id, + plan_id: pro.id, + cancel_action: "uncancel", + }); - if (subDefaultPm) { - console.log(chalk.green("Payment method is set at the SUBSCRIPTION level")); - } else if (customerDefaultPm) { - console.log(chalk.green("Payment method is set at the CUSTOMER level (invoice_settings)")); - } else if (customerDefaultSource) { - console.log(chalk.green("Payment method is set at the CUSTOMER level (default_source)")); - } else { - console.log(chalk.red("No default payment method found on subscription or customer")); + await new Promise((resolve) => setTimeout(resolve, 3000)); + + // Step 5: Entity 2 attaches pro + console.log("Attaching pro to entity 2..."); + try { + const result = await autumnV2_2.billing.attach({ + customer_id: customerId, + entity_id: entities[1].id, + plan_id: pro.id, + redirect_mode: "if_required", + }); + console.log("Entity 2 result:", JSON.stringify(result, null, 2)); + console.log(chalk.red("BUG NOT REPRODUCED — entity 2 attach succeeded")); + } catch (error: any) { + console.log(chalk.green("BUG REPRODUCED — entity 2 attach failed:")); + console.log("Error:", JSON.stringify(error, null, 2)); } }); diff --git a/server/tests/integration/billing/attach/new-plan/schedule-addon-attach.test.ts b/server/tests/integration/billing/attach/new-plan/schedule-addon-attach.test.ts new file mode 100644 index 000000000..9df42def7 --- /dev/null +++ b/server/tests/integration/billing/attach/new-plan/schedule-addon-attach.test.ts @@ -0,0 +1,113 @@ +import { test } from "bun:test"; +import type { ApiCustomerV3 } from "@autumn/shared"; +import { + applySubscriptionDiscount, + createPercentCoupon, + getStripeSubscription, +} from "@tests/integration/billing/utils/discounts/discountTestUtils"; +import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { + expectProductCanceling, + expectProductScheduled, +} from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +/** + * Reproduces: attaching a one-off add-on to a subscription that already + * has a schedule (from a prior downgrade) fails with Stripe error + * "You cannot migrate a subscription that is already attached to a schedule". + * + * The root cause is that setupStripeBillingContext skips fetching the schedule + * when targetCustomerProduct is undefined (new attachment, no transition). + * buildStripeSubscriptionScheduleAction then sees hasSchedule=false and emits + * type:"create" instead of type:"update", which Stripe rejects. + */ +test.concurrent(`${chalk.yellowBright("bug-repro: attach one-off add-on after scheduled downgrade hits 'subscription already attached to schedule'")}`, async () => { + const customerId = "repro-schedule-addon-attach"; + + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + + const premium = products.premium({ + id: "premium", + items: [messagesItem], + }); + + const pro = products.pro({ + id: "pro", + items: [messagesItem], + }); + + const topupAddon = products.base({ + id: "topup-addon", + items: [ + items.oneOffWords({ + includedUsage: 0, + billingUnits: 100, + price: 25, + }), + ], + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [premium, pro, topupAddon] }), + ], + actions: [s.billing.attach({ productId: premium.id })], + }); + + // Apply a 20% discount to the subscription (matches production scenario) + const { stripeCli, subscription: subBefore } = await getStripeSubscription({ + customerId, + }); + + const coupon = await createPercentCoupon({ + stripeCli, + percentOff: 20, + }); + + await applySubscriptionDiscount({ + stripeCli, + subscriptionId: subBefore.id, + couponIds: [coupon.id], + }); + + // Schedule downgrade: Premium -> Pro (creates a subscription schedule) + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: pro.id, + redirect_mode: "if_required", + }); + + // Verify the downgrade was scheduled correctly + const customer = await autumnV1.customers.get(customerId); + await expectProductCanceling({ customer, productId: premium.id }); + await expectProductScheduled({ customer, productId: pro.id }); + + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: topupAddon.id, + redirect_mode: "if_required", + options: [{ feature_id: TestFeature.Words, quantity: 100 }], + }); + + const customerAfter = await autumnV1.customers.get(customerId); + expectCustomerFeatureCorrect({ + customer: customerAfter, + featureId: TestFeature.Words, + balance: 100, + usage: 0, + }); + + await expectCustomerInvoiceCorrect({ + customer: customerAfter, + count: 2, + latestTotal: 25, + }); +}); diff --git a/vite/src/hooks/stores/useSubscriptionStore.ts b/vite/src/hooks/stores/useSubscriptionStore.ts index 37be8c2e8..e4bc11ac9 100644 --- a/vite/src/hooks/stores/useSubscriptionStore.ts +++ b/vite/src/hooks/stores/useSubscriptionStore.ts @@ -21,10 +21,15 @@ export const useEntity = () => { const { customer } = useCusQuery(); const entities = (customer as FullCustomer)?.entities || []; + // #region agent log + fetch('http://127.0.0.1:7322/ingest/302190cd-01f7-494e-9c36-a1625f5cf969',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'0967f4'},body:JSON.stringify({sessionId:'0967f4',location:'useSubscriptionStore.ts:useEntity',message:'entities debug',data:{customerExists:!!customer,entitiesRaw:(customer as any)?.entities,entitiesIsArray:Array.isArray((customer as any)?.entities),entitiesLength:entities.length,entitiesNullCount:entities.filter((e:any) => e == null).length,firstFewEntities:entities.slice(0,3).map((e:any) => e == null ? 'NULL' : {id:e?.id,internal_id:e?.internal_id}),selectedEntityId},timestamp:Date.now()})}).catch(()=>{}); + // #endregion + // Find the full entity object const entity = entities.find( (e: Entity) => - e.id === selectedEntityId || e.internal_id === selectedEntityId, + e != null && + (e.id === selectedEntityId || e.internal_id === selectedEntityId), ); // Sync query param to store on mount/change diff --git a/vite/src/views/admin/components/EdgeConfigTab.tsx b/vite/src/views/admin/components/EdgeConfigTab.tsx index 6380b2f8c..7d2376dd9 100644 --- a/vite/src/views/admin/components/EdgeConfigTab.tsx +++ b/vite/src/views/admin/components/EdgeConfigTab.tsx @@ -3,13 +3,17 @@ import { Button } from "@/components/v2/buttons/Button"; import { CustomerBlockDialog } from "./CustomerBlockDialog"; import { EdgeConfigDialog } from "./EdgeConfigDialog"; import { FeatureFlagsDialog } from "./FeatureFlagsDialog"; +import { OrgLimitsDialog } from "./OrgLimitsDialog"; import { RawEdgeConfigDialog } from "./RawEdgeConfigDialog"; +import { StripeSyncDialog } from "./StripeSyncDialog"; export function EdgeConfigTab() { const [requestBlockEditOpen, setRequestBlockEditOpen] = useState(false); const [requestBlockRawOpen, setRequestBlockRawOpen] = useState(false); const [featureFlagsOpen, setFeatureFlagsOpen] = useState(false); const [customerBlockOpen, setCustomerBlockOpen] = useState(false); + const [orgLimitsOpen, setOrgLimitsOpen] = useState(false); + const [stripeSyncOpen, setStripeSyncOpen] = useState(false); return (
@@ -71,6 +75,39 @@ export function EdgeConfigTab() { Edit
+ +
+
+
Org Limits
+
+ Per-org overrides for max customer products returned in queries + (default 15). +
+
+ +
+ +
+
+
Stripe Sync
+
+ Enable Stripe webhook event syncing to the sync DB per org. +
+
+ +
+ + + + ); } diff --git a/vite/src/views/admin/components/OrgLimitsDialog.tsx b/vite/src/views/admin/components/OrgLimitsDialog.tsx new file mode 100644 index 000000000..a1168fed4 --- /dev/null +++ b/vite/src/views/admin/components/OrgLimitsDialog.tsx @@ -0,0 +1,342 @@ +import Editor from "@monaco-editor/react"; +import { useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { Badge } from "@/components/v2/badges/Badge"; +import { Button } from "@/components/v2/buttons/Button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/v2/dialogs/Dialog"; +import { Input } from "@/components/v2/inputs/Input"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; +import { getBackendErr } from "@/utils/genUtils"; + +const DEFAULT_CUS_PRODUCT_LIMIT = 15; + +type OrgLimitsEntry = { + maxCusProducts?: number; +}; + +type OrgLimitsConfig = { + orgs: Record; + configHealthy: boolean; + configConfigured: boolean; + lastSuccessAt: string | null; + error: string | null; +}; + +const DEFAULT_CONFIG: OrgLimitsConfig = { + orgs: {}, + configHealthy: false, + configConfigured: false, + lastSuccessAt: null, + error: null, +}; + +const getEditableConfig = ({ config }: { config: OrgLimitsConfig }) => ({ + orgs: config.orgs, +}); + +const getEntryRows = ({ config }: { config: OrgLimitsConfig }) => { + return Object.entries(config.orgs) + .map(([orgId, entry]) => ({ + orgId, + maxCusProducts: entry.maxCusProducts ?? DEFAULT_CUS_PRODUCT_LIMIT, + })) + .sort((a, b) => a.orgId.localeCompare(b.orgId)); +}; + +export function OrgLimitsDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const axiosInstance = useAxiosInstance(); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [config, setConfig] = useState(DEFAULT_CONFIG); + const [jsonText, setJsonText] = useState(""); + const [jsonError, setJsonError] = useState(null); + const [syncSource, setSyncSource] = useState<"form" | "json">("form"); + const [newOrgId, setNewOrgId] = useState(""); + const [newLimit, setNewLimit] = useState(""); + + useEffect(() => { + if (!open) return; + + let cancelled = false; + setLoading(true); + + void axiosInstance + .get("/admin/org-limits-config") + .then(({ data }) => { + if (cancelled) return; + const mergedConfig: OrgLimitsConfig = { + ...DEFAULT_CONFIG, + ...data, + }; + setConfig(mergedConfig); + setJsonText( + JSON.stringify(getEditableConfig({ config: mergedConfig }), null, 2), + ); + setJsonError(null); + setSyncSource("form"); + }) + .catch((error) => { + if (!cancelled) { + toast.error(getBackendErr(error, "Failed to load org limits config")); + } + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [axiosInstance, open]); + + useEffect(() => { + if (syncSource !== "form") return; + setJsonText(JSON.stringify(getEditableConfig({ config }), null, 2)); + setJsonError(null); + }, [config, syncSource]); + + const entryRows = useMemo(() => getEntryRows({ config }), [config]); + + const handleJsonChange = (value: string | undefined) => { + const text = value ?? ""; + setJsonText(text); + setSyncSource("json"); + + try { + const parsed = JSON.parse(text) as { + orgs?: Record; + }; + setConfig((current) => ({ + ...current, + orgs: parsed.orgs ?? {}, + })); + setJsonError(null); + } catch { + setJsonError("Invalid JSON"); + } + }; + + const addEntry = () => { + const orgId = newOrgId.trim(); + const limit = parseInt(newLimit.trim(), 10); + + if (!orgId || Number.isNaN(limit) || limit < 1) return; + + setSyncSource("form"); + setConfig((current) => ({ + ...current, + orgs: { + ...current.orgs, + [orgId]: { maxCusProducts: limit }, + }, + })); + setNewOrgId(""); + setNewLimit(""); + }; + + const removeEntry = ({ orgId }: { orgId: string }) => { + setSyncSource("form"); + setConfig((current) => { + const nextOrgs = { ...current.orgs }; + delete nextOrgs[orgId]; + return { ...current, orgs: nextOrgs }; + }); + }; + + const handleSave = async () => { + if (jsonError) { + toast.error("Fix JSON errors before saving"); + return; + } + + let payload: unknown; + try { + payload = JSON.parse(jsonText); + } catch { + toast.error("Invalid JSON"); + return; + } + + setSaving(true); + try { + await axiosInstance.put("/admin/org-limits-config", payload); + toast.success("Org limits config saved"); + onOpenChange(false); + } catch (error) { + toast.error(getBackendErr(error, "Failed to save org limits config")); + } finally { + setSaving(false); + } + }; + + return ( + + + + Org Limits + + Configure per-org limits such as max customer products returned in + queries. Default is {DEFAULT_CUS_PRODUCT_LIMIT}. + + + + {loading ? ( +
Loading...
+ ) : ( +
+
+
+ Org Overrides +
+ +
+
+ setNewOrgId(event.target.value)} + /> + setNewLimit(event.target.value)} + /> + +
+ +
+ {entryRows.length === 0 ? ( +
+ No org overrides — all orgs use default ( + {DEFAULT_CUS_PRODUCT_LIMIT}) +
+ ) : ( + entryRows.map((entry) => ( +
+
+
+ {entry.orgId} +
+
+ maxCusProducts: {entry.maxCusProducts} +
+
+ +
+ )) + )} +
+
+ +
+
+ + {config.configHealthy + ? "Config healthy" + : "Config unavailable"} + + {config.lastSuccessAt && ( + + Last refresh:{" "} + {new Date(config.lastSuccessAt).toLocaleString()} + + )} +
+
+ {config.configConfigured === false + ? "S3 org limits config is not configured." + : config.error || + "Limits update within 5 minutes of saving."} +
+
+
+ +
+
+ Raw JSON +
+
+ +
+ {jsonError && ( +
{jsonError}
+ )} +
+
+ )} + + + + + +
+
+ ); +} diff --git a/vite/src/views/admin/components/StripeSyncDialog.tsx b/vite/src/views/admin/components/StripeSyncDialog.tsx new file mode 100644 index 000000000..7b842bb0c --- /dev/null +++ b/vite/src/views/admin/components/StripeSyncDialog.tsx @@ -0,0 +1,306 @@ +import Editor from "@monaco-editor/react"; +import { useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { Badge } from "@/components/v2/badges/Badge"; +import { Button } from "@/components/v2/buttons/Button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/v2/dialogs/Dialog"; +import { Input } from "@/components/v2/inputs/Input"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; +import { getBackendErr } from "@/utils/genUtils"; + +type StripeSyncConfig = { + enabledOrgIds: string[]; + configHealthy: boolean; + configConfigured: boolean; + lastSuccessAt: string | null; + error: string | null; +}; + +const DEFAULT_CONFIG: StripeSyncConfig = { + enabledOrgIds: [], + configHealthy: false, + configConfigured: false, + lastSuccessAt: null, + error: null, +}; + +export function StripeSyncDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const axiosInstance = useAxiosInstance(); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [config, setConfig] = useState(DEFAULT_CONFIG); + const [jsonText, setJsonText] = useState(""); + const [jsonError, setJsonError] = useState(null); + const [syncSource, setSyncSource] = useState<"form" | "json">("form"); + const [newOrgId, setNewOrgId] = useState(""); + + useEffect(() => { + if (!open) return; + + let cancelled = false; + setLoading(true); + + void axiosInstance + .get("/admin/stripe-sync-config") + .then(({ data }) => { + if (cancelled) return; + const merged = { ...DEFAULT_CONFIG, ...data }; + setConfig(merged); + setJsonText( + JSON.stringify({ enabledOrgIds: merged.enabledOrgIds }, null, 2), + ); + setJsonError(null); + setSyncSource("form"); + }) + .catch((error) => { + if (!cancelled) { + toast.error( + getBackendErr(error, "Failed to load stripe sync config"), + ); + } + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [axiosInstance, open]); + + useEffect(() => { + if (syncSource !== "form") return; + setJsonText( + JSON.stringify({ enabledOrgIds: config.enabledOrgIds }, null, 2), + ); + setJsonError(null); + }, [config, syncSource]); + + const sortedOrgIds = useMemo( + () => [...config.enabledOrgIds].sort(), + [config.enabledOrgIds], + ); + + const handleJsonChange = (value: string | undefined) => { + const text = value ?? ""; + setJsonText(text); + setSyncSource("json"); + + try { + const parsed = JSON.parse(text) as { enabledOrgIds?: string[] }; + setConfig((current) => ({ + ...current, + enabledOrgIds: parsed.enabledOrgIds ?? [], + })); + setJsonError(null); + } catch { + setJsonError("Invalid JSON"); + } + }; + + const addOrg = () => { + const orgId = newOrgId.trim(); + if (!orgId) return; + if (config.enabledOrgIds.includes(orgId)) { + toast.error("Org already in list"); + return; + } + + setSyncSource("form"); + setConfig((current) => ({ + ...current, + enabledOrgIds: [...current.enabledOrgIds, orgId], + })); + setNewOrgId(""); + }; + + const removeOrg = ({ orgId }: { orgId: string }) => { + setSyncSource("form"); + setConfig((current) => ({ + ...current, + enabledOrgIds: current.enabledOrgIds.filter((id) => id !== orgId), + })); + }; + + const handleSave = async () => { + if (jsonError) { + toast.error("Fix JSON errors before saving"); + return; + } + + let payload: unknown; + try { + payload = JSON.parse(jsonText); + } catch { + toast.error("Invalid JSON"); + return; + } + + setSaving(true); + try { + await axiosInstance.put("/admin/stripe-sync-config", payload); + toast.success("Stripe sync config saved"); + onOpenChange(false); + } catch (error) { + toast.error(getBackendErr(error, "Failed to save stripe sync config")); + } finally { + setSaving(false); + } + }; + + return ( + + + + Stripe Sync + + Manage which orgs have Stripe webhook events synced to the sync DB. + Changes take effect within 60 seconds. + + + + {loading ? ( +
Loading...
+ ) : ( +
+
+
+ Enabled Orgs +
+ +
+
+ setNewOrgId(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") addOrg(); + }} + /> + +
+ +
+ {sortedOrgIds.length === 0 ? ( +
+ No orgs enabled — sync is disabled for all orgs. +
+ ) : ( + sortedOrgIds.map((orgId) => ( +
+
+ {orgId} +
+ +
+ )) + )} +
+
+ +
+
+ + {config.configHealthy + ? "Config healthy" + : "Config unavailable"} + + {config.lastSuccessAt && ( + + Last refresh:{" "} + {new Date(config.lastSuccessAt).toLocaleString()} + + )} +
+
+ {config.configConfigured === false + ? "S3 stripe sync config is not configured." + : config.error || + "Config updates within 60 seconds of saving."} +
+
+
+ +
+
+ Raw JSON +
+
+ +
+ {jsonError && ( +
{jsonError}
+ )} +
+
+ )} + + + + + +
+
+ ); +} diff --git a/vite/src/views/main-sidebar/org-dropdown/components/OrgLogo.tsx b/vite/src/views/main-sidebar/org-dropdown/components/OrgLogo.tsx index f2de62022..1bdbcc254 100644 --- a/vite/src/views/main-sidebar/org-dropdown/components/OrgLogo.tsx +++ b/vite/src/views/main-sidebar/org-dropdown/components/OrgLogo.tsx @@ -9,13 +9,13 @@ export const OrgLogo = ({ org }: { org: FrontendOrg }) => { "rounded-md overflow-hidden flex items-center justify-center scale-100 translate-x-px bg-zinc-200 w-5 h-5 min-w-5 min-h-5", )} > - {org.logo ? ( + {/* {org.logo ? ( {org.name} - ) : ( - - {firstLetter} - - )} + ) : ( */} + + {firstLetter} + + {/* )} */} ); }; diff --git a/vite/src/views/products/plan/components/EditPlanHeader.tsx b/vite/src/views/products/plan/components/EditPlanHeader.tsx index 73b8e312f..bd94f004b 100644 --- a/vite/src/views/products/plan/components/EditPlanHeader.tsx +++ b/vite/src/views/products/plan/components/EditPlanHeader.tsx @@ -194,7 +194,10 @@ export const EditPlanHeader = () => { {hasRCMapping && ( - }> + } + > RC @@ -209,8 +212,7 @@ export const EditPlanHeader = () => { } - > - + > This plan is linked to Vercel Marketplace