diff --git a/.infisical.json b/.infisical.json deleted file mode 100644 index bfbdfaf..0000000 --- a/.infisical.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "workspaceId": "3dbddeb7-9e1b-444c-be5a-1dedc702351c", - "defaultEnvironment": "dev", - "gitBranchToEnvironmentMapping": null -} diff --git a/README.md b/README.md index c99aaa2..adf5add 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,9 @@ same ideas — it is [`docs/index.html`](docs/index.html), published with GitHub against it. See [`docs/mcp.md`](docs/mcp.md). - **Bilingual UI** — English and Traditional Chinese, switched per user without reloading or changing URLs. See [Internationalization](#internationalization). -- **Auth** — Google OAuth via better-auth, invite-based member management, org settings. +- **Auth** — Google OAuth, enterprise SSO (any OIDC provider, routed by email domain) and + email + password, all via better-auth; invite-based member management, org settings. + See [`docs/sso.md`](docs/sso.md). ## Tech stack @@ -128,6 +130,28 @@ The first user to sign in lands on `/onboarding`. To make someone the owner of a organization, edit `v_email` in [`scripts/bootstrap-owner.sql`](scripts/bootstrap-owner.sql) and run it against your database. +### Signing in without Google + +Google is the primary method, but it is not the only one. + +**Email + password.** Useful if you self-host and do not want to wire up Google at all. +Public password *signup* is deliberately off: better-auth links accounts by email +address, and an unverified self-registered password account on someone else's email +would let an attacker sit and wait for the real owner to arrive via Google. So accounts +are created server-side instead: + +```bash +bun scripts/create-user.ts --email you@example.com \ + --password 'a-long-passphrase' --name 'Your Name' --org your-org-slug +``` + +Minimum password length is 12. Pass the password via `CREATE_USER_PASSWORD` instead of +`--password` to keep it out of your shell history. + +**Enterprise SSO.** Any OIDC provider, routed by the email's domain — the user types +their address and gets handed to the right IdP. Registration is likewise server-side +only. See [`docs/sso.md`](docs/sso.md). + ## Configuration | Variable | Purpose | @@ -220,8 +244,8 @@ src/ i18n/ # locale config and bilingual message catalogue lib/ # auth (better-auth), session helpers, object storage, utils migrations/ # plain forward-only SQL migrations -scripts/ # one-off operational SQL (e.g. bootstrap-owner) -docs/ # landing page (GitHub Pages root), deployment guide, MCP docs, assets +scripts/ # one-off operational scripts (bootstrap-owner, create-user, register-sso-provider) +docs/ # landing page (GitHub Pages root), deployment guide, MCP + SSO docs, assets Dockerfile # vendor-neutral self-host image docker-compose.yml ``` diff --git a/bun.lock b/bun.lock index 1fbfbd5..4c55061 100644 --- a/bun.lock +++ b/bun.lock @@ -4,8 +4,9 @@ "": { "name": "internal", "dependencies": { + "@better-auth/sso": "^1.6.14", "@neondatabase/serverless": "^1.1.0", - "@opennextjs/cloudflare": "1.20.3", + "@opennextjs/cloudflare": "^1.20.3", "better-auth": "^1.6.14", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -73,6 +74,8 @@ "@ast-grep/napi-win32-x64-msvc": ["@ast-grep/napi-win32-x64-msvc@0.40.5", "", { "os": "win32", "cpu": "x64" }, "sha512-dqm5zg/o4Nh4VOQPEpMS23ot8HVd22gG0eg01t4CFcZeuzyuSgBlOL3N7xLbz3iH2sVkk7keuBwAzOIpTqziNQ=="], + "@authenio/xml-encryption": ["@authenio/xml-encryption@2.0.2", "", { "dependencies": { "@xmldom/xmldom": "^0.8.6", "escape-html": "^1.0.3", "xpath": "0.0.32" } }, "sha512-cTlrKttbrRHEw3W+0/I609A2Matj5JQaRvfLtEIGZvlN0RaPi+3ANsMeqAyCAVlH/lUIW2tmtBlSMni74lcXeg=="], + "@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=="], "@aws-crypto/crc32c": ["@aws-crypto/crc32c@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag=="], @@ -235,6 +238,8 @@ "@better-auth/prisma-adapter": ["@better-auth/prisma-adapter@1.6.14", "", { "peerDependencies": { "@better-auth/core": "^1.6.14", "@better-auth/utils": "0.4.1", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@prisma/client", "prisma"] }, "sha512-9b9wSqhCthMmOYo0QdX+N/cOv+fNck/JE5CZQuuWwEJl5QeoYhCZesXjts5VfLAPMIf6vKw3QNBrn0SVMXXi2Q=="], + "@better-auth/sso": ["@better-auth/sso@1.6.14", "", { "dependencies": { "fast-xml-parser": "^5.8.0", "jose": "^6.1.3", "samlify": "^2.13.1", "tldts": "^6.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/core": "^1.6.14", "@better-auth/utils": "0.4.1", "@better-fetch/fetch": "1.1.21", "better-auth": "^1.6.14", "better-call": "1.3.5" } }, "sha512-P0o30yry8FVr0YwB/nCTJ4+oNIBholq6njiExzmqFI88MV17pblahov6+pmXCYrPyh1NGkI+HwTLirrFcKfLOw=="], + "@better-auth/telemetry": ["@better-auth/telemetry@1.6.14", "", { "peerDependencies": { "@better-auth/core": "^1.6.14", "@better-auth/utils": "0.4.1", "@better-fetch/fetch": "1.1.21" } }, "sha512-ALi3cEx5eyrFY+TeAdhc1uq8FqJyGvzgvIo7GQZOqGqLZxHY9nte44WN++jBFGJJbsW3e4cgLj8dQK291s6wWQ=="], "@better-auth/utils": ["@better-auth/utils@0.4.1", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-SZBPRPF3z0nBvE5ygOkxae35wnnXPRShmqFo78S+qslLeFoPu/pMgnXAuNKFMMybac3tiLaVg1e3MQW5MC+1iA=="], @@ -481,7 +486,7 @@ "@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], - "@nodable/entities": ["@nodable/entities@2.1.1", "", {}, "sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg=="], + "@nodable/entities": ["@nodable/entities@3.0.0", "", {}, "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw=="], "@node-minify/core": ["@node-minify/core@8.0.6", "", { "dependencies": { "@node-minify/utils": "8.0.6", "glob": "9.3.5", "mkdirp": "1.0.4" } }, "sha512-/vxN46ieWDLU67CmgbArEvOb41zlYFOkOtr9QW9CnTrBLuTyGgkyNWC2y5+khvRw3Br58p2B5ZVSx/PxCTru6g=="], @@ -925,6 +930,10 @@ "@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.12.2", "", { "os": "win32", "cpu": "x64" }, "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA=="], + "@xmldom/is-dom-node": ["@xmldom/is-dom-node@1.0.1", "", {}, "sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q=="], + + "@xmldom/xmldom": ["@xmldom/xmldom@0.8.15", "", {}, "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA=="], + "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], @@ -947,6 +956,8 @@ "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "anynum": ["anynum@1.0.1", "", {}, "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A=="], + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], @@ -971,6 +982,8 @@ "arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="], + "asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="], + "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], "ast-types-flow": ["ast-types-flow@0.0.8", "", {}, "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ=="], @@ -1273,7 +1286,7 @@ "fast-xml-builder": ["fast-xml-builder@1.2.0", "", { "dependencies": { "path-expression-matcher": "^1.5.0", "xml-naming": "^0.1.0" } }, "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q=="], - "fast-xml-parser": ["fast-xml-parser@5.7.3", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.7", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg=="], + "fast-xml-parser": ["fast-xml-parser@5.11.1", "", { "dependencies": { "@nodable/entities": "^3.0.0", "fast-xml-builder": "^1.2.0", "is-unsafe": "^2.0.0", "path-expression-matcher": "^1.6.2", "strnum": "^2.4.2", "xml-naming": "^0.3.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-TBw6K/fxoQGGjCmZDw9w/ZwP3uDcnTM4YH/g+PFRWr8sbe5idXtxNN6vITh4+1ruCZaho6uBFurElsA7F0zzgw=="], "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], @@ -1489,6 +1502,8 @@ "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], + "is-unsafe": ["is-unsafe@2.0.2", "", {}, "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ=="], + "is-weakmap": ["is-weakmap@2.0.2", "", {}, "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w=="], "is-weakref": ["is-weakref@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew=="], @@ -1649,6 +1664,8 @@ "node-releases": ["node-releases@2.0.47", "", {}, "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og=="], + "node-rsa": ["node-rsa@1.1.1", "", { "dependencies": { "asn1": "^0.2.4" } }, "sha512-Jd4cvbJMryN21r5HgxQOpMEqv+ooke/korixNNK3mGqfGJmy0M77WDDzo/05969+OkMy3XW1UuZsSmW9KQm7Fw=="], + "npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="], "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], @@ -1705,7 +1722,7 @@ "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], - "path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="], + "path-expression-matcher": ["path-expression-matcher@1.6.2", "", {}, "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], @@ -1817,6 +1834,8 @@ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "samlify": ["samlify@2.13.1", "", { "dependencies": { "@authenio/xml-encryption": "^2.0.2", "@xmldom/xmldom": "^0.8.11", "node-rsa": "^1.1.1", "xml": "^1.0.1", "xml-crypto": "^6.1.2", "xml-escape": "^1.1.0", "xpath": "^0.0.34" } }, "sha512-vdYr/zohDGBbfWNU4miEzc1jmWOtkLySPViapC6nfGkv9KxzLq4UlGkKyryzwLw4jVlZk88Rw93HaCRVpe+t+g=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -1897,7 +1916,7 @@ "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], - "strnum": ["strnum@2.3.0", "", {}, "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q=="], + "strnum": ["strnum@2.4.2", "", { "dependencies": { "anynum": "^1.0.1" } }, "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw=="], "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], @@ -1919,9 +1938,9 @@ "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - "tldts": ["tldts@7.4.2", "", { "dependencies": { "tldts-core": "^7.4.2" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw=="], + "tldts": ["tldts@6.1.86", "", { "dependencies": { "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ=="], - "tldts-core": ["tldts-core@7.4.2", "", {}, "sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA=="], + "tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="], "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], @@ -2033,7 +2052,15 @@ "wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], - "xml-naming": ["xml-naming@0.1.0", "", {}, "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw=="], + "xml": ["xml@1.0.1", "", {}, "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw=="], + + "xml-crypto": ["xml-crypto@6.1.2", "", { "dependencies": { "@xmldom/is-dom-node": "^1.0.1", "@xmldom/xmldom": "^0.8.10", "xpath": "^0.0.33" } }, "sha512-leBOVQdVi8FvPJrMYoum7Ici9qyxfE4kVi+AkpUoYCSXaQF4IlBm1cneTK9oAxR61LpYxTx7lNcsnBIeRpGW2w=="], + + "xml-escape": ["xml-escape@1.1.0", "", {}, "sha512-B/T4sDK8Z6aUh/qNr7mjKAwwncIljFuUP+DO/D5hloYFj+90O88z8Wf7oSucZTHxBAsC1/CTP4rtx/x1Uf72Mg=="], + + "xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="], + + "xpath": ["xpath@0.0.34", "", {}, "sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA=="], "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], @@ -2061,6 +2088,8 @@ "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], + "@authenio/xml-encryption/xpath": ["xpath@0.0.32", "", {}, "sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw=="], + "@aws-crypto/sha1-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], "@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], @@ -2071,6 +2100,8 @@ "@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.31", "", { "dependencies": { "@aws-sdk/types": "^3.973.10", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-Kn2up9SlG1KC6wRtwf0d7waTGF6rvp9DxYqB54x6UCKdQ6kyaXCqHL4WGb5vUJga5kS8FxnjhY0LqM28aMvnNQ=="], + "@aws-sdk/xml-builder/fast-xml-parser": ["fast-xml-parser@5.7.3", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.7", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg=="], + "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], @@ -2157,6 +2188,10 @@ "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "fast-xml-builder/path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="], + + "fast-xml-builder/xml-naming": ["xml-naming@0.1.0", "", {}, "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw=="], + "fetch-blob/web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], @@ -2199,6 +2234,8 @@ "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + "tough-cookie/tldts": ["tldts@7.4.2", "", { "dependencies": { "tldts-core": "^7.4.2" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw=="], + "tsx/esbuild": ["esbuild@0.28.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.0", "@esbuild/android-arm": "0.28.0", "@esbuild/android-arm64": "0.28.0", "@esbuild/android-x64": "0.28.0", "@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-x64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-x64": "0.28.0", "@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-mips64el": "0.28.0", "@esbuild/linux-ppc64": "0.28.0", "@esbuild/linux-riscv64": "0.28.0", "@esbuild/linux-s390x": "0.28.0", "@esbuild/linux-x64": "0.28.0", "@esbuild/netbsd-arm64": "0.28.0", "@esbuild/netbsd-x64": "0.28.0", "@esbuild/openbsd-arm64": "0.28.0", "@esbuild/openbsd-x64": "0.28.0", "@esbuild/openharmony-arm64": "0.28.0", "@esbuild/sunos-x64": "0.28.0", "@esbuild/win32-arm64": "0.28.0", "@esbuild/win32-ia32": "0.28.0", "@esbuild/win32-x64": "0.28.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw=="], "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], @@ -2209,6 +2246,14 @@ "wrap-ansi/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + "xml-crypto/xpath": ["xpath@0.0.33", "", {}, "sha512-NNXnzrkDrAzalLhIUc01jO2mOzXGXh1JwPgkihcLLzw98c0WgYDmmjSh1Kl3wzaxSVWMuA+fe0WTWOBDWCBmNA=="], + + "@aws-sdk/xml-builder/fast-xml-parser/@nodable/entities": ["@nodable/entities@2.1.1", "", {}, "sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg=="], + + "@aws-sdk/xml-builder/fast-xml-parser/path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="], + + "@aws-sdk/xml-builder/fast-xml-parser/strnum": ["strnum@2.3.0", "", {}, "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q=="], + "@dotenvx/dotenvx/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], "@dotenvx/dotenvx/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], @@ -2359,6 +2404,8 @@ "string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "tough-cookie/tldts/tldts-core": ["tldts-core@7.4.2", "", {}, "sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA=="], + "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA=="], "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.0", "", { "os": "android", "cpu": "arm" }, "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ=="], diff --git a/deployment-architectures-screenshot.png b/deployment-architectures-screenshot.png deleted file mode 100644 index 9c30de4..0000000 Binary files a/deployment-architectures-screenshot.png and /dev/null differ diff --git a/docs/local-dev.md b/docs/local-dev.md index 7febe18..2cfdc4f 100644 --- a/docs/local-dev.md +++ b/docs/local-dev.md @@ -5,8 +5,8 @@ and the built-worker preview uses a copy of production: | Command | Reads | Database | | --- | --- | --- | -| `bun dev` (next dev) | `.env.local` | Neon **dev** branch (`br-bold-wildflower-a1fce385`) | -| `bun run cf:preview` (built worker) | `.dev.vars` | Neon **local-preview** branch (`br-raspy-lab-a10s90pg`, a copy of production) | +| `bun dev` (next dev) | `.env.local` | Neon **dev** branch (``) | +| `bun run cf:preview` (built worker) | `.dev.vars` | Neon **local-preview** branch (``, a copy of production) | `next dev` reads `process.env` from `.env*`; the OpenNext/`wrangler dev` preview reads `process.env` from `.dev.vars`. That's why each can point at a different DB. diff --git a/docs/mcp.md b/docs/mcp.md index 8827141..1427a95 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3,13 +3,25 @@ This app exposes a [Model Context Protocol](https://modelcontextprotocol.io) server so an MCP client (Claude, etc.) can run essentially the whole app in natural language — the ledger (內外帳), parties, categories, bank accounts, invoices, -projects, recurring subscriptions, contracts, employees, payroll -(read), and reconciliations. +projects, recurring subscriptions, contracts, employees, payroll records, and +reconciliations. A headline use: **never forget when to bill a client for recurring work.** `list_upcoming_billing` projects the next charge of every active subscription from `interval_months` + `start_date`. One-off collection is tracked on contracts (已收/未收). +> **Bookkeeping only — this server moves no money.** Every write creates or edits +> a row in the organization's own books. Nothing here initiates, authorizes or +> executes a payment, transfer, payout or trade, and the server has no connection +> to a bank, card network or payment provider. `pay_employee_salary` writes a +> payslip plus the matching salary-expense ledger entry; `create_reimbursement` +> books a repaid advance; a `transfer` transaction is a book entry between two of +> the organization's own ledger accounts; `create_billing_item` adds a reminder to +> an internal board and bills nobody; `create_invoice` records an invoice, it does +> not issue one. Tool titles and descriptions are worded to say so explicitly — +> keep them that way, both directories reject anything that reads like a payment +> rail. + Design: read tools reuse the app's own query layer; writes are org-scoped and validate every referenced id against your org. Fixed value sets are exposed as enums in each tool's input schema; ids (categories, accounts, parties, …) are @@ -84,14 +96,19 @@ Every tool carries, all derived centrally in `src/lib/mcp/handler.ts` (never in the `tools-*.ts` modules): - a human-readable `title` — de-snake-cased from the tool name, with a small - override table for names that don't read well; + override table for names that don't read well, plus the ones whose default + title would read like moving money (`pay_employee_salary` → "Record a salary + payslip", `create_reimbursement` → "Record an advance reimbursement", + `create_invoice` → "Record an invoice"); - MCP `annotations`: `readOnlyHint` / `destructiveHint` / `idempotentHint` derived from the verb, plus `openWorldHint`, which is `false` for everything except `sync_billing_calendar` (the only tool that writes to a third-party - system). Two overrides correct the verb heuristic: `sync_billing_calendar` - gets `openWorldHint: true`, and `pay_employee_salary` gets - `destructiveHint: true` — it posts a salary expense to the ledger and the - month can't be re-paid, with no "unpay" tool to undo it; + system). Four overrides correct the verb heuristic: `sync_billing_calendar` + gets `openWorldHint: true`; `pay_employee_salary` gets `destructiveHint: true` + — it writes the payslip plus the salary-expense ledger entry, the month can't + be booked twice and no tool reverses it; and `set_subscription_period` + (an upsert) and `unmark_accountant_notified` (clears a flag to null) get + `idempotentHint: true`, which the `update_`/`delete_` prefix rule misses; - a `[read]` / `[write]` / `[delete]` tag prefixed to the description, so clients can group read vs write at a glance; - `securitySchemes: [{ type: "oauth2", scopes: [...] }]` (mirrored under `_meta` @@ -100,16 +117,34 @@ the `tools-*.ts` modules): - `_meta["openai/toolInvocation/invoking" | "invoked"]`, the status line ChatGPT shows while a call is in flight. -**Output schemas.** A tool may declare an `outputSchema`; when it does, the -handler additionally returns the result as MCP `structuredContent` (the JSON -text block stays, per MCP's back-compat recommendation). Only `list_organizations` -declares one today — it's the reference implementation. Adding schemas to the -rest is worthwhile (OpenAI's guidance is to declare one for every tool that -returns structured data), but the schema is a promise: `structuredContent` must -validate against it, and tools that return a bare array must not declare one. - -**Discovery** — `list_organizations` (call first), `create_organization` (spin up a -new org, makes you its owner), `get_financial_overview`. +**Output schemas.** Every tool declares an `outputSchema` — all 68 of them, as of +server version 1.2.0. When a tool declares one the handler additionally returns +the result as MCP `structuredContent` (the JSON text block stays, per MCP's +back-compat recommendation), which is what ChatGPT and Codex prefer over parsing +JSON out of text. `list_organizations` remains the reference implementation. + +The schema is a **promise**: MCP requires `structuredContent` to validate against +it, so each one is derived from what that tool's `execute` actually returns — +drizzle `numeric` columns come back as strings, `date` columns as `YYYY-MM-DD` +strings, and every nullable field is declared as `["string", "null"]` rather than +quietly typed as non-null. Schemas over whole `.returning()` rows deliberately +leave `additionalProperties` open, so a future migration that adds a column +doesn't silently invalidate them; hand-built fixed shapes are closed. + +Only an object-rooted result can carry a schema, because MCP will not mirror a +bare array into `structuredContent`. Every tool that used to answer with a bare +array — the `list_*` tools plus `get_financial_overview` — therefore returns +`{ items, count }`, built by the `listResult` / `listSchema` helpers in +`src/lib/mcp/shared.ts`. That wrapper is what makes 100% coverage possible. Two +tools predate the convention and keep their own named roots: +`list_organizations` returns `{ organizations, hint }` and +`list_upcoming_billing` returns `{ today, windowDays, upcomingCount, upcoming, +note }`. A new tool whose return shape isn't stable and object-rooted may still +omit `outputSchema`; it just keeps the JSON-in-a-text-block result shape. Do not +declare a schema you cannot guarantee. + +**Discovery** — `list_organizations` (call first), `get_financial_overview`. +Organizations are created in the web app, not over MCP. **Billing** — `list_billing_status` is the main one: the whole billing board in a single call, merging one-off charges (contract instalments / project milestones) @@ -130,7 +165,7 @@ board automatically. `sync_billing_calendar` pushes the board to Google Calendar **Ledger (內外帳)** — `list_transactions`, `get_transaction`, `list_outstanding_advances`, `create_transaction` (expense/income/advance/transfer), `update_transaction` (date/amount/category/project/…), `delete_transaction`, -`create_reimbursement` (settle an advance). +`create_reimbursement` (book an advance as repaid). **Accounting master data** — parties: `list_parties`/`get_party`/`create_party`/ `update_party`/`delete_party`; categories: `list_categories`/`create_category`/ @@ -159,17 +194,19 @@ handled conservatively over MCP: national ID and salary account come back **masked** (full values are web-app only), and every employee read is written to the activity log as a `read` entry — so who pulled contact data, and when, is always answerable. Payroll: -`list_payroll_runs`, `list_payslips`, `list_salary_status` (who's paid for a month -+ when), `pay_employee_salary` (records the payslip **and** posts the salary -expense to the ledger); reconciliations: `list_reconciliations` + +`list_payroll_runs`, `list_payslips`, `list_salary_status` (whose salary is +booked for a month + when), `pay_employee_salary` (writes the payslip **and** the +matching salary-expense ledger entry — bookkeeping only, it pays nobody); +reconciliations: `list_reconciliations` + `create`/`update`/`delete`; accountant notices: `list_accountant_notices`, `mark_accountant_notified`, `unmark_accountant_notified`. -**Not exposed (do in the app):** uploading invoice/receipt **files** (R2), -multi-currency FX entry, and *connecting* Google Calendar (the OAuth consent needs -a browser — do it once in 組織設定, after which `sync_billing_calendar` works over -MCP). These need file handling or extra UI. Deletes that would -break references return a clear error suggesting deactivation/archiving instead. +**Not exposed (do in the app):** creating an organization, uploading +invoice/receipt **files** (R2), multi-currency FX entry, and *connecting* Google +Calendar (the OAuth consent needs a browser — do it once in 組織設定, after which +`sync_billing_calendar` works over MCP). These need file handling or extra UI. +Deletes that would break references return a clear error suggesting +deactivation/archiving instead. ## Setup @@ -209,16 +246,68 @@ things that don't live in this repo: completed **individual or business identity verification** — publishing under an unverified name is an automatic rejection; - public listing URLs: website, support, **privacy policy**, **terms**; plus a - name, logo, short/long description and category; + name, logo, short/long description and category. `/privacy` and `/terms` are + served bilingually (en / zh-TW) and default to English for any browser that + does not ask for Chinese, so a reviewer can read them without switching + anything; the language switcher in the header is there if they want to; - **domain verification**: set `OPENAI_APPS_CHALLENGE_TOKEN` to the token the portal generates and redeploy, so `/.well-known/openai-apps-challenge` returns it as bare text; - **reviewer demo credentials** that work without MFA, SMS, email confirmation - or VPN — today this server signs in with Google only, which is a problem - worth solving before submitting; + or VPN, backed by a fully populated workspace — see + [Reviewer demo account](#reviewer-demo-account) below; - five positive and three negative test cases, starter prompts, country availability, and release notes. +## Reviewer demo account + +Both directories ask for the same thing in different words — OpenAI wants +"test credentials for a fully populated account", Anthropic wants a "fully +featured demo account with sample data". An empty workspace fails review: most +of the 68 tools would answer with an empty array and the reviewer has no way to +tell what the connector does. + +Two commands produce that account. Run them against the environment you are +submitting (`bun` picks up `.env.local`; use `bun --env-file=… ` for another): + +```sh +# 1. the sign-in credentials themselves (email + password, server-side only — +# HTTP sign-up is disabled on purpose). +bun scripts/create-user.ts \ + --email reviewer@example.com \ + --password '' \ + --name 'Directory Reviewer' + +# 2. the workspace behind them: a demo org owned by that user, populated across +# every tool domain. +bun scripts/seed-demo-org.ts --owner-email reviewer@example.com --org-slug demo +``` + +`seed-demo-org.ts` creates one organization ("Meridian Software Ltd. (Demo)" by +default, `--org-name` to change it) and fills it with plainly fictional data: +categories, TWD/USD bank accounts, customers and vendors, six months of +transactions across the internal and external books, projects, contracts with +instalment schedules, subscriptions with period overrides, billing rows in every +board state (due / billed / partial / overdue / paid / upcoming), invoices in +several issue states, employees with payroll runs and payslips, an outstanding +advance plus a settled reimbursement, bank reconciliations, accountant notices, +and activity-log entries. It prints a summary of what it wrote. + +Safety properties worth keeping: + +- it only ever **inserts**, and every row carries the organization it just + created — nothing outside that org is touched; +- it **refuses to run** when the target slug already exists, rather than pouring + a second copy into the existing demo org (which would double every board row). + `--force` creates a fresh org under a suffixed slug (`demo-2`, `demo-3`, …) + and still leaves the existing one alone; +- there is no interactive transaction on neon-http, so a mid-run failure leaves + a half-built org. The error names the section that failed and the summary + prints the org id — delete that org and re-run. + +Give the reviewer the email, the password, and `organizationId: "demo"` (tools +accept the slug as well as the id). + Sources: [Build an MCP server](https://developers.openai.com/plugins/build/mcp-server), [Authentication](https://developers.openai.com/plugins/build/auth), [Submit plugins](https://developers.openai.com/plugins/deploy/submission), diff --git a/docs/sso.md b/docs/sso.md new file mode 100644 index 0000000..5530d5b --- /dev/null +++ b/docs/sso.md @@ -0,0 +1,145 @@ +# Single sign-on (SSO) + +Besides Google, the app can sign people in through **any OIDC identity provider** — +Cloudflare Access, Okta, Entra ID, Auth0, Keycloak, whatever speaks OpenID Connect. + +The login page does **home realm discovery**: the user picks *Continue with SSO* and +types their email. The email's domain is matched against the registered providers and +the browser is handed to whichever IdP owns that domain. No domain is hardcoded +anywhere in the UI — which domains work is entirely a function of what has been +registered in the database. + +First sign-in through a provider creates the user automatically (implicit signup). +It does **not** put them in an organization; they land on `/onboarding` and join via +the normal invitation flow, exactly like a first-time Google user. + +Implemented with better-auth's [`@better-auth/sso`](https://www.better-auth.com/docs/plugins/sso) +plugin (see `src/lib/auth.ts`). SAML is supported by the plugin but not used here. + +## Endpoints + +| Purpose | URL | +| --- | --- | +| Start SSO sign-in (called by the login page) | `POST /api/auth/sign-in/sso` | +| OIDC callback — **this is the IdP's redirect URL** | `/api/auth/sso/callback/` | +| Provider registration | *disabled over HTTP — see below* | + +`` is `BETTER_AUTH_URL`, the public URL of the deployment. + +## Registration is server-side only + +The plugin ships a `POST /sso/register` endpoint that is guarded by nothing more than +"you are signed in". That is not enough for this app: registering a provider means +declaring *who is allowed to mint identities here*, so any ordinary member could point +a domain at an IdP they control and issue themselves accounts. + +`src/lib/auth.ts` therefore closes that route two independent ways: + +- `disabledPaths: ["/sso/register"]` — the router answers `404` before the handler runs. +- `sso({ providersLimit: 0 })` — the handler itself returns `403` even if the route + were ever reachable. + +`disabledPaths` is enforced in the HTTP router only, so a direct server-side write still +works. Registration goes through `scripts/register-sso-provider.ts`, which needs +`DATABASE_URL` — i.e. an operator, not a user. + +`/sso/update-provider` and `/sso/delete-provider` stay enabled: they check ownership +against `sso_provider.user_id`, and that column is only ever filled by the script. + +## Setting up Cloudflare Access for SaaS + +Cloudflare Access can act as the IdP in front of whatever identity source your team +already uses (Google Workspace, Entra ID, one-time PIN, …). Zero Trust → **Access** → +**Applications** → **Add an application** → **SaaS**. + +1. **Application** — give it a name. Under *Application type* pick **OIDC**. +2. **Scopes** — tick `openid`, `email`, `profile`. These are what the app asks for. +3. **Redirect URLs** — enter the callback for the provider id you are about to use: + + ``` + https:///api/auth/sso/callback/ + ``` + + `` is a short slug you choose (e.g. `acme`). It must match the + `--provider-id` you pass to the script, because it is part of the URL. +4. Save. Cloudflare then shows three values you will need — copy them now, the client + secret is only shown once: + + | Cloudflare field | Script argument | + | --- | --- | + | Client ID | `--client-id` | + | Client secret | `--client-secret` | + | Issuer (a.k.a. *OIDC issuer*) | `--issuer` | + + The issuer looks like this, where `` is the same Client ID as above: + + ``` + https://.cloudflareaccess.com/cdn-cgi/access/sso/oidc/ + ``` + + Discovery lives at `/.well-known/openid-configuration`. You can `curl` it to + confirm the values before running the script. +5. Add an **Access policy** deciding who may use the application. Cloudflare enforces + this *before* the user ever reaches this app. + +Other IdPs work the same way — only the wording of the fields differs. + +## Registering the provider + +```bash +SSO_CLIENT_SECRET='…' bun scripts/register-sso-provider.ts \ + --provider-id acme \ + --domain acme.com \ + --issuer 'https://.cloudflareaccess.com/cdn-cgi/access/sso/oidc/' \ + --client-id '' +``` + +| Argument | Env var | Meaning | +| --- | --- | --- | +| `--provider-id` | `SSO_PROVIDER_ID` | Slug; appears in the callback URL. Must match the IdP's redirect URL. | +| `--domain` | `SSO_DOMAIN` | Email domain routed to this IdP. This is the home-realm-discovery key. | +| `--issuer` | `SSO_ISSUER` | OIDC issuer URL. | +| `--client-id` | `SSO_CLIENT_ID` | OAuth client id from the IdP. | +| `--client-secret` | `SSO_CLIENT_SECRET` | OAuth client secret. Prefer the env var so it stays out of shell history. | +| `--owner-email` | — | Optional. An existing user who may edit/delete this provider over HTTP. Omit to keep it script-managed only. | +| `--org` | — | Optional organization slug to associate. Does **not** auto-add users to it. | +| `--scopes` | — | Comma-separated. Default `openid,email,profile`. | + +The script fetches the discovery document at registration time and stores the resolved +endpoints. That is deliberate: the plugin only re-runs discovery at sign-in when the +stored config is missing endpoints, and that path validates IdP URLs against the app's +`trustedOrigins` — so pre-resolving means you never have to touch `trustedOrigins` to +add an IdP. During discovery the script trusts only the issuer's own origin, so a +malicious discovery document cannot redirect the token or JWKS endpoint elsewhere. + +`bun` reads `.env.local` automatically, so `DATABASE_URL` does not need to be passed. +Use `bun --env-file=… ` to target a different environment. + +It prints the callback URL when it finishes — check that it matches what you entered in +the IdP. + +## Verifying + +1. Open `/login`, click **Continue with SSO**, enter an address at the registered domain. +2. You should be sent to the IdP, and back to `/dashboard` (or straight back into an MCP + OAuth flow, if that is what started the login). +3. An email domain with no provider gets a friendly "SSO is not configured for this email + domain" toast rather than a raw error. + +## Removing a provider + +There is no UI. Delete the row: + +```sql +DELETE FROM sso_provider WHERE provider_id = 'acme'; +``` + +Existing sessions survive — sign-in method is not re-checked per request. Revoke access +at the IdP (or delete the sessions) if you need the users out immediately. + +## See also + +- `migrations/0020_sso_provider.sql` — the table and why each column exists. +- `src/lib/auth.ts` — plugin configuration and the registration lockdown. +- Password sign-in, the other non-Google method, is documented in the README; accounts + are provisioned with `scripts/create-user.ts`. diff --git a/migrations/0020_sso_provider.sql b/migrations/0020_sso_provider.sql new file mode 100644 index 0000000..11f6f81 --- /dev/null +++ b/migrations/0020_sso_provider.sql @@ -0,0 +1,44 @@ +-- 0020: 企業 SSO(@better-auth/sso plugin)。 +-- +-- 目的:讓客戶/自架者用自己的 IdP 登入,不必人人都有 Google 帳號。登入頁做的是 +-- home realm discovery —— 使用者只填 email,plugin 拿 email 的網域比對下面這張表的 +-- `domain` 欄,命中就導去對應的 IdP。所以「支援哪些網域」是資料,不是程式。 +-- +-- 欄位表照抄 plugin 自己的 schema 宣告(`sso()` 回傳值的 schema.ssoProvider.fields), +-- 沒有多加也沒有少放。plugin 沒有為這個 model 宣告 created_at / updated_at,這裡也就 +-- 沒有 —— 加了 better-auth 也不會去寫它。`domain_verified` 只在 plugin 開啟 +-- `domainVerification` 時才屬於 schema,本專案沒開,故不建。 +-- +-- Forward-only, additive. Run AFTER 0019_contract_billing_plan.sql. +-- "user" 是保留字,故加引號(與其他 auth 表一致)。 +-- +-- ⚠️ 這張表**沒有**對應的 HTTP 寫入口。plugin 的 POST /sso/register 只掛 +-- sessionMiddleware,任何登入中的使用者都能註冊 IdP,等於把「誰能簽發本站身分」 +-- 交出去,所以 src/lib/auth.ts 用 disabledPaths + providersLimit: 0 兩層把它封死。 +-- 註冊一律走 scripts/register-sso-provider.ts(需要 DATABASE_URL)。詳見 docs/sso.md。 + +CREATE TABLE sso_provider ( + id text PRIMARY KEY, + -- IdP 的 OIDC issuer。Cloudflare Access for SaaS 長這樣: + -- https://.cloudflareaccess.com/cdn-cgi/access/sso/oidc/ + issuer text NOT NULL, + -- 整包 OIDC 設定(endpoints、clientId、clientSecret、scopes…)序列化成 JSON + -- 字串存一欄。這是 plugin 的形狀 —— 它自己 JSON.parse 回來用。 + oidc_config text, + -- SAML 走同一張表的另一欄。本專案目前只註冊 OIDC provider。 + saml_config text, + -- 註冊這個 provider 的人。腳本註冊時填「以誰的名義」,也決定了誰能改/刪它 + -- (plugin 的 update/delete 走 checkProviderAccess,比對的就是這一欄)。 + user_id text REFERENCES "user"(id) ON DELETE CASCADE, + -- 對外可見的 provider 代號,也是 callback 路徑的一段: + -- /api/auth/sso/callback/ + provider_id text NOT NULL UNIQUE, + -- 綁定組織(選填)。本次不做組織自動配置,登入後仍走既有的邀請流程。 + organization_id text, + -- home realm discovery 的鍵:email 的網域比對這一欄。 + domain text NOT NULL +); + +-- 每次 SSO 登入都會用網域查一次;plugin 找不到精確相符時才會全表掃描做子網域比對。 +CREATE INDEX idx_sso_provider_domain ON sso_provider(domain); +CREATE INDEX idx_sso_provider_user ON sso_provider(user_id); diff --git a/package.json b/package.json index a222e18..3f15b74 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "cf:typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts" }, "dependencies": { + "@better-auth/sso": "^1.6.14", "@neondatabase/serverless": "^1.1.0", "@opennextjs/cloudflare": "^1.20.3", "better-auth": "^1.6.14", diff --git a/scripts/create-user.ts b/scripts/create-user.ts new file mode 100644 index 0000000..efd0637 --- /dev/null +++ b/scripts/create-user.ts @@ -0,0 +1,118 @@ +/** + * 建立一個 email + 密碼帳號(伺服器端專用)。 + * + * 為什麼需要這支腳本:src/lib/auth.ts 把 `emailAndPassword.disableSignUp` 設成 + * true,也就是 HTTP 上的 POST /api/auth/sign-up/email 一律拒絕。理由寫在那裡 + * ——公開的密碼註冊會開出帳號預劫持的路。所以密碼帳號只能由能拿到 DATABASE_URL + * 的人在伺服器端開,也就是這支腳本。 + * + * 它為什麼繞得過 disableSignUp:那個開關只在 /sign-up/email 這條 route 裡被檢查, + * 不在資料層。這支腳本走的是 `auth.$context` 底下的 internalAdapter —— 與 + * better-auth 官方 admin plugin 的 createUser 完全同一條路徑(見 + * better-auth/dist/plugins/admin/routes.mjs:createUser → password.hash → + * linkAccount,providerId "credential"),只是少了 HTTP 那一層。 + * + * 用法(本機,對 dev DB): + * bun scripts/create-user.ts --email reviewer@example.com \ + * --password 'a-long-passphrase' --name 'Directory Reviewer' + * + * 加上 --org 讓他直接成為某個組織的成員(不必再走邀請流程): + * bun scripts/create-user.ts --email reviewer@example.com \ + * --password 'a-long-passphrase' --name 'Directory Reviewer' \ + * --org my-org-slug --role admin + * + * bun 會自動讀取 .env.local,所以 DATABASE_URL / BETTER_AUTH_SECRET 不必另外帶。 + * 要指定別的環境檔就用 `bun --env-file=.dev.vars scripts/create-user.ts …`。 + * + * ⚠️ 密碼會出現在 shell 歷史裡。正式用途請改用 `--password "$(read -rs …)"` + * 之類的方式,或把它放進環境變數 CREATE_USER_PASSWORD(本腳本也讀這個)。 + */ +import { parseArgs } from "node:util"; +import { auth } from "@/lib/auth"; + +const { values } = parseArgs({ + options: { + email: { type: "string" }, + password: { type: "string" }, + name: { type: "string" }, + org: { type: "string" }, + role: { type: "string", default: "member" }, + }, +}); + +const email = values.email?.trim().toLowerCase(); +const password = values.password ?? process.env.CREATE_USER_PASSWORD; +const name = values.name?.trim(); +const orgSlug = values.org?.trim(); +const role = values.role ?? "member"; + +function fail(message: string): never { + console.error(`✗ ${message}`); + process.exit(1); +} + +if (!email) fail("--email is required"); +if (!password) fail("--password (or CREATE_USER_PASSWORD) is required"); +if (!name) fail("--name is required"); +if (!["owner", "admin", "member"].includes(role)) { + fail(`--role must be one of owner | admin | member (got "${role}")`); +} + +const ctx = await auth.$context; + +// 長度限制從 auth 設定本身讀,不要在這裡再寫一個數字 —— 兩邊各寫一份,遲早會不一致, +// 而且不一致的方向會是「腳本開得出登入頁拒絕的帳號」。 +const minLength = ctx.password.config.minPasswordLength; +if (password.length < minLength) { + fail(`password must be at least ${minLength} characters (got ${password.length})`); +} + +if (await ctx.internalAdapter.findUserByEmail(email)) { + fail(`a user with email ${email} already exists`); +} + +// 先找組織,再建 user:找不到組織就早一點失敗,不要留下一個半成品帳號。 +let organizationId: string | null = null; +if (orgSlug) { + const org = await ctx.adapter.findOne<{ id: string; name: string }>({ + model: "organization", + where: [{ field: "slug", value: orgSlug }], + }); + if (!org) fail(`no organization with slug "${orgSlug}"`); + organizationId = org.id; +} + +const user = await ctx.internalAdapter.createUser({ + email, + name, + // 這個帳號是人工開的,email 由開帳號的人背書 —— 系統裡不該留下一個「永遠等不到 + // 驗證信」的未驗證狀態。也讓之後同一個人改用 Google 登入時能正常連到同一個 user。 + emailVerified: true, +}); + +// providerId 必須正好是 "credential":better-auth 的密碼登入就是照這個字串去 +// account 表找那一列的 password 欄。accountId 用 user.id,與 admin plugin 一致。 +await ctx.internalAdapter.linkAccount({ + accountId: user.id, + providerId: "credential", + userId: user.id, + password: await ctx.password.hash(password), +}); + +console.log(`✓ created user ${user.email} (id ${user.id}, name "${user.name}")`); +console.log(" sign-in method: email + password (account.provider_id = 'credential')"); + +if (organizationId) { + await ctx.adapter.create({ + model: "member", + data: { + organizationId, + userId: user.id, + role, + createdAt: new Date(), + }, + }); + console.log(`✓ added as "${role}" of organization ${orgSlug} (id ${organizationId})`); +} else { + console.log(" no --org given: they will land on /onboarding until they join an org"); +} diff --git a/scripts/register-sso-provider.ts b/scripts/register-sso-provider.ts new file mode 100644 index 0000000..9dae1d8 --- /dev/null +++ b/scripts/register-sso-provider.ts @@ -0,0 +1,189 @@ +/** + * 註冊一個 OIDC 的 SSO provider(伺服器端專用)。 + * + * 為什麼是腳本而不是後台頁面:@better-auth/sso 的 POST /sso/register 只掛了 + * sessionMiddleware —— 任何一個登入中的一般使用者都能註冊一個他自己控制的 IdP, + * 等於把「誰能簽發本站身分」的權力交出去。src/lib/auth.ts 因此用 + * `disabledPaths: ["/sso/register"]` + `sso({ providersLimit: 0 })` 兩層把那條 + * route 封死,註冊只留這一條需要 DATABASE_URL 的路徑。 + * + * disabledPaths 只在 HTTP router 的 onRequest 生效,所以「封死 HTTP」與「腳本 + * 仍能寫入」並不衝突;本腳本直接走 adapter 寫 ssoProvider,寫進去的欄位與 + * /sso/register 成功時寫的完全一致(見 @better-auth/sso 的 buildOIDCConfig)。 + * + * 用法(本機,對 dev DB): + * bun scripts/register-sso-provider.ts \ + * --provider-id acme \ + * --domain acme.com \ + * --issuer "$SSO_ISSUER" \ + * --client-id "$SSO_CLIENT_ID" \ + * --client-secret "$SSO_CLIENT_SECRET" + * + * 每個參數也都能改用環境變數帶(SSO_PROVIDER_ID / SSO_DOMAIN / SSO_ISSUER / + * SSO_CLIENT_ID / SSO_CLIENT_SECRET)—— client secret 建議走這條,不要留在 + * shell 歷史裡。bun 會自動讀 .env.local。 + * + * ── Cloudflare Access for SaaS(OIDC)的對應關係 ──────────────────────────── + * 在 Zero Trust → Access → Applications 建一個 SaaS 應用、type 選 OIDC,建完後 + * Cloudflare 會給三個值: + * + * Cloudflare 給的 這支腳本的參數 + * ───────────────────────────────── ────────────────── + * Client ID --client-id + * Client secret --client-secret + * Issuer / "Public key / OIDC issuer" --issuer + * + * issuer 長這樣( 就是上面那個 Client ID): + * https://.cloudflareaccess.com/cdn-cgi/access/sso/oidc/ + * discovery 文件在 /.well-known/openid-configuration,本腳本會去抓它把 + * 各個 endpoint 寫進 DB —— 這樣之後每次登入都不必再連 discovery。 + * + * 反過來,Cloudflare 那邊的 Redirect URL 要填本站的 SSO callback: + * /api/auth/sso/callback/ + * 腳本跑完會把這個網址印出來。完整步驟見 docs/sso.md。 + */ +import { parseArgs } from "node:util"; +import { DiscoveryError, discoverOIDCConfig } from "@better-auth/sso"; +import { auth } from "@/lib/auth"; + +const { values } = parseArgs({ + options: { + "provider-id": { type: "string" }, + domain: { type: "string" }, + issuer: { type: "string" }, + "client-id": { type: "string" }, + "client-secret": { type: "string" }, + // 這個 provider 的擁有者(email)。給了才有人能透過 HTTP 的 + // /sso/update-provider、/sso/delete-provider 管它;不給就是純腳本管理。 + "owner-email": { type: "string" }, + org: { type: "string" }, + scopes: { type: "string", default: "openid,email,profile" }, + }, +}); + +function fail(message: string): never { + console.error(`✗ ${message}`); + process.exit(1); +} + +const providerId = (values["provider-id"] ?? process.env.SSO_PROVIDER_ID)?.trim(); +const domain = (values.domain ?? process.env.SSO_DOMAIN)?.trim().toLowerCase(); +const issuer = (values.issuer ?? process.env.SSO_ISSUER)?.trim(); +const clientId = (values["client-id"] ?? process.env.SSO_CLIENT_ID)?.trim(); +const clientSecret = values["client-secret"] ?? process.env.SSO_CLIENT_SECRET; +const ownerEmail = values["owner-email"]?.trim().toLowerCase(); +const orgSlug = values.org?.trim(); +const scopes = (values.scopes ?? "openid,email,profile") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + +if (!providerId) fail("--provider-id (or SSO_PROVIDER_ID) is required"); +if (!domain) fail("--domain (or SSO_DOMAIN) is required"); +if (!issuer) fail("--issuer (or SSO_ISSUER) is required"); +if (!clientId) fail("--client-id (or SSO_CLIENT_ID) is required"); +if (!clientSecret) fail("--client-secret (or SSO_CLIENT_SECRET) is required"); + +let issuerOrigin: string; +try { + issuerOrigin = new URL(issuer).origin; +} catch { + fail(`--issuer must be an absolute URL (got "${issuer}")`); +} + +const ctx = await auth.$context; + +if (await ctx.adapter.findOne({ model: "ssoProvider", where: [{ field: "providerId", value: providerId }] })) { + fail(`an SSO provider with providerId "${providerId}" already exists`); +} +if (await ctx.adapter.findOne({ model: "ssoProvider", where: [{ field: "domain", value: domain }] })) { + fail(`an SSO provider is already registered for domain "${domain}"`); +} + +let userId: string | null = null; +if (ownerEmail) { + const found = await ctx.internalAdapter.findUserByEmail(ownerEmail); + if (!found) fail(`no user with email ${ownerEmail} — have them sign in once first`); + userId = found.user.id; +} + +let organizationId: string | null = null; +if (orgSlug) { + const org = await ctx.adapter.findOne<{ id: string }>({ + model: "organization", + where: [{ field: "slug", value: orgSlug }], + }); + if (!org) fail(`no organization with slug "${orgSlug}"`); + organizationId = org.id; +} + +// 現在就把 discovery 文件抓下來、把 endpoints 存進 DB。這不只是省一次往返: +// @better-auth/sso 只有在存起來的設定缺 endpoint 時才會在登入當下重跑 discovery +// (needsRuntimeDiscovery),而那條路徑會拿本站的 trustedOrigins 去驗 IdP 的網址 —— +// 也就是說沒先水合的話,每加一個 IdP 都得改一次 trustedOrigins 才登得進去。 +// +// isTrustedOrigin 這裡只信任 issuer 自己的 origin:discovery 文件是 IdP 給的, +// 不該讓它把 token / jwks endpoint 指到第三方主機(那是一條 SSRF + 憑證外流的路)。 +// 操作者親手打進來的 issuer 才是這裡唯一的信任來源。 +let discovered; +try { + discovered = await discoverOIDCConfig({ + issuer, + isTrustedOrigin: (url) => { + try { + return new URL(url).origin === issuerOrigin; + } catch { + return false; + } + }, + }); +} catch (error) { + if (error instanceof DiscoveryError) { + fail(`OIDC discovery failed for ${issuer}: ${error.message}`); + } + throw error; +} + +// 欄位與 /sso/register 成功時寫進去的完全一致(@better-auth/sso 的 +// buildOIDCConfig,非 skipDiscovery 分支),plugin 讀回來時才不會少東西。 +const oidcConfig = { + issuer: discovered.issuer, + clientId, + clientSecret, + authorizationEndpoint: discovered.authorizationEndpoint, + tokenEndpoint: discovered.tokenEndpoint, + tokenEndpointAuthentication: discovered.tokenEndpointAuthentication, + jwksEndpoint: discovered.jwksEndpoint, + userInfoEndpoint: discovered.userInfoEndpoint, + discoveryEndpoint: discovered.discoveryEndpoint, + pkce: true, + scopes, + overrideUserInfo: false, +}; + +await ctx.adapter.create({ + model: "ssoProvider", + data: { + issuer, + domain, + providerId, + oidcConfig: JSON.stringify(oidcConfig), + samlConfig: null, + userId, + organizationId, + }, +}); + +console.log(`✓ registered SSO provider "${providerId}" for domain ${domain}`); +console.log(` issuer: ${issuer}`); +console.log(` authorization: ${discovered.authorizationEndpoint}`); +console.log(` token: ${discovered.tokenEndpoint}`); +console.log(` jwks: ${discovered.jwksEndpoint}`); +console.log(` scopes: ${scopes.join(" ")}`); +console.log(` owner: ${ownerEmail ?? "(none — script-managed only)"}`); +console.log(` organization: ${orgSlug ?? "(none)"}`); +console.log(""); +console.log("Set this as the Redirect URL in the IdP:"); +console.log(` ${ctx.baseURL}/sso/callback/${providerId}`); +console.log(""); +console.log(`Anyone with an @${domain} email can now use "Continue with SSO" on /login.`); diff --git a/scripts/seed-demo-org.ts b/scripts/seed-demo-org.ts new file mode 100644 index 0000000..75aa5dd --- /dev/null +++ b/scripts/seed-demo-org.ts @@ -0,0 +1,1161 @@ +/** + * 建立一個「資料齊全的示範工作區」,給目錄審核員(OpenAI Plugin Directory / + * Anthropic connector directory)用。 + * + * 為什麼需要這支腳本:兩邊的送審規則都要求附上一組審核帳號,而且那個帳號背後 + * 必須是一個「fully populated / fully featured demo account with sample data」的 + * 工作區 —— 空的工作區會讓 68 支 MCC 工具裡的大半回傳空陣列,審核員無從判斷這個 + * connector 到底能做什麼。這支腳本一次把每一個工具域都鋪上資料。 + * + * 用法(先用 create-user.ts 開好審核帳號,再把他掛成新 org 的 owner): + * bun scripts/create-user.ts --email reviewer@example.com \ + * --password 'a-long-passphrase' --name 'Directory Reviewer' + * bun scripts/seed-demo-org.ts --owner-email reviewer@example.com --org-slug demo + * + * bun 會自動讀取 .env.local,所以 DATABASE_URL 不必另外帶。要指定別的環境檔就用 + * `bun --env-file=.dev.vars scripts/seed-demo-org.ts …`。 + * + * 安全性質(刻意的設計,不要拿掉): + * - 只會**新增**資料,而且每一列都帶著新建立的 organization_id。腳本從頭到尾 + * 不對既有資料下 UPDATE / DELETE。 + * - 目標 slug 已經有 org 就直接中止,不會往既有的示範 org 裡再倒一份資料 + * (那會讓看板出現兩倍的列,比空的還糟)。要再產一份就加 --force,它會改用 + * demo-2 / demo-3 這種尾綴,仍然不碰既有的那一個。 + * + * ⚠️ neon-http 沒有互動式交易(見 src/db/index.ts 與 auth.ts 的 transaction:false), + * 所以中途失敗會留下一個半成品 org。錯誤訊息會印出它的 id 與 slug,砍掉重來即可。 + * + * 資料一律是虛構的:公司名沿用 landing 頁的示範家族(Morningside Creative / + * Verdant Biosciences …),統一編號用 DEMO0001 這種不可能與真實統編相撞的字串, + * 身分證字號與薪轉帳號都是明顯的假值,金額取整數。 + */ +import { parseArgs } from "node:util"; +import { addDays, addMonths, format, startOfMonth } from "date-fns"; +import { eq } from "drizzle-orm"; +import { auth } from "@/lib/auth"; +import { getDb } from "@/db"; +import { splitGross } from "@/lib/vat"; +import { organization } from "@/db/auth-schema"; +import { + accountReconciliations, + activityLog, + bankAccounts, + billingItems, + categories, + contracts, + documents, + employees, + invoices, + parties, + payrollItemTypes, + payrollRuns, + payslipItems, + payslips, + projects, + subscriptionPeriods, + subscriptions, + transactions, +} from "@/db/schema"; + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +const { values } = parseArgs({ + options: { + "owner-email": { type: "string" }, + "org-slug": { type: "string", default: "demo" }, + "org-name": { type: "string", default: "Meridian Software Ltd. (Demo)" }, + force: { type: "boolean", default: false }, + }, +}); + +const ownerEmail = values["owner-email"]?.trim().toLowerCase(); +const requestedSlug = (values["org-slug"] ?? "demo").trim(); +const orgName = (values["org-name"] ?? "").trim(); +const force = values.force === true; + +function fail(message: string): never { + console.error(`✗ ${message}`); + process.exit(1); +} + +if (!ownerEmail) fail("--owner-email is required"); +if (!requestedSlug) fail("--org-slug must not be empty"); +if (!orgName) fail("--org-name must not be empty"); +if (!process.env.DATABASE_URL) fail("DATABASE_URL is not set"); + +/** + * 每一段都包一層,失敗時把「在做哪一段」帶進錯誤訊息 —— 這支腳本插入的表超過 + * 二十張,光看 Postgres 的 "violates foreign key constraint" 完全不知道是誰。 + */ +async function section(label: string, fn: () => Promise): Promise { + try { + return await fn(); + } catch (e) { + const detail = e instanceof Error ? e.message : String(e); + throw new Error(`while seeding ${label}: ${detail}`, { cause: e }); + } +} + +// --------------------------------------------------------------------------- +// 日期工具:全部相對於「今天」算,讓示範資料永遠是「最近半年」而不會過期。 +// --------------------------------------------------------------------------- + +const TODAY = new Date(); +const iso = (d: Date) => format(d, "yyyy-MM-dd"); +/** n 天前 / 後(負數為前) */ +const day = (n: number) => iso(addDays(TODAY, n)); +/** 第 k 個月(0 = 本月)的 1 號 */ +const monthStart = (k: number) => iso(startOfMonth(addMonths(TODAY, k))); +/** 第 k 個月的第 d 天 */ +const monthDay = (k: number, d: number) => iso(addDays(startOfMonth(addMonths(TODAY, k)), d - 1)); +/** + * 第 k 個月的第 d 天,但絕不晚於今天。月初(1~4 號)跑這支腳本時,本月的固定支出 + * 若照原本的日子排會落在未來 —— 一筆「已經付掉」卻標著未來日期的交易看起來就是壞的。 + */ +const pastMonthDay = (k: number, d: number) => { + const target = monthDay(k, d); + const today = iso(TODAY); + if (target > today) return today; + return target; +}; +/** 日期字串 + n 天 */ +const plus = (base: string, n: number) => iso(addDays(new Date(`${base}T00:00:00`), n)); +/** numeric 欄位一律以字串寫入(drizzle 的 numeric 對應 string)。 */ +const dec = (n: number) => n.toFixed(2); +const stamp = (n: number) => addDays(TODAY, n).toISOString(); + +// --------------------------------------------------------------------------- +// 名稱 → id 的查表:多列 INSERT ... RETURNING 的順序在 Postgres 上雖然與輸入 +// 一致,但這裡刻意改用「回傳的名稱」對回去,之後有人插入中間一列也不會錯位。 +// --------------------------------------------------------------------------- + +type NamedRow = { id: number; name: string }; +const byName = (rows: NamedRow[]) => new Map(rows.map((r) => [r.name, r.id])); + +function pick(map: Map, key: string): number { + const id = map.get(key); + if (id === undefined) throw new Error(`internal: seeded row "${key}" was not returned`); + return id; +} + +// --------------------------------------------------------------------------- +// 1. 找出審核帳號 + 建立 organization +// --------------------------------------------------------------------------- + +const ctx = await auth.$context; +const db = getDb(); + +const owner = await ctx.internalAdapter.findUserByEmail(ownerEmail); +if (!owner) { + fail( + `no user with email ${ownerEmail}. Create the reviewer account first:\n` + + ` bun scripts/create-user.ts --email ${ownerEmail} --password '' --name 'Directory Reviewer'`, + ); +} +const ownerUser = owner.user; + +async function freeSlug(base: string): Promise { + for (let i = 1; i < 100; i++) { + const candidate = i === 1 ? base : `${base}-${i}`; + const [taken] = await db + .select({ id: organization.id }) + .from(organization) + .where(eq(organization.slug, candidate)) + .limit(1); + if (!taken) return candidate; + } + throw new Error(`could not find a free slug based on "${base}" after 99 tries`); +} + +const [clash] = await db + .select({ id: organization.id, name: organization.name }) + .from(organization) + .where(eq(organization.slug, requestedSlug)) + .limit(1); + +if (clash && !force) { + fail( + `an organization with slug "${requestedSlug}" already exists ("${clash.name}", id ${clash.id}).\n` + + ` Refusing to seed into it — a second pass would double every row on the billing board.\n` + + ` Re-run with --force to create a fresh org under a suffixed slug instead, or delete that org first.`, + ); +} + +const slug = clash ? await freeSlug(requestedSlug) : requestedSlug; + +const org = await section("organization", async () => { + const created = await ctx.adapter.create< + { name: string; slug: string; createdAt: Date; metadata: string }, + { id: string; name: string; slug: string } + >({ + model: "organization", + data: { + name: orgName, + slug, + createdAt: new Date(), + metadata: JSON.stringify({ demo: true, seededBy: "scripts/seed-demo-org.ts" }), + }, + }); + return created; +}); +const ORG = org.id; + +await section("owner membership", async () => { + await ctx.adapter.create({ + model: "member", + data: { organizationId: ORG, userId: ownerUser.id, role: "owner", createdAt: new Date() }, + }); +}); + +// 這之後每一列都帶 organizationId: ORG。 +const scoped = { organizationId: ORG }; + +// --------------------------------------------------------------------------- +// 2. 會計科目 —— 「薪資費用」的名稱是硬條件:pay_employee_salary 與 +// mutations.ts 都靠這個字串找發薪科目,改名就會變成未分類支出。 +// --------------------------------------------------------------------------- + +const CATEGORY_DEFS: { name: string; kind: string }[] = [ + { name: "專案服務收入", kind: "income" }, + { name: "訂閱收入", kind: "income" }, + { name: "顧問收入", kind: "income" }, + { name: "外包成本", kind: "cogs" }, + { name: "雲端服務成本", kind: "cogs" }, + { name: "薪資費用", kind: "expense" }, + { name: "辦公室租金", kind: "expense" }, + { name: "軟體訂閱", kind: "expense" }, + { name: "差旅交通", kind: "expense" }, + { name: "行銷推廣", kind: "expense" }, + { name: "專業服務費", kind: "expense" }, + { name: "雜項支出", kind: "expense" }, + { name: "利息收入", kind: "non_operating" }, + { name: "匯兌損益", kind: "non_operating" }, + { name: "帳戶互轉", kind: "transfer" }, + { name: "股東往來", kind: "equity" }, +]; + +const cat = await section("categories", async () => + byName( + await db + .insert(categories) + .values(CATEGORY_DEFS.map((c) => ({ ...scoped, name: c.name, kind: c.kind, isActive: true }))) + .returning({ id: categories.id, name: categories.name }), + ), +); + +// --------------------------------------------------------------------------- +// 3. 銀行帳戶(混幣別;帳號尾碼是假的遮罩樣式,schema 沒有帳號欄位所以寫在名稱裡) +// --------------------------------------------------------------------------- + +const ACCT_TWD = "Northgate Bank 台幣營運帳戶 (****4821)"; +const ACCT_USD = "Northgate Bank 美金帳戶 (****7390)"; +const ACCT_CASH = "零用金 Petty Cash"; + +const acct = await section("bank accounts", async () => + byName( + await db + .insert(bankAccounts) + .values([ + { ...scoped, name: ACCT_TWD, kind: "bank", currency: "TWD", openingBalance: dec(1_250_000) }, + { ...scoped, name: ACCT_USD, kind: "bank", currency: "USD", openingBalance: dec(18_000) }, + { ...scoped, name: ACCT_CASH, kind: "cash", currency: "TWD", openingBalance: dec(20_000) }, + ]) + .returning({ id: bankAccounts.id, name: bankAccounts.name }), + ), +); +const twdAcct = pick(acct, ACCT_TWD); +const usdAcct = pick(acct, ACCT_USD); +const cashAcct = pick(acct, ACCT_CASH); + +// --------------------------------------------------------------------------- +// 4. 往來對象(客戶 / 廠商 / 政府機關) +// 統編一律用 DEMO000n —— 八碼但含字母,不可能撞到真實的統一編號。 +// 聯絡信箱用 .example(RFC 2606 保留網域),不會寄到任何真實信箱。 +// --------------------------------------------------------------------------- + +type PartyDef = { + name: string; + label: string; + taxId: string | null; + currency: string; + account: number | null; + contact: string | null; + typical: number | null; +}; + +const PARTY_DEFS: PartyDef[] = [ + { name: "Morningside Creative", label: "customer", taxId: "DEMO0001", currency: "TWD", account: twdAcct, contact: "ap@morningside.example", typical: 60_000 }, + { name: "Verdant Biosciences", label: "customer", taxId: "DEMO0002", currency: "TWD", account: twdAcct, contact: "finance@verdant.example", typical: 260_000 }, + { name: "Harborline Logistics", label: "customer", taxId: "DEMO0003", currency: "TWD", account: twdAcct, contact: "ap@harborline.example", typical: 480_000 }, + { name: "Cobalt Analytics Inc.", label: "customer", taxId: "DEMO0004", currency: "USD", account: usdAcct, contact: "billing@cobalt-analytics.example", typical: 15_000 }, + { name: "Lantern Health Co.", label: "customer", taxId: "DEMO0005", currency: "TWD", account: twdAcct, contact: "accounts@lanternhealth.example", typical: 45_000 }, + { name: "Kestrel Robotics", label: "customer", taxId: "DEMO0006", currency: "TWD", account: twdAcct, contact: "ap@kestrelrobotics.example", typical: 360_000 }, + { name: "Northgate Cloud Services", label: "vendor", taxId: "DEMO0007", currency: "USD", account: usdAcct, contact: "billing@northgatecloud.example", typical: 1_200 }, + { name: "Pinecrest Office Park", label: "vendor", taxId: "DEMO0008", currency: "TWD", account: twdAcct, contact: "leasing@pinecrest.example", typical: 45_000 }, + { name: "Fairweather CPA Office", label: "vendor", taxId: "DEMO0009", currency: "TWD", account: twdAcct, contact: "hello@fairweather-cpa.example", typical: 15_000 }, + { name: "Skyline Travel Agency", label: "vendor", taxId: "DEMO0010", currency: "TWD", account: twdAcct, contact: "corp@skylinetravel.example", typical: 6_000 }, + { name: "Brightfold Studio", label: "vendor", taxId: "DEMO0011", currency: "TWD", account: twdAcct, contact: "studio@brightfold.example", typical: 120_000 }, + { name: "Halcyon Supplies Co.", label: "vendor", taxId: "DEMO0012", currency: "TWD", account: cashAcct, contact: "orders@halcyonsupplies.example", typical: 3_500 }, + { name: "示範市稅捐稽徵處 (Demo City Tax Bureau)", label: "gov", taxId: null, currency: "TWD", account: null, contact: null, typical: null }, +]; + +const party = await section("parties", async () => + byName( + await db + .insert(parties) + .values( + PARTY_DEFS.map((p) => ({ + ...scoped, + name: p.name, + label: p.label, + taxId: p.taxId, + defaultCurrency: p.currency, + defaultAccountId: p.account, + typicalAmount: p.typical == null ? null : dec(p.typical), + contact: p.contact, + note: "示範資料(fictional demo data)", + isActive: true, + })), + ) + .returning({ id: parties.id, name: parties.name }), + ), +); + +const morningside = pick(party, "Morningside Creative"); +const verdant = pick(party, "Verdant Biosciences"); +const harborline = pick(party, "Harborline Logistics"); +const cobalt = pick(party, "Cobalt Analytics Inc."); +const lantern = pick(party, "Lantern Health Co."); +const kestrel = pick(party, "Kestrel Robotics"); +const northgateCloud = pick(party, "Northgate Cloud Services"); +const pinecrest = pick(party, "Pinecrest Office Park"); +const fairweather = pick(party, "Fairweather CPA Office"); +const skyline = pick(party, "Skyline Travel Agency"); +const brightfold = pick(party, "Brightfold Studio"); +const halcyon = pick(party, "Halcyon Supplies Co."); + +// --------------------------------------------------------------------------- +// 5. 專案 +// --------------------------------------------------------------------------- + +const PROJECT_DEFS = [ + { name: "Morningside 品牌官網改版", client: morningside, status: "active", description: "形象官網重新設計與前端實作。" }, + { name: "Verdant 數據儀表板", client: verdant, status: "active", description: "實驗數據儀表板與 API 串接。" }, + { name: "Harborline 排程系統導入", client: harborline, status: "active", description: "倉儲排程系統導入與教育訓練。" }, + { name: "Cobalt Analytics 平台整合", client: cobalt, status: "active", description: "跨區資料平台整合(USD 計價)。" }, + { name: "Lantern Health 試辦計畫", client: lantern, status: "archived", description: "已結案的三個月試辦計畫。" }, +]; + +const project = await section("projects", async () => + byName( + await db + .insert(projects) + .values(PROJECT_DEFS.map((p) => ({ ...scoped, name: p.name, clientPartyId: p.client, status: p.status, description: p.description }))) + .returning({ id: projects.id, name: projects.name }), + ), +); + +const projMorningside = pick(project, "Morningside 品牌官網改版"); +const projVerdant = pick(project, "Verdant 數據儀表板"); +const projHarborline = pick(project, "Harborline 排程系統導入"); +const projCobalt = pick(project, "Cobalt Analytics 平台整合"); +const projLantern = pick(project, "Lantern Health 試辦計畫"); + +// --------------------------------------------------------------------------- +// 6. 合約(狀態涵蓋 draft / active / completed;請款計畫涵蓋 single / installments) +// --------------------------------------------------------------------------- + +type ContractDef = { + title: string; + customer: number; + project: number | null; + amount: number; + currency: string; + signed: string; + start: string; + end: string | null; + terms: number | null; + status: string; + plan: string; + count: number | null; + split: string | null; + interval: number; +}; + +const CONTRACT_DEFS: ContractDef[] = [ + { title: "品牌官網改版合約", customer: morningside, project: projMorningside, amount: 900_000, currency: "TWD", signed: day(-80), start: day(-75), end: day(45), terms: 30, status: "active", plan: "installments", count: 3, split: "30,40,30", interval: 2 }, + { title: "數據儀表板建置合約", customer: verdant, project: projVerdant, amount: 1_200_000, currency: "TWD", signed: day(-65), start: day(-60), end: day(60), terms: 45, status: "active", plan: "installments", count: 2, split: "50,50", interval: 2 }, + { title: "排程系統導入合約", customer: harborline, project: projHarborline, amount: 480_000, currency: "TWD", signed: day(-20), start: day(-12), end: day(90), terms: 45, status: "active", plan: "single", count: 1, split: null, interval: 1 }, + { title: "Analytics Platform Integration", customer: cobalt, project: projCobalt, amount: 60_000, currency: "USD", signed: day(-110), start: day(-100), end: day(200), terms: 30, status: "active", plan: "installments", count: 4, split: "25,25,25,25", interval: 3 }, + { title: "Lantern Health 試辦服務合約", customer: lantern, project: projLantern, amount: 150_000, currency: "TWD", signed: day(-160), start: day(-155), end: day(-70), terms: 30, status: "completed", plan: "single", count: 1, split: null, interval: 1 }, + { title: "Kestrel 平台導入合約(草稿)", customer: kestrel, project: null, amount: 750_000, currency: "TWD", signed: day(-4), start: day(20), end: day(200), terms: 30, status: "draft", plan: "installments", count: 3, split: "30,40,30", interval: 2 }, +]; + +const contract = await section("contracts", async () => { + const rows = await db + .insert(contracts) + .values( + CONTRACT_DEFS.map((c) => ({ + ...scoped, + customerPartyId: c.customer, + projectId: c.project, + title: c.title, + amount: dec(c.amount), + currency: c.currency, + startDate: c.start, + endDate: c.end, + signedDate: c.signed, + paymentTermsDays: c.terms, + status: c.status, + note: "示範資料(fictional demo data)", + billingPlan: c.plan, + installmentCount: c.count, + installmentSplit: c.split, + billingIntervalMonths: c.interval, + dueRule: "signed_date", + dueDay: null, + })), + ) + .returning({ id: contracts.id, title: contracts.title }); + return new Map(rows.map((r) => [r.title, r.id])); +}); + +const ctMorningside = pick(contract, "品牌官網改版合約"); +const ctVerdant = pick(contract, "數據儀表板建置合約"); +const ctHarborline = pick(contract, "排程系統導入合約"); +const ctCobalt = pick(contract, "Analytics Platform Integration"); +const ctLantern = pick(contract, "Lantern Health 試辦服務合約"); + +// --------------------------------------------------------------------------- +// 7. 請款項目(billing_items) +// +// 狀態不是存欄位、是 deriveBillingStatus() 由日期與已收金額推導出來的,所以這裡 +// 的日期組合就是「看板上會看到什麼」的唯一控制點。刻意每一種狀態都湊出至少一列: +// paid 應請款日在 90 天內且已收齊(更久以前的已收齊會被看板濾掉) +// overdue 已請款且「請款日 + 月結天數」已過(合約 A 期中款:-40 + 30 = -10) +// upcoming 應請款日還沒到 +// partial 收了一部分 +// due 應請款日已到但還沒請款 +// billed 已請款、期限未到 +// cancelled 不進看板,但 list_billing_status 之外的工具看得到 +// --------------------------------------------------------------------------- + +type BillingDef = { + title: string; + customer: number; + contract: number | null; + project: number | null; + amount: number; + currency: string; + due: string | null; + billed: string | null; + paid: string | null; + invoiced: string | null; + needsInvoice: boolean; + status: string; + note: string | null; +}; + +const BILLING_DEFS: BillingDef[] = [ + { title: "官網改版 — 簽約金", customer: morningside, contract: ctMorningside, project: projMorningside, amount: 270_000, currency: "TWD", due: day(-75), billed: day(-75), paid: day(-64), invoiced: day(-74), needsInvoice: true, status: "paid", note: "已收齊" }, + { title: "官網改版 — 期中款", customer: morningside, contract: ctMorningside, project: projMorningside, amount: 360_000, currency: "TWD", due: day(-40), billed: day(-40), paid: null, invoiced: day(-39), needsInvoice: true, status: "billed", note: "月結 30 天已過期,待催收" }, + { title: "官網改版 — 尾款", customer: morningside, contract: ctMorningside, project: projMorningside, amount: 270_000, currency: "TWD", due: day(35), billed: null, paid: null, invoiced: null, needsInvoice: true, status: "scheduled", note: null }, + { title: "數據儀表板 — 首期款", customer: verdant, contract: ctVerdant, project: projVerdant, amount: 600_000, currency: "TWD", due: day(-60), billed: day(-58), paid: null, invoiced: day(-57), needsInvoice: true, status: "billed", note: "客戶分兩次匯款" }, + { title: "數據儀表板 — 尾款", customer: verdant, contract: ctVerdant, project: projVerdant, amount: 600_000, currency: "TWD", due: day(-3), billed: null, paid: null, invoiced: null, needsInvoice: true, status: "scheduled", note: "驗收完成,該請款了" }, + { title: "排程系統導入 — 全額", customer: harborline, contract: ctHarborline, project: projHarborline, amount: 480_000, currency: "TWD", due: day(-12), billed: day(-10), paid: null, invoiced: null, needsInvoice: true, status: "billed", note: "月結 45 天" }, + { title: "Analytics Platform — Q1", customer: cobalt, contract: ctCobalt, project: projCobalt, amount: 15_000, currency: "USD", due: day(-100), billed: day(-100), paid: day(-88), invoiced: day(-99), needsInvoice: true, status: "paid", note: null }, + { title: "Analytics Platform — Q2", customer: cobalt, contract: ctCobalt, project: projCobalt, amount: 15_000, currency: "USD", due: day(-8), billed: day(-6), paid: null, invoiced: day(-6), needsInvoice: true, status: "billed", note: null }, + { title: "Analytics Platform — Q3", customer: cobalt, contract: ctCobalt, project: projCobalt, amount: 15_000, currency: "USD", due: day(82), billed: null, paid: null, invoiced: null, needsInvoice: true, status: "scheduled", note: null }, + { title: "Analytics Platform — Q4", customer: cobalt, contract: ctCobalt, project: projCobalt, amount: 15_000, currency: "USD", due: day(174), billed: null, paid: null, invoiced: null, needsInvoice: true, status: "scheduled", note: null }, + { title: "試辦服務 — 全額", customer: lantern, contract: ctLantern, project: projLantern, amount: 150_000, currency: "TWD", due: day(-150), billed: day(-150), paid: day(-140), invoiced: day(-149), needsInvoice: true, status: "paid", note: "試辦計畫結案" }, + { title: "臨時請款 — 內容授權費", customer: lantern, contract: null, project: null, amount: 45_000, currency: "TWD", due: day(-20), billed: day(-18), paid: null, invoiced: day(-18), needsInvoice: true, status: "billed", note: "無合約的單筆請款" }, + { title: "臨時請款 — 教育訓練(已取消)", customer: kestrel, contract: null, project: null, amount: 30_000, currency: "TWD", due: day(20), billed: null, paid: null, invoiced: null, needsInvoice: false, status: "cancelled", note: "客戶延後,改期再開" }, +]; + +const billing = await section("billing items", async () => { + const rows = await db + .insert(billingItems) + .values( + BILLING_DEFS.map((b) => ({ + ...scoped, + customerPartyId: b.customer, + contractId: b.contract, + projectId: b.project, + title: b.title, + amount: dec(b.amount), + currency: b.currency, + dueDate: b.due, + billedOn: b.billed, + paidOn: b.paid, + invoicedOn: b.invoiced, + needsInvoice: b.needsInvoice, + status: b.status, + note: b.note, + })), + ) + .returning({ id: billingItems.id, title: billingItems.title }); + return new Map(rows.map((r) => [r.title, r.id])); +}); + +const biMorningsideSigning = pick(billing, "官網改版 — 簽約金"); +const biVerdantFirst = pick(billing, "數據儀表板 — 首期款"); +const biCobaltQ1 = pick(billing, "Analytics Platform — Q1"); +const biLanternFull = pick(billing, "試辦服務 — 全額"); +const biHarborlineFull = pick(billing, "排程系統導入 — 全額"); + +// --------------------------------------------------------------------------- +// 8. 訂閱 + 期別覆寫 +// +// 訂閱的期別是即時算出來的(computeSubscriptionSchedule),不落地成資料列; +// subscription_periods 只記「人做了什麼」——覆寫金額、實際請款日、開票日。 +// --------------------------------------------------------------------------- + +const SUB_VERDANT = "Verdant 數據儀表板維運"; +const SUB_KESTREL = "Kestrel Robotics 平台年費"; +const SUB_COBALT = "Cobalt Analytics 平台訂閱"; +const SUB_LANTERN = "Lantern Health 試用方案"; +const SUB_MORNINGSIDE = "Morningside 網站維運"; + +const sub = await section("subscriptions", async () => + byName( + await db + .insert(subscriptions) + .values([ + { ...scoped, customerPartyId: verdant, projectId: projVerdant, name: SUB_VERDANT, amount: dec(45_000), currency: "TWD", intervalMonths: 1, startDate: monthStart(-6), endDate: null, status: "active", note: "月繳維運合約" }, + { ...scoped, customerPartyId: kestrel, projectId: null, name: SUB_KESTREL, amount: dec(360_000), currency: "TWD", intervalMonths: 12, startDate: monthStart(-2), endDate: null, status: "active", note: "年繳平台授權" }, + { ...scoped, customerPartyId: cobalt, projectId: projCobalt, name: SUB_COBALT, amount: dec(2_500), currency: "USD", intervalMonths: 1, startDate: monthStart(-4), endDate: null, status: "active", note: "USD 月繳" }, + { ...scoped, customerPartyId: lantern, projectId: projLantern, name: SUB_LANTERN, amount: dec(12_000), currency: "TWD", intervalMonths: 1, startDate: monthStart(-9), endDate: monthStart(-6), status: "ended", note: "試用結束未續約" }, + { ...scoped, customerPartyId: morningside, projectId: projMorningside, name: SUB_MORNINGSIDE, amount: dec(18_000), currency: "TWD", intervalMonths: 3, startDate: monthStart(-5), endDate: null, status: "paused", note: "客戶要求暫停一季" }, + ]) + .returning({ id: subscriptions.id, name: subscriptions.name }), + ), +); + +const subVerdant = pick(sub, SUB_VERDANT); +const subKestrel = pick(sub, SUB_KESTREL); +const subCobalt = pick(sub, SUB_COBALT); +const subMorningside = pick(sub, SUB_MORNINGSIDE); + +const subPeriodCount = await section("subscription periods", async () => { + const rows = await db + .insert(subscriptionPeriods) + .values([ + // 過渡期只收半個月:expectedAmount 覆寫,對應的收款交易也只有 22,500。 + { ...scoped, subscriptionId: subVerdant, periodStart: monthStart(-5), expectedAmount: dec(22_500), dueDate: monthDay(-5, 10), billedOn: monthDay(-5, 3), invoicedOn: monthDay(-5, 3), note: "過渡期只收半個月" }, + // 上個月已請款、已開票,但錢還沒進來 → 看板上是 overdue。 + { ...scoped, subscriptionId: subVerdant, periodStart: monthStart(-1), expectedAmount: null, dueDate: monthDay(-1, 10), billedOn: monthDay(-1, 3), invoicedOn: monthDay(-1, 3), note: null }, + // 年費:已請款、期限還沒到 → 看板上是 billed。 + { ...scoped, subscriptionId: subKestrel, periodStart: monthStart(-2), expectedAmount: null, dueDate: day(15), billedOn: day(-2), invoicedOn: null, note: "年費請款單已寄出" }, + // 本期只記應請款日,不覆寫金額。 + { ...scoped, subscriptionId: subCobalt, periodStart: monthStart(0), expectedAmount: null, dueDate: monthDay(0, 10), billedOn: null, invoicedOn: null, note: null }, + ]) + .returning({ id: subscriptionPeriods.id }); + return rows.length; +}); + +// --------------------------------------------------------------------------- +// 9. 員工(身分證字號與薪轉帳號都是明顯的假值;MCP 回傳前還會再遮罩一次) +// --------------------------------------------------------------------------- + +type EmployeeDef = { + name: string; + type: string; + nationalId: string; + base: number; + account: string; + start: string; + end: string | null; + active: boolean; + pension: boolean; + email: string; + phone: string; +}; + +const EMPLOYEE_DEFS: EmployeeDef[] = [ + { name: "陳品妤 (Chen Pin-Yu)", type: "full_time", nationalId: "A299000011", base: 68_000, account: "00000000010011", start: day(-780), end: null, active: true, pension: true, email: "pinyu@meridian-demo.example", phone: "02-0000-0011" }, + { name: "林承澔 (Lin Cheng-Hao)", type: "full_time", nationalId: "A199000022", base: 82_000, account: "00000000010022", start: day(-1100), end: null, active: true, pension: true, email: "chenghao@meridian-demo.example", phone: "02-0000-0022" }, + { name: "宋子昀 (Avery Sung)", type: "full_time", nationalId: "A299000033", base: 95_000, account: "00000000010033", start: day(-430), end: null, active: true, pension: true, email: "avery@meridian-demo.example", phone: "02-0000-0033" }, + { name: "黃思羽 (Huang Si-Yu)", type: "part_time", nationalId: "A299000044", base: 32_000, account: "00000000010044", start: day(-200), end: null, active: true, pension: false, email: "siyu@meridian-demo.example", phone: "02-0000-0044" }, + { name: "吳冠廷 (Wu Kuan-Ting)", type: "contractor", nationalId: "A199000055", base: 55_000, account: "00000000010055", start: day(-600), end: day(-60), active: false, pension: false, email: "kuanting@meridian-demo.example", phone: "02-0000-0055" }, +]; + +const employee = await section("employees", async () => + byName( + await db + .insert(employees) + .values( + EMPLOYEE_DEFS.map((e) => ({ + ...scoped, + name: e.name, + nationalId: e.nationalId, + employmentType: e.type, + hasLaborInsurance: e.active, + hasHealthInsurance: e.active, + hasPension: e.pension, + baseSalary: dec(e.base), + laborInsuredSalary: dec(e.base), + healthInsuredSalary: dec(e.base), + salaryAccount: e.account, + startDate: e.start, + endDate: e.end, + isActive: e.active, + workEmail: e.email, + personalEmail: null, + phone: e.phone, + note: "示範資料(fictional demo data)", + userId: null, + })), + ) + .returning({ id: employees.id, name: employees.name }), + ), +); + +const PAYROLL_EMPLOYEES = EMPLOYEE_DEFS.filter((e) => e.active); + +// --------------------------------------------------------------------------- +// 10. 發票(開立 / 取得 × valid / void / allowance × Simpany 各種同步狀態) +// --------------------------------------------------------------------------- + +type InvoiceDef = { + number: string; + direction: string; + date: string; + party: number; + partyName: string; + taxId: string | null; + gross: number; + currency: string; + status: string; + external: string; + externalRef: string | null; + contract: number | null; + billingItem: number | null; + note: string | null; +}; + +const INVOICE_DEFS: InvoiceDef[] = [ + { number: "DM-2001", direction: "issued", date: day(-74), party: morningside, partyName: "Morningside Creative", taxId: "DEMO0001", gross: 270_000, currency: "TWD", status: "valid", external: "issued", externalRef: "SIM-DEMO-2001", contract: ctMorningside, billingItem: biMorningsideSigning, note: "簽約金發票" }, + { number: "DM-2002", direction: "issued", date: day(-57), party: verdant, partyName: "Verdant Biosciences", taxId: "DEMO0002", gross: 600_000, currency: "TWD", status: "valid", external: "issued", externalRef: "SIM-DEMO-2002", contract: ctVerdant, billingItem: biVerdantFirst, note: "首期款發票" }, + { number: "DM-2003", direction: "issued", date: day(-2), party: harborline, partyName: "Harborline Logistics", taxId: "DEMO0003", gross: 480_000, currency: "TWD", status: "valid", external: "pending", externalRef: null, contract: ctHarborline, billingItem: biHarborlineFull, note: "尚未在外部發票系統開立" }, + { number: "DM-2004", direction: "issued", date: day(-35), party: verdant, partyName: "Verdant Biosciences", taxId: "DEMO0002", gross: 45_000, currency: "TWD", status: "void", external: "void", externalRef: "SIM-DEMO-2004", contract: null, billingItem: null, note: "統編key錯,作廢重開" }, + { number: "DM-2005", direction: "issued", date: day(-25), party: morningside, partyName: "Morningside Creative", taxId: "DEMO0001", gross: 15_000, currency: "TWD", status: "allowance", external: "issued", externalRef: "SIM-DEMO-2005", contract: null, billingItem: null, note: "折讓單(範圍縮減)" }, + { number: "DM-2006", direction: "issued", date: day(-140), party: lantern, partyName: "Lantern Health Co.", taxId: "DEMO0005", gross: 150_000, currency: "TWD", status: "valid", external: "issued", externalRef: "SIM-DEMO-2006", contract: ctLantern, billingItem: biLanternFull, note: "試辦計畫結案發票" }, + { number: "DM-7001", direction: "received", date: day(-30), party: pinecrest, partyName: "Pinecrest Office Park", taxId: "DEMO0008", gross: 45_000, currency: "TWD", status: "valid", external: "n_a", externalRef: null, contract: null, billingItem: null, note: "辦公室租金進項" }, + { number: "DM-7002", direction: "received", date: day(-20), party: northgateCloud, partyName: "Northgate Cloud Services", taxId: "DEMO0007", gross: 1_200, currency: "USD", status: "valid", external: "n_a", externalRef: null, contract: null, billingItem: null, note: "境外雲端服務,無進項稅額" }, +]; + +const invoice = await section("invoices", async () => { + const rows = await db + .insert(invoices) + .values( + INVOICE_DEFS.map((v) => { + // 台灣營業稅 5%;外幣進項沒有可扣抵稅額,稅額記 0。 + const { net, tax } = v.currency === "TWD" ? splitGross(v.gross) : { net: v.gross, tax: 0 }; + return { + ...scoped, + direction: v.direction, + invoiceNumber: v.number, + invoiceDate: v.date, + counterpartyName: v.partyName, + counterpartyTaxId: v.taxId, + amountNet: dec(net), + tax: dec(tax), + amountGross: dec(v.gross), + currency: v.currency, + status: v.status, + note: v.note, + partyId: v.party, + contractId: v.contract, + billingItemId: v.billingItem, + externalStatus: v.external, + externalRef: v.externalRef, + }; + }), + ) + .returning({ id: invoices.id, number: invoices.invoiceNumber }); + return new Map(rows.map((r) => [r.number ?? "", r.id])); +}); + +const invMorningsideSigning = pick(invoice, "DM-2001"); +const invVerdantFirst = pick(invoice, "DM-2002"); +const invRent = pick(invoice, "DM-7001"); +const invCloud = pick(invoice, "DM-7002"); + +// --------------------------------------------------------------------------- +// 11. 交易 +// +// 兩批:第一批是不需要回頭引用 id 的「帳本噪音」,第二批(下面)是要被 +// payslips / documents / 撥款還代墊 指回來的少數幾筆,用不重複的 description 對回 id。 +// +// 不變式(照 src/lib/account-currency.ts):交易幣別必須等於它掛的銀行帳戶幣別。 +// income → toAccountId;expense → fromAccountId;advance → 不掛帳戶;transfer → 兩腳。 +// amount_twd 只在 TWD 時填,比照 create_transaction 與 mutations.ts 的行為。 +// --------------------------------------------------------------------------- + +type TxnDef = { + type: string; + date: string; + amount: number; + currency: string; + description: string; + category: string | null; + party: number | null; + from: number | null; + to: number | null; + book: string; + project?: number | null; + contract?: number | null; + subscription?: number | null; + period?: string | null; + billingItem?: number | null; + invoice?: number | null; + employee?: number | null; + related?: number | null; + vat?: boolean; +}; + +function txnValues(t: TxnDef) { + return { + ...scoped, + txnDate: t.date, + description: t.description, + categoryId: t.category == null ? null : pick(cat, t.category), + amount: dec(t.amount), + currency: t.currency, + amountTwd: t.currency === "TWD" ? dec(t.amount) : null, + fromAccountId: t.from, + toAccountId: t.to, + book: t.book, + invoiceId: t.invoice ?? null, + billedToCompanyTaxId: t.vat ?? false, + partyId: t.party, + settleEmployeeId: t.employee ?? null, + relatedToId: t.related ?? null, + type: t.type, + projectId: t.project ?? null, + contractId: t.contract ?? null, + subscriptionId: t.subscription ?? null, + subscriptionPeriod: t.period ?? null, + billingItemId: t.billingItem ?? null, + }; +} + +const bulkTxns: TxnDef[] = [ + // ---- 合約收款 ---- + { type: "income", date: day(-64), amount: 270_000, currency: "TWD", description: "官網改版簽約金", category: "專案服務收入", party: morningside, from: null, to: twdAcct, book: "both", project: projMorningside, contract: ctMorningside, billingItem: biMorningsideSigning, invoice: invMorningsideSigning }, + { type: "income", date: day(-45), amount: 250_000, currency: "TWD", description: "數據儀表板首期款(部分入帳)", category: "專案服務收入", party: verdant, from: null, to: twdAcct, book: "both", project: projVerdant, contract: ctVerdant, billingItem: biVerdantFirst, invoice: invVerdantFirst }, + { type: "income", date: day(-88), amount: 15_000, currency: "USD", description: "Analytics Platform Q1 payment", category: "專案服務收入", party: cobalt, from: null, to: usdAcct, book: "both", project: projCobalt, contract: ctCobalt, billingItem: biCobaltQ1 }, + { type: "income", date: day(-140), amount: 150_000, currency: "TWD", description: "Lantern Health 試辦計畫結案款", category: "專案服務收入", party: lantern, from: null, to: twdAcct, book: "both", project: projLantern, contract: ctLantern, billingItem: biLanternFull }, + + // ---- 訂閱收款(掛到各自的期別)---- + ...[-6, -5, -4, -3, -2].map((k) => ({ + type: "income", + date: monthDay(k, 8), + amount: k === -5 ? 22_500 : 45_000, + currency: "TWD", + description: `${SUB_VERDANT} ${monthStart(k).slice(0, 7)} 月費`, + category: "訂閱收入", + party: verdant, + from: null, + to: twdAcct, + book: "both", + project: projVerdant, + subscription: subVerdant, + period: monthStart(k), + })), + ...[-4, -3, -2].map((k) => ({ + type: "income", + date: monthDay(k, 6), + amount: 2_500, + currency: "USD", + description: `${SUB_COBALT} ${monthStart(k).slice(0, 7)}`, + category: "訂閱收入", + party: cobalt, + from: null, + to: usdAcct, + book: "both", + project: projCobalt, + subscription: subCobalt, + period: monthStart(k), + })), + ...[-5, -2].map((k) => ({ + type: "income", + date: monthDay(k, 12), + amount: 18_000, + currency: "TWD", + description: `${SUB_MORNINGSIDE} ${monthStart(k).slice(0, 7)} 季費`, + category: "訂閱收入", + party: morningside, + from: null, + to: twdAcct, + book: "both", + project: projMorningside, + subscription: subMorningside, + period: monthStart(k), + })), + + // ---- 其他收入(book=internal:只進內帳,沒有上外帳)---- + { type: "income", date: day(-30), amount: 60_000, currency: "TWD", description: "Harborline 顧問時數(未開發票)", category: "顧問收入", party: harborline, from: null, to: twdAcct, book: "internal", project: projHarborline }, + { type: "income", date: day(-15), amount: 850, currency: "TWD", description: "活存利息", category: "利息收入", party: null, from: null, to: twdAcct, book: "both" }, + + // ---- 固定支出:房租(6 個月)---- + ...[-5, -4, -3, -2, -1, 0].map((k) => ({ + type: "expense", + date: pastMonthDay(k, 5), + amount: 45_000, + currency: "TWD", + description: `${monthStart(k).slice(0, 7)} 辦公室租金`, + category: "辦公室租金", + party: pinecrest, + from: twdAcct, + to: null, + book: "both", + vat: true, + invoice: k === -1 ? invRent : null, + })), + + // ---- 固定支出:雲端(USD,6 個月)---- + ...[-5, -4, -3, -2, -1, 0].map((k) => ({ + type: "expense", + date: pastMonthDay(k, 3), + amount: 1_200, + currency: "USD", + description: `${monthStart(k).slice(0, 7)} Northgate Cloud hosting`, + category: "雲端服務成本", + party: northgateCloud, + from: usdAcct, + to: null, + book: "both", + invoice: k === -1 ? invCloud : null, + })), + + // ---- 其餘營運支出 ---- + { type: "expense", date: day(-95), amount: 8_500, currency: "TWD", description: "設計工具年費", category: "軟體訂閱", party: brightfold, from: twdAcct, to: null, book: "both", vat: true }, + { type: "expense", date: day(-55), amount: 8_500, currency: "TWD", description: "專案管理工具授權", category: "軟體訂閱", party: brightfold, from: twdAcct, to: null, book: "both", vat: true }, + { type: "expense", date: day(-70), amount: 120_000, currency: "TWD", description: "官網改版前端外包", category: "外包成本", party: brightfold, from: twdAcct, to: null, book: "both", project: projMorningside, contract: ctMorningside, vat: true }, + { type: "expense", date: day(-40), amount: 120_000, currency: "TWD", description: "儀表板圖表外包", category: "外包成本", party: brightfold, from: twdAcct, to: null, book: "both", project: projVerdant, contract: ctVerdant, vat: true }, + { type: "expense", date: day(-80), amount: 25_000, currency: "TWD", description: "產品說明會場地與素材", category: "行銷推廣", party: halcyon, from: twdAcct, to: null, book: "external" }, + { type: "expense", date: day(-25), amount: 25_000, currency: "TWD", description: "社群廣告投放", category: "行銷推廣", party: halcyon, from: twdAcct, to: null, book: "external" }, + { type: "expense", date: day(-100), amount: 15_000, currency: "TWD", description: "上期營業稅申報服務費", category: "專業服務費", party: fairweather, from: twdAcct, to: null, book: "both", vat: true }, + { type: "expense", date: day(-35), amount: 15_000, currency: "TWD", description: "本期營業稅申報服務費", category: "專業服務費", party: fairweather, from: twdAcct, to: null, book: "both", vat: true }, + { type: "expense", date: day(-18), amount: 3_500, currency: "TWD", description: "辦公室補給品", category: "雜項支出", party: halcyon, from: cashAcct, to: null, book: "internal" }, + { type: "expense", date: day(-6), amount: 3_500, currency: "TWD", description: "會議茶水與耗材", category: "雜項支出", party: halcyon, from: cashAcct, to: null, book: "internal" }, + + // ---- 帳戶互轉(transfer 一律 book=both)---- + { type: "transfer", date: day(-60), amount: 100_000, currency: "TWD", description: "撥補零用金", category: "帳戶互轉", party: null, from: twdAcct, to: cashAcct, book: "both" }, + { type: "transfer", date: day(-10), amount: 50_000, currency: "TWD", description: "撥補零用金", category: "帳戶互轉", party: null, from: twdAcct, to: cashAcct, book: "both" }, +]; + +const bulkTxnCount = await section("transactions (bulk)", async () => { + const rows = await db + .insert(transactions) + .values(bulkTxns.map(txnValues)) + .returning({ id: transactions.id }); + return rows.length; +}); + +// 需要被回頭引用的交易:description 在這個 org 內唯一,用它對回 id。 +const empIds = PAYROLL_EMPLOYEES.map((e) => ({ def: e, id: pick(employee, e.name) })); +const PAID_RUN_OFFSETS = [-2, -1]; + +const SALARY_DESC = (k: number, name: string) => `${monthStart(k).slice(0, 7)} 薪資 - ${name}`; +/** + * 發薪日:當月 25 號(薪資月份本身,不是次月)。 + * 刻意不用「次月 5 號」——那樣在每個月的 1~4 號跑這支腳本時,最近一期「已發放」 + * 的薪資會帶著未來的日期,示範資料看起來就像壞掉的。 + */ +const PAY_DAY = (k: number) => monthDay(k, 25); +const ADV_TRAVEL_DESC = "代墊:客戶拜訪計程車資"; +const ADV_SUPPLY_DESC = "代墊:展場臨時採購"; +const DOC_TAXI_DESC = "客戶拜訪車資(紙本收據)"; +const DOC_MEAL_DESC = "專案結案聚餐(三聯式發票)"; + +/** 示範用的固定扣除額,湊出「應稅 / 免稅 / 各項扣除」都有的薪資單。 */ +function deductions(base: number) { + const labor = Math.round((base * 0.02) / 100) * 100; + const health = Math.round((base * 0.015) / 100) * 100; + const tax = Math.round((base * 0.03) / 100) * 100; + return { labor, health, tax, total: labor + health + tax }; +} +const MEAL_ALLOWANCE = 2_400; +function netPay(base: number): number { + return base + MEAL_ALLOWANCE - deductions(base).total; +} + +const trackedTxns: TxnDef[] = [ + // 發薪支出(每位在職員工 × 已發放的兩個月) + ...PAID_RUN_OFFSETS.flatMap((k) => + empIds.map(({ def, id }) => ({ + type: "expense", + date: PAY_DAY(k), + amount: netPay(def.base), + currency: "TWD", + description: SALARY_DESC(k, def.name), + category: "薪資費用", + party: null, + from: twdAcct, + to: null, + book: "both", + employee: id, + })), + ), + // 代墊(advance 不掛帳戶):一筆未還、一筆下面會撥款 + { type: "advance", date: day(-9), amount: 3_800, currency: "TWD", description: ADV_TRAVEL_DESC, category: "差旅交通", party: skyline, from: null, to: null, book: "internal", employee: empIds[1].id }, + { type: "advance", date: day(-50), amount: 6_500, currency: "TWD", description: ADV_SUPPLY_DESC, category: "雜項支出", party: halcyon, from: null, to: null, book: "internal", employee: empIds[0].id }, + // 有紙本/三聯式發票要通知會計師的兩筆支出 + { type: "expense", date: day(-22), amount: 1_200, currency: "TWD", description: DOC_TAXI_DESC, category: "差旅交通", party: skyline, from: cashAcct, to: null, book: "both", vat: false }, + { type: "expense", date: day(-5), amount: 4_800, currency: "TWD", description: DOC_MEAL_DESC, category: "雜項支出", party: halcyon, from: cashAcct, to: null, book: "both", vat: true }, +]; + +const tracked = await section("transactions (tracked)", async () => { + const rows = await db + .insert(transactions) + .values(trackedTxns.map(txnValues)) + .returning({ id: transactions.id, description: transactions.description }); + return new Map(rows.map((r) => [r.description ?? "", r.id])); +}); + +const advSupplyId = pick(tracked, ADV_SUPPLY_DESC); + +// 撥款還代墊:relatedToId 指回原代墊,所以必須等上一批拿到 id 之後才能插。 +// 只還其中一筆 —— 另一筆留在 list_outstanding_advances 上。 +const reimbursementCount = await section("reimbursement", async () => { + const rows = await db + .insert(transactions) + .values( + txnValues({ + type: "reimbursement", + date: day(-44), + amount: 6_500, + currency: "TWD", + description: "撥款還代墊", + category: "雜項支出", + party: null, + from: twdAcct, + to: null, + book: "internal", + employee: empIds[0].id, + related: advSupplyId, + }), + ) + .returning({ id: transactions.id }); + return rows.length; +}); + +// --------------------------------------------------------------------------- +// 12. 薪資:項目類型 → 薪資期別 → 薪資單 → 明細 +// --------------------------------------------------------------------------- + +const ITEM_BASE = "本薪"; +const ITEM_MEAL = "伙食津貼"; +const ITEM_LABOR = "勞保費"; +const ITEM_HEALTH = "健保費"; +const ITEM_TAX = "代扣所得稅"; + +const itemType = await section("payroll item types", async () => + byName( + await db + .insert(payrollItemTypes) + .values([ + { ...scoped, name: ITEM_BASE, direction: "earning", isTaxable: true, isStatutory: false, note: null }, + { ...scoped, name: ITEM_MEAL, direction: "earning", isTaxable: false, isStatutory: false, note: "免稅上限內的伙食津貼" }, + { ...scoped, name: "加班費", direction: "earning", isTaxable: true, isStatutory: false, note: null }, + { ...scoped, name: ITEM_LABOR, direction: "deduction", isTaxable: false, isStatutory: true, note: null }, + { ...scoped, name: ITEM_HEALTH, direction: "deduction", isTaxable: false, isStatutory: true, note: null }, + { ...scoped, name: "勞退自提", direction: "deduction", isTaxable: false, isStatutory: true, note: null }, + { ...scoped, name: ITEM_TAX, direction: "deduction", isTaxable: false, isStatutory: true, note: null }, + ]) + .returning({ id: payrollItemTypes.id, name: payrollItemTypes.name }), + ), +); + +/** 薪資期別:前兩個月已發放(狀態 paid),本月還是草稿(未發放)。 */ +const RUN_DEFS = [ + { offset: -2, status: "paid", note: "已完成發放" }, + { offset: -1, status: "paid", note: "已完成發放" }, + { offset: 0, status: "draft", note: "本月尚未發放" }, +]; + +const run = await section("payroll runs", async () => { + const rows = await db + .insert(payrollRuns) + .values( + RUN_DEFS.map((r) => { + const d = addMonths(TODAY, r.offset); + return { + ...scoped, + periodYear: d.getFullYear(), + periodMonth: d.getMonth() + 1, + payDate: PAY_DAY(r.offset), + status: r.status, + note: r.note, + }; + }), + ) + .returning({ id: payrollRuns.id, periodYear: payrollRuns.periodYear, periodMonth: payrollRuns.periodMonth }); + return new Map(rows.map((r) => [`${r.periodYear}-${r.periodMonth}`, r.id])); +}); + +function runKey(offset: number): string { + const d = addMonths(TODAY, offset); + return `${d.getFullYear()}-${d.getMonth() + 1}`; +} + +const payslipIds = await section("payslips", async () => { + const values = RUN_DEFS.flatMap((r) => + empIds.map(({ def, id }) => { + const d = deductions(def.base); + const paidTxn = PAID_RUN_OFFSETS.includes(r.offset) + ? (tracked.get(SALARY_DESC(r.offset, def.name)) ?? null) + : null; + return { + payrollRunId: pick(run, runKey(r.offset)), + employeeId: id, + taxableTotal: dec(def.base), + nontaxableTotal: dec(MEAL_ALLOWANCE), + deductionTotal: dec(d.total), + netPay: dec(netPay(def.base)), + paidTransactionId: paidTxn, + note: paidTxn ? null : "尚未發放", + }; + }), + ); + const rows = await db + .insert(payslips) + .values(values) + .returning({ id: payslips.id, payrollRunId: payslips.payrollRunId, employeeId: payslips.employeeId }); + return new Map(rows.map((r) => [`${r.payrollRunId}:${r.employeeId}`, r.id])); +}); + +const payslipItemCount = await section("payslip items", async () => { + const values = RUN_DEFS.flatMap((r) => + empIds.flatMap(({ def, id }) => { + const slipId = payslipIds.get(`${pick(run, runKey(r.offset))}:${id}`); + if (slipId === undefined) throw new Error(`internal: payslip for ${def.name} ${runKey(r.offset)} missing`); + const d = deductions(def.base); + return [ + { payslipId: slipId, itemTypeId: pick(itemType, ITEM_BASE), name: ITEM_BASE, direction: "earning", isTaxable: true, amount: dec(def.base), hours: null }, + { payslipId: slipId, itemTypeId: pick(itemType, ITEM_MEAL), name: ITEM_MEAL, direction: "earning", isTaxable: false, amount: dec(MEAL_ALLOWANCE), hours: null }, + { payslipId: slipId, itemTypeId: pick(itemType, ITEM_LABOR), name: ITEM_LABOR, direction: "deduction", isTaxable: false, amount: dec(d.labor), hours: null }, + { payslipId: slipId, itemTypeId: pick(itemType, ITEM_HEALTH), name: ITEM_HEALTH, direction: "deduction", isTaxable: false, amount: dec(d.health), hours: null }, + { payslipId: slipId, itemTypeId: pick(itemType, ITEM_TAX), name: ITEM_TAX, direction: "deduction", isTaxable: false, amount: dec(d.tax), hours: null }, + ]; + }), + ); + const rows = await db.insert(payslipItems).values(values).returning({ id: payslipItems.id }); + return rows.length; +}); + +// --------------------------------------------------------------------------- +// 13. 銀行對帳(每個帳戶 × 月底結存) +// --------------------------------------------------------------------------- + +const reconciliationCount = await section("reconciliations", async () => { + const lastMonthEnd = plus(monthStart(0), -1); + const prevMonthEnd = plus(monthStart(-1), -1); + const rows = await db + .insert(accountReconciliations) + .values([ + { ...scoped, accountId: twdAcct, asOfDate: lastMonthEnd, statementBalance: dec(1_486_500), note: "與銀行對帳單相符" }, + { ...scoped, accountId: twdAcct, asOfDate: prevMonthEnd, statementBalance: dec(1_312_000), note: "與銀行對帳單相符" }, + { ...scoped, accountId: usdAcct, asOfDate: lastMonthEnd, statementBalance: dec(23_400), note: "USD 帳戶月結" }, + { ...scoped, accountId: cashAcct, asOfDate: lastMonthEnd, statementBalance: dec(41_800), note: "零用金盤點,差異 0" }, + ]) + .returning({ id: accountReconciliations.id }); + return rows.length; +}); + +// --------------------------------------------------------------------------- +// 14. 憑證:list_accountant_notices 看的是「invoice_kind = paper 且掛在交易上」的 +// documents —— 一筆待通知、一筆已通知。R2 裡沒有對應的實體檔案(示範資料只需要 +// metadata),note 有寫清楚,下載會 404 是預期行為。 +// --------------------------------------------------------------------------- + +const documentCount = await section("documents", async () => { + const placeholder = "示範資料:僅有 metadata,R2 內沒有對應檔案。"; + const rows = await db + .insert(documents) + .values([ + { ...scoped, docType: "invoice", r2Key: `demo/${ORG}/paper-invoice-taxi.pdf`, fileName: "計程車收據.pdf", contentType: "application/pdf", sizeBytes: 48_120, transactionId: pick(tracked, DOC_TAXI_DESC), invoiceId: null, invoiceKind: "paper", accountantNotifiedAt: null, note: `待通知會計師。${placeholder}` }, + { ...scoped, docType: "invoice", r2Key: `demo/${ORG}/paper-invoice-meal.pdf`, fileName: "三聯式發票-結案聚餐.pdf", contentType: "application/pdf", sizeBytes: 61_540, transactionId: pick(tracked, DOC_MEAL_DESC), invoiceId: null, invoiceKind: "paper", accountantNotifiedAt: stamp(-2), note: `已通知會計師。${placeholder}` }, + { ...scoped, docType: "receipt", r2Key: `demo/${ORG}/electronic-invoice-rent.pdf`, fileName: "租金電子發票.pdf", contentType: "application/pdf", sizeBytes: 33_900, transactionId: null, invoiceId: invRent, invoiceKind: "electronic", accountantNotifiedAt: null, note: `電子發票,會計師自動看得到。${placeholder}` }, + { ...scoped, docType: "contract", r2Key: `demo/${ORG}/contract-morningside.pdf`, fileName: "品牌官網改版合約.pdf", contentType: "application/pdf", sizeBytes: 210_400, transactionId: null, invoiceId: null, invoiceKind: null, accountantNotifiedAt: null, note: placeholder }, + ]) + .returning({ id: documents.id }); + return rows.length; +}); + +// --------------------------------------------------------------------------- +// 15. 操作紀錄 +// +// 這支腳本是直接寫 DB 的,不會走到 logWeb / logMcp,所以 list_activity 預設會是空的。 +// 補幾筆據實描述「這批資料是被 seed 出來的」紀錄,讓稽核頁不是空白,也不假裝有 +// 沒發生過的操作。 +// --------------------------------------------------------------------------- + +const activityCount = await section("activity log", async () => { + const entry = (entityType: string, entityId: number | null, summary: string, daysAgo: number) => ({ + ...scoped, + actorUserId: ownerUser.id, + actorEmail: ownerUser.email, + actorName: ownerUser.name, + channel: "web", + action: "create", + entityType, + entityId, + summary, + createdAt: stamp(-daysAgo), + }); + const rows = await db + .insert(activityLog) + .values([ + entry("category", pick(cat, "專案服務收入"), "建立會計科目(示範資料匯入)", 7), + entry("bank_account", twdAcct, `建立帳戶「${ACCT_TWD}」(示範資料匯入)`, 7), + entry("party", morningside, "建立客戶「Morningside Creative」(示範資料匯入)", 7), + entry("project", projMorningside, "建立專案「Morningside 品牌官網改版」(示範資料匯入)", 6), + entry("contract", ctMorningside, "建立合約「品牌官網改版合約」(示範資料匯入)", 6), + entry("billing_item", biMorningsideSigning, "展開請款排程「官網改版 — 簽約金」(示範資料匯入)", 6), + entry("subscription", subVerdant, `建立訂閱「${SUB_VERDANT}」(示範資料匯入)`, 5), + entry("invoice", invMorningsideSigning, "建立發票 DM-2001(示範資料匯入)", 5), + entry("employee", empIds[0].id, `建立員工「${empIds[0].def.name}」(示範資料匯入)`, 4), + entry("transaction", advSupplyId, "建立代墊交易(示範資料匯入)", 3), + entry("reconciliation", null, "建立銀行對帳紀錄(示範資料匯入)", 2), + ]) + .returning({ id: activityLog.id }); + return rows.length; +}); + +// --------------------------------------------------------------------------- +// 摘要 +// --------------------------------------------------------------------------- + +const totalTxns = bulkTxnCount + trackedTxns.length + reimbursementCount; + +console.log(`✓ seeded demo organization "${org.name}"`); +console.log(` organizationId : ${ORG}`); +const slugNote = + slug === requestedSlug ? "" : ` (requested "${requestedSlug}" was taken; --force picked this)`; +console.log(` slug : ${slug}${slugNote}`); +console.log(` owner : ${ownerUser.email} (${ownerUser.id})`); +console.log(""); +console.log(" categories " + CATEGORY_DEFS.length); +console.log(" bank accounts 3 (TWD ×2, USD ×1 — bank + cash)"); +console.log(" parties " + PARTY_DEFS.length + " (6 customers, 6 vendors, 1 gov)"); +console.log(" projects " + PROJECT_DEFS.length + " (4 active, 1 archived)"); +console.log(" contracts " + CONTRACT_DEFS.length + " (4 active, 1 completed, 1 draft)"); +console.log(" billing items " + BILLING_DEFS.length + " (paid / overdue / due / billed / partial / upcoming / cancelled)"); +console.log(" subscriptions 5 (3 active, 1 paused, 1 ended)"); +console.log(" subscription periods " + subPeriodCount); +console.log(" employees " + EMPLOYEE_DEFS.length + " (4 active, 1 departed)"); +console.log(" invoices " + INVOICE_DEFS.length + " (issued/received × valid/void/allowance)"); +console.log(" transactions " + totalTxns + " (income/expense/advance/reimbursement/transfer, books both+internal+external)"); +console.log(" payroll runs " + RUN_DEFS.length + " (2 paid, 1 draft)"); +console.log(" payslips " + payslipIds.size + " with " + payslipItemCount + " line items"); +console.log(" payroll item types 7"); +console.log(" reconciliations " + reconciliationCount); +console.log(" documents " + documentCount + " (1 accountant notice pending, 1 notified)"); +console.log(" activity log " + activityCount); +console.log(""); +console.log(` Sign in as ${ownerUser.email} and pass organizationId="${slug}" to any MCP tool.`); diff --git a/src/app/landing-content.ts b/src/app/landing-content.ts index c1a6687..08eb446 100644 --- a/src/app/landing-content.ts +++ b/src/app/landing-content.ts @@ -22,7 +22,13 @@ // React 元件:語言切換器與依登入狀態變化的按鈕。class 與 id="nav" 都原樣保留。 // wordmark 順帶從 Pathors Internal 縮成 Internal(頁尾同步)。 -/** docs/index.html