From 71dced46e6810f28b67d66c448fc0791c981074f Mon Sep 17 00:00:00 2001 From: kaitoyama Date: Fri, 19 Jun 2026 23:38:01 +0900 Subject: [PATCH 01/30] feat: auth foundation + membership collection (OpenSpec) Implements the first two Checkin capabilities, spec-driven via OpenSpec. add-auth-foundation: - isct email magic-link sessions: HMAC-SHA256 mail_hash (no plaintext email stored), server-side sessions in __Host- cookies, double-submit CSRF, Mailer adapter (log/SendGrid). - traQ OAuth (PKCE) admin login gated by an env traQ-ID allow-list. - requireUser / requireAdmin authorization helpers on the oRPC Context. add-membership-collection: - Stripe adapter layer (lazy client; Customer get-or-create; Invoice create -> finalize -> send) isolated from the Stripe-independent billing domain (term/price/authorize). - membership.issueInvoice (self, mail_hash-matched) and issueSpecialInvoice (admin-only special price), with Stripe idempotency keys and draft cleanup on failure. - invoice.paid webhook: raw-body signature verify + event-id idempotency + accountant Notifier. DB (Drizzle/MariaDB): users / sessions / email_verifications / stripe_events, with users.stripe_customer_id. Migrations 0000, 0001. Tooling: @fission-ai/openspec, zod, vitest, stripe. Both changes were Codex-reviewed, fixes applied, delta specs synced to openspec/specs/ and archived. Gates green: lint, typecheck, build, test (26). Live Stripe E2E pending test-mode keys. Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 54 + apps/web/app/plugins/orpc.ts | 36 + apps/web/nuxt.config.ts | 30 +- apps/web/server/routes/csrf.get.ts | 9 + apps/web/server/routes/login.get.ts | 20 + apps/web/server/routes/login/callback.get.ts | 49 + apps/web/server/routes/logout.post.ts | 11 + apps/web/server/routes/rpc/[...].ts | 18 +- .../server/routes/verify-email/confirm.get.ts | 26 + .../routes/webhook/invoice-paid.post.ts | 55 + apps/web/server/utils/auth-config.ts | 51 + apps/web/server/utils/auth.ts | 92 ++ apps/web/server/utils/billing-config.ts | 34 + design.md | 276 ++++ .../.openspec.yaml | 2 + .../2026-06-19-add-auth-foundation/design.md | 116 ++ .../proposal.md | 36 + .../specs/admin-authorization/spec.md | 43 + .../specs/email-verification/spec.md | 66 + .../specs/identity/spec.md | 52 + .../specs/session/spec.md | 80 ++ .../2026-06-19-add-auth-foundation/tasks.md | 50 + .../.openspec.yaml | 2 + .../design.md | 106 ++ .../proposal.md | 35 + .../specs/identity/spec.md | 15 + .../specs/membership-billing/spec.md | 74 ++ .../specs/payment-webhook/spec.md | 33 + .../specs/stripe-customer/spec.md | 38 + .../tasks.md | 41 + openspec/specs/admin-authorization/spec.md | 49 + openspec/specs/email-verification/spec.md | 72 + openspec/specs/identity/spec.md | 63 + openspec/specs/membership-billing/spec.md | 95 ++ openspec/specs/payment-webhook/spec.md | 44 + openspec/specs/session/spec.md | 86 ++ openspec/specs/stripe-customer/spec.md | 44 + package.json | 5 +- packages/api/package.json | 5 +- packages/api/src/auth/auth.test.ts | 93 ++ packages/api/src/auth/config.ts | 51 + packages/api/src/auth/context.ts | 50 + packages/api/src/auth/crypto.ts | 46 + packages/api/src/auth/email-verification.ts | 80 ++ packages/api/src/auth/identity.ts | 54 + packages/api/src/auth/index.ts | 9 + packages/api/src/auth/mailer.ts | 66 + packages/api/src/auth/redirect.ts | 27 + packages/api/src/auth/session.ts | 83 ++ packages/api/src/auth/traq.ts | 77 ++ packages/api/src/billing/authorize.ts | 40 + packages/api/src/billing/billing.test.ts | 92 ++ packages/api/src/billing/config.ts | 25 + packages/api/src/billing/index.ts | 4 + packages/api/src/billing/pricing.ts | 50 + packages/api/src/billing/term.ts | 25 + packages/api/src/index.ts | 15 + packages/api/src/notify/index.ts | 1 + packages/api/src/notify/notifier.ts | 21 + packages/api/src/orpc.ts | 22 +- packages/api/src/router.ts | 195 ++- packages/api/src/stripe/client.ts | 35 + packages/api/src/stripe/customers.ts | 78 ++ packages/api/src/stripe/index.ts | 4 + packages/api/src/stripe/invoices.ts | 88 ++ packages/api/src/stripe/webhook.ts | 30 + packages/api/src/webhook/events.ts | 55 + packages/api/src/webhook/index.ts | 1 + packages/db/drizzle/0000_black_khan.sql | 34 + packages/db/drizzle/0000_giant_selene.sql | 6 - packages/db/drizzle/0001_naive_stryfe.sql | 10 + packages/db/drizzle/meta/0000_snapshot.json | 188 ++- packages/db/drizzle/meta/0001_snapshot.json | 295 +++++ packages/db/drizzle/meta/_journal.json | 11 +- packages/db/src/schema.ts | 66 +- pnpm-lock.yaml | 1156 +++++++++++++++++ vitest.config.ts | 9 + 77 files changed, 5165 insertions(+), 40 deletions(-) create mode 100644 apps/web/server/routes/csrf.get.ts create mode 100644 apps/web/server/routes/login.get.ts create mode 100644 apps/web/server/routes/login/callback.get.ts create mode 100644 apps/web/server/routes/logout.post.ts create mode 100644 apps/web/server/routes/verify-email/confirm.get.ts create mode 100644 apps/web/server/routes/webhook/invoice-paid.post.ts create mode 100644 apps/web/server/utils/auth-config.ts create mode 100644 apps/web/server/utils/auth.ts create mode 100644 apps/web/server/utils/billing-config.ts create mode 100644 design.md create mode 100644 openspec/changes/archive/2026-06-19-add-auth-foundation/.openspec.yaml create mode 100644 openspec/changes/archive/2026-06-19-add-auth-foundation/design.md create mode 100644 openspec/changes/archive/2026-06-19-add-auth-foundation/proposal.md create mode 100644 openspec/changes/archive/2026-06-19-add-auth-foundation/specs/admin-authorization/spec.md create mode 100644 openspec/changes/archive/2026-06-19-add-auth-foundation/specs/email-verification/spec.md create mode 100644 openspec/changes/archive/2026-06-19-add-auth-foundation/specs/identity/spec.md create mode 100644 openspec/changes/archive/2026-06-19-add-auth-foundation/specs/session/spec.md create mode 100644 openspec/changes/archive/2026-06-19-add-auth-foundation/tasks.md create mode 100644 openspec/changes/archive/2026-06-19-add-membership-collection/.openspec.yaml create mode 100644 openspec/changes/archive/2026-06-19-add-membership-collection/design.md create mode 100644 openspec/changes/archive/2026-06-19-add-membership-collection/proposal.md create mode 100644 openspec/changes/archive/2026-06-19-add-membership-collection/specs/identity/spec.md create mode 100644 openspec/changes/archive/2026-06-19-add-membership-collection/specs/membership-billing/spec.md create mode 100644 openspec/changes/archive/2026-06-19-add-membership-collection/specs/payment-webhook/spec.md create mode 100644 openspec/changes/archive/2026-06-19-add-membership-collection/specs/stripe-customer/spec.md create mode 100644 openspec/changes/archive/2026-06-19-add-membership-collection/tasks.md create mode 100644 openspec/specs/admin-authorization/spec.md create mode 100644 openspec/specs/email-verification/spec.md create mode 100644 openspec/specs/identity/spec.md create mode 100644 openspec/specs/membership-billing/spec.md create mode 100644 openspec/specs/payment-webhook/spec.md create mode 100644 openspec/specs/session/spec.md create mode 100644 openspec/specs/stripe-customer/spec.md create mode 100644 packages/api/src/auth/auth.test.ts create mode 100644 packages/api/src/auth/config.ts create mode 100644 packages/api/src/auth/context.ts create mode 100644 packages/api/src/auth/crypto.ts create mode 100644 packages/api/src/auth/email-verification.ts create mode 100644 packages/api/src/auth/identity.ts create mode 100644 packages/api/src/auth/index.ts create mode 100644 packages/api/src/auth/mailer.ts create mode 100644 packages/api/src/auth/redirect.ts create mode 100644 packages/api/src/auth/session.ts create mode 100644 packages/api/src/auth/traq.ts create mode 100644 packages/api/src/billing/authorize.ts create mode 100644 packages/api/src/billing/billing.test.ts create mode 100644 packages/api/src/billing/config.ts create mode 100644 packages/api/src/billing/index.ts create mode 100644 packages/api/src/billing/pricing.ts create mode 100644 packages/api/src/billing/term.ts create mode 100644 packages/api/src/notify/index.ts create mode 100644 packages/api/src/notify/notifier.ts create mode 100644 packages/api/src/stripe/client.ts create mode 100644 packages/api/src/stripe/customers.ts create mode 100644 packages/api/src/stripe/index.ts create mode 100644 packages/api/src/stripe/invoices.ts create mode 100644 packages/api/src/stripe/webhook.ts create mode 100644 packages/api/src/webhook/events.ts create mode 100644 packages/api/src/webhook/index.ts create mode 100644 packages/db/drizzle/0000_black_khan.sql delete mode 100644 packages/db/drizzle/0000_giant_selene.sql create mode 100644 packages/db/drizzle/0001_naive_stryfe.sql create mode 100644 packages/db/drizzle/meta/0001_snapshot.json create mode 100644 vitest.config.ts diff --git a/.env.example b/.env.example index 776ee4d..e73f1e1 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,56 @@ # MariaDB connection string used by Drizzle (drizzle-orm/mysql2) and the Nitro server. DATABASE_URL="mysql://checkin:password@localhost:3306/checkin" + +# --- Auth foundation (OpenSpec: add-auth-foundation) --- + +# HMAC secret for mail_hash. REQUIRED. Keep stable — changing it breaks the +# person <-> Stripe correspondence table. Generate e.g. `openssl rand -hex 32`. +MAIL_HASH_SECRET="" + +# Comma-separated isct email domains allowed to verify (mail_hash key source). +ISCT_ALLOWED_EMAIL_DOMAINS="m.isct.ac.jp" + +# Absolute origin of this app; used to build magic-link / OAuth redirect URLs +# and to validate same-site redirects. e.g. https://checkin.trap.jp +APP_ORIGIN="http://localhost:3000" + +# Token / session lifetimes (seconds). +EMAIL_VERIFICATION_TTL="1800" +SESSION_TTL="2592000" + +# Comma-separated traQ IDs (usernames) granted accountant (admin) access. +CHECKIN_ACCOUNTANT_TRAQ_IDS="" + +# traQ OAuth (client registered out-of-band; fill in from the registration). +TRAQ_OAUTH_CLIENT_ID="" +TRAQ_OAUTH_CLIENT_SECRET="" +TRAQ_OAUTH_AUTHORIZE_URL="" +TRAQ_OAUTH_TOKEN_URL="" +TRAQ_OAUTH_USERINFO_URL="" +TRAQ_OAUTH_SCOPE="" +# Field in the userinfo response that holds the traQ ID (username). +TRAQ_OAUTH_USER_ID_FIELD="name" + +# Mailer: "log" (dev, prints to console) or "sendgrid" (production). +MAILER_DRIVER="log" +MAIL_FROM="noreply@localhost" +SENDGRID_API_KEY="" + +# --- Membership collection / Stripe (OpenSpec: add-membership-collection) --- + +# Stripe secret key (test mode to start, sk_test_...). Leave empty for auth-only +# dev — the Stripe adapter is lazy, so non-billing requests work without it. +STRIPE_SECRET_KEY="" +# Signing secret for the invoice-paid webhook endpoint (whsec_...). +STRIPE_WEBHOOK_SECRET="" + +# Stripe Price ids for the collection matrix (amounts live on the Price, not here): +# 新規入部費 前期(4–9月) ¥4,000 / 後期(10–3月) ¥2,000 +# 継続(部費) 標準 ¥4,000 / 特別 ¥2,000(管理者のみ発行) +PRICE_SHINKI_ZENKI="" +PRICE_SHINKI_KOUKI="" +PRICE_KEIZOKU_STANDARD="" +PRICE_KEIZOKU_SPECIAL="" + +# Days until an issued (send_invoice) invoice is due. +INVOICE_DAYS_UNTIL_DUE="7" diff --git a/apps/web/app/plugins/orpc.ts b/apps/web/app/plugins/orpc.ts index 7fd82ff..e4e5a70 100644 --- a/apps/web/app/plugins/orpc.ts +++ b/apps/web/app/plugins/orpc.ts @@ -3,12 +3,48 @@ import { RPCLink } from '@orpc/client/fetch' import type { RouterClient } from '@orpc/server' import type { AppRouter } from '@checkin/api' +const CSRF_COOKIE = '__Host-checkin_csrf' + +/** Read the (non-HttpOnly) CSRF cookie value on the client. */ +function readCsrfCookie(): string | undefined { + const hit = document.cookie.split('; ').find(c => c.startsWith(`${CSRF_COOKIE}=`)) + return hit ? decodeURIComponent(hit.slice(CSRF_COOKIE.length + 1)) : undefined +} + +let csrfInflight: Promise | null = null + +/** Ensure a CSRF cookie exists (fetching GET /csrf once if needed) and return its value. */ +async function ensureCsrfToken(): Promise { + let token = readCsrfCookie() + if (!token) { + csrfInflight ??= fetch('/csrf', { credentials: 'same-origin' }).finally(() => { + csrfInflight = null + }) + await csrfInflight + token = readCsrfCookie() + } + return token +} + export default defineNuxtPlugin(() => { // Resolve an absolute origin so the link works during SSR too. const origin = import.meta.server ? useRequestURL().origin : window.location.origin + // During SSR the link calls our own /rpc over HTTP; forward the incoming + // cookies (captured at setup) so the session is visible while rendering. + const ssrCookie = import.meta.server ? useRequestHeaders(['cookie']) : undefined + const link = new RPCLink({ url: `${origin}/rpc`, + // Dynamic headers per request: forward cookies on SSR; on the client attach + // the double-submit CSRF token so state-changing procedures pass assertCsrf(). + headers: async () => { + if (import.meta.server) { + return ssrCookie ?? {} + } + const token = await ensureCsrfToken() + return token ? { 'x-csrf-token': token } : {} + }, }) const client: RouterClient = createORPCClient(link) diff --git a/apps/web/nuxt.config.ts b/apps/web/nuxt.config.ts index e428b19..d24d7a8 100644 --- a/apps/web/nuxt.config.ts +++ b/apps/web/nuxt.config.ts @@ -3,8 +3,36 @@ export default defineNuxtConfig({ modules: ['@nuxt/eslint'], devtools: { enabled: true }, runtimeConfig: { - // Override at runtime via NUXT_DATABASE_URL. + // Server-only secrets/config. Override at runtime via NUXT_* env vars. databaseUrl: process.env.DATABASE_URL ?? '', + // Identity / sessions + mailHashSecret: process.env.MAIL_HASH_SECRET ?? '', + allowedEmailDomains: process.env.ISCT_ALLOWED_EMAIL_DOMAINS ?? 'm.isct.ac.jp', + appOrigin: process.env.APP_ORIGIN ?? '', + emailVerificationTtlSec: process.env.EMAIL_VERIFICATION_TTL ?? '1800', + sessionTtlSec: process.env.SESSION_TTL ?? '2592000', + // Accountant (admin) allow-list + accountantTraqIds: process.env.CHECKIN_ACCOUNTANT_TRAQ_IDS ?? '', + // traQ OAuth (client registered out-of-band; URLs/scope come from env) + traqClientId: process.env.TRAQ_OAUTH_CLIENT_ID ?? '', + traqClientSecret: process.env.TRAQ_OAUTH_CLIENT_SECRET ?? '', + traqAuthorizeUrl: process.env.TRAQ_OAUTH_AUTHORIZE_URL ?? '', + traqTokenUrl: process.env.TRAQ_OAUTH_TOKEN_URL ?? '', + traqUserinfoUrl: process.env.TRAQ_OAUTH_USERINFO_URL ?? '', + traqScope: process.env.TRAQ_OAUTH_SCOPE ?? '', + traqUserIdField: process.env.TRAQ_OAUTH_USER_ID_FIELD ?? 'name', + // Mailer + mailerDriver: process.env.MAILER_DRIVER ?? 'log', + mailFrom: process.env.MAIL_FROM ?? 'noreply@localhost', + sendgridApiKey: process.env.SENDGRID_API_KEY ?? '', + // Membership collection / Stripe + stripeSecretKey: process.env.STRIPE_SECRET_KEY ?? '', + stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET ?? '', + priceShinkiZenki: process.env.PRICE_SHINKI_ZENKI ?? '', + priceShinkiKouki: process.env.PRICE_SHINKI_KOUKI ?? '', + priceKeizokuStandard: process.env.PRICE_KEIZOKU_STANDARD ?? '', + priceKeizokuSpecial: process.env.PRICE_KEIZOKU_SPECIAL ?? '', + invoiceDaysUntilDue: process.env.INVOICE_DAYS_UNTIL_DUE ?? '7', }, compatibilityDate: '2025-01-01', typescript: { diff --git a/apps/web/server/routes/csrf.get.ts b/apps/web/server/routes/csrf.get.ts new file mode 100644 index 0000000..dfca1a0 --- /dev/null +++ b/apps/web/server/routes/csrf.get.ts @@ -0,0 +1,9 @@ +import { generateToken } from '@checkin/api' + +/** GET /csrf — issue a double-submit CSRF token + `__Host-checkin_csrf` cookie. */ +export default defineEventHandler((event) => { + const config = resolveAuthConfig() + const token = generateToken() + setCsrfCookie(event, token, config.sessionTtlSec) + return { csrfToken: token } +}) diff --git a/apps/web/server/routes/login.get.ts b/apps/web/server/routes/login.get.ts new file mode 100644 index 0000000..e317193 --- /dev/null +++ b/apps/web/server/routes/login.get.ts @@ -0,0 +1,20 @@ +import { buildAuthorizeUrl, generatePkce, generateToken, sanitizeRedirect } from '@checkin/api' + +/** GET /login — start traQ OAuth (authorization code + PKCE). Preserves `redirect`. */ +export default defineEventHandler((event) => { + const config = resolveAuthConfig() + const query = getQuery(event) + const redirect = sanitizeRedirect(typeof query.redirect === 'string' ? query.redirect : undefined) + + const state = generateToken(16) + const { verifier, challenge } = generatePkce() + setOAuthCookies(event, { state, verifier, redirect }) + + const redirectUri = `${config.appOrigin}/login/callback` + const authorizeUrl = buildAuthorizeUrl(config.traq, { + state, + codeChallenge: challenge, + redirectUri, + }) + return sendRedirect(event, authorizeUrl) +}) diff --git a/apps/web/server/routes/login/callback.get.ts b/apps/web/server/routes/login/callback.get.ts new file mode 100644 index 0000000..5238c9b --- /dev/null +++ b/apps/web/server/routes/login/callback.get.ts @@ -0,0 +1,49 @@ +import { + createSession, + exchangeCodeForToken, + fetchTraqUserId, + isAccountant, + safeEqual, + sanitizeRedirect, +} from '@checkin/api' + +/** + * GET /login/callback — validate state, exchange the code, resolve the traQ ID, + * gate on the accountant allow-list, then establish an admin session. + */ +export default defineEventHandler(async (event) => { + const config = resolveAuthConfig() + const query = getQuery(event) + const cookies = getOAuthCookies(event) + clearOAuthCookies(event) + + const code = typeof query.code === 'string' ? query.code : '' + const state = typeof query.state === 'string' ? query.state : '' + + // state must match the value we stored before redirecting (CSRF for OAuth). + if (!code || !state || !cookies.state || !cookies.verifier || !safeEqual(state, cookies.state)) { + return sendRedirect(event, '/?login=error') + } + + try { + const redirectUri = `${config.appOrigin}/login/callback` + const accessToken = await exchangeCodeForToken(config.traq, { + code, + codeVerifier: cookies.verifier, + redirectUri, + }) + const traqId = await fetchTraqUserId(config.traq, accessToken) + + if (!isAccountant(traqId, config.accountantTraqIds)) { + return sendRedirect(event, '/?login=forbidden') + } + + const token = await createSession(useDatabase(), { actor: 'admin', traqId }, config.sessionTtlSec) + setSessionCookie(event, token, config.sessionTtlSec) + return sendRedirect(event, sanitizeRedirect(cookies.redirect)) + } + catch (error) { + console.error('traQ OAuth callback failed:', error) + return sendRedirect(event, '/?login=error') + } +}) diff --git a/apps/web/server/routes/logout.post.ts b/apps/web/server/routes/logout.post.ts new file mode 100644 index 0000000..39d391b --- /dev/null +++ b/apps/web/server/routes/logout.post.ts @@ -0,0 +1,11 @@ +import { destroySession } from '@checkin/api' + +/** POST /logout — destroy the current session and clear the cookie. */ +export default defineEventHandler(async (event) => { + if (!isCsrfValid(event)) { + throw createError({ statusCode: 403, statusMessage: 'CSRF token missing or invalid' }) + } + await destroySession(useDatabase(), getCookie(event, SESSION_COOKIE)) + clearSessionCookie(event) + return { ok: true } +}) diff --git a/apps/web/server/routes/rpc/[...].ts b/apps/web/server/routes/rpc/[...].ts index 9d2efb3..d2171a8 100644 --- a/apps/web/server/routes/rpc/[...].ts +++ b/apps/web/server/routes/rpc/[...].ts @@ -4,18 +4,16 @@ import { appRouter } from '@checkin/api' const handler = new RPCHandler(appRouter) export default defineEventHandler(async (event) => { + // Build the full request context: db handle + resolved auth config/mailer + + // the session restored from the session cookie + CSRF-aware authorization + // helpers. CSRF is enforced per-procedure via context.assertCsrf() — all oRPC + // calls are POST so we cannot gate by HTTP method, and SSR reads such as + // health.check / auth.me never call it. + const context = await buildRequestContext(event) + const { matched, response } = await handler.handle(toWebRequest(event), { prefix: '/rpc', - context: { - // A single mysql2 pool is created on first use and cached per server - // instance (see useDatabase); the pool itself connects lazily. Note that - // oRPC reads context.db for *every* request, so DATABASE_URL must be set - // even for DB-free procedures like health.check — the getter only defers - // creation, it does not make it conditional on the procedure. - get db() { - return useDatabase() - }, - }, + context, }) if (matched) { diff --git a/apps/web/server/routes/verify-email/confirm.get.ts b/apps/web/server/routes/verify-email/confirm.get.ts new file mode 100644 index 0000000..371ad95 --- /dev/null +++ b/apps/web/server/routes/verify-email/confirm.get.ts @@ -0,0 +1,26 @@ +import { consumeEmailVerification, createSession, getOrCreateUserByMailHash, sanitizeRedirect } from '@checkin/api' + +/** + * GET /verify-email/confirm — consume a magic-link token (single-use, expiry, + * reuse-safe), get-or-create the person row, establish a user session, then + * navigate to the stored same-site redirect. + */ +export default defineEventHandler(async (event) => { + const config = resolveAuthConfig() + const query = getQuery(event) + const token = typeof query.token === 'string' ? query.token : '' + if (!token) { + return sendRedirect(event, '/?verify=invalid') + } + + const db = useDatabase() + const consumed = await consumeEmailVerification(db, token) + if (!consumed) { + return sendRedirect(event, '/?verify=invalid') + } + + const user = await getOrCreateUserByMailHash(db, consumed.mailHash) + const sessionToken = await createSession(db, { actor: 'user', userId: user.id }, config.sessionTtlSec) + setSessionCookie(event, sessionToken, config.sessionTtlSec) + return sendRedirect(event, sanitizeRedirect(consumed.redirect)) +}) diff --git a/apps/web/server/routes/webhook/invoice-paid.post.ts b/apps/web/server/routes/webhook/invoice-paid.post.ts new file mode 100644 index 0000000..d12dddc --- /dev/null +++ b/apps/web/server/routes/webhook/invoice-paid.post.ts @@ -0,0 +1,55 @@ +import { constructEvent, createNotifier, createStripeClient, hasProcessedStripeEvent, recordStripeEventOnce } from '@checkin/api' + +/** + * POST /webhook/invoice-paid — Stripe `invoice.paid` receiver. + * + * No CSRF/session here: Stripe authenticates via the request signature, not a + * browser cookie (design.md D6). We verify the signature over the RAW body, + * dedupe by event id, and on `invoice.paid` notify the accountant. Other event + * types are acknowledged and ignored. + * + * Delivery semantics: at-least-once with happy-path dedup. We notify FIRST and + * record the event only AFTER notify resolves, so a notify failure leaves the + * event unrecorded and Stripe retries. A notify-success/record-fail or two + * concurrent first deliveries may rarely double-notify, which is acceptable for + * accountant notifications. + */ +export default defineEventHandler(async (event) => { + const billing = resolveBillingConfig() + const stripe = createStripeClient(billing.stripeSecretKey) + + // Raw, unparsed body is required for the signature HMAC to match. + const rawBody = await readRawBody(event, false) + const signature = getHeader(event, 'stripe-signature') + + let verified: { id: string, type: string } + try { + if (!rawBody) { + throw new Error('missing request body') + } + verified = constructEvent(stripe, rawBody, signature, billing.stripeWebhookSecret) + } + catch { + // Invalid or missing signature → reject without processing. + throw createError({ statusCode: 400, statusMessage: 'invalid Stripe signature' }) + } + + // Ignore everything except invoice.paid (acknowledge so Stripe stops retrying). + if (verified.type !== 'invoice.paid') { + return { ok: true, ignored: verified.type } + } + + // Idempotency (happy path): skip if this event was already processed before. + const db = useDatabase() + if (await hasProcessedStripeEvent(db, verified.id)) { + return { ok: true, duplicate: true } + } + + // Notify FIRST, then record. A notify failure throws before recordStripeEventOnce + // runs, so the event stays unrecorded and Stripe will retry (at-least-once). + const notifier = createNotifier() + await notifier.notify(`入金を確認しました(Stripe event ${verified.id})。`) + await recordStripeEventOnce(db, verified) + + return { ok: true } +}) diff --git a/apps/web/server/utils/auth-config.ts b/apps/web/server/utils/auth-config.ts new file mode 100644 index 0000000..cc221cc --- /dev/null +++ b/apps/web/server/utils/auth-config.ts @@ -0,0 +1,51 @@ +import type { AuthConfig } from '@checkin/api' + +let cached: AuthConfig | undefined + +function csv(value: unknown): string[] { + return String(value ?? '') + .split(',') + .map(s => s.trim()) + .filter(Boolean) +} + +function num(value: unknown, fallback: number): number { + const n = Number(value) + return Number.isFinite(n) && n > 0 ? n : fallback +} + +/** + * Resolve the typed AuthConfig from Nuxt runtimeConfig (env-backed). Cached per + * server instance. The domain layer in @checkin/api only sees this typed shape. + */ +export function resolveAuthConfig(): AuthConfig { + if (cached) { + return cached + } + const rc = useRuntimeConfig() + const domains = csv(rc.allowedEmailDomains) + + cached = { + mailHashSecret: String(rc.mailHashSecret ?? ''), + allowedEmailDomains: domains.length ? domains : ['m.isct.ac.jp'], + appOrigin: String(rc.appOrigin || 'http://localhost:3000'), + emailVerificationTtlSec: num(rc.emailVerificationTtlSec, 1800), + sessionTtlSec: num(rc.sessionTtlSec, 2592000), + accountantTraqIds: csv(rc.accountantTraqIds), + traq: { + clientId: String(rc.traqClientId ?? ''), + clientSecret: String(rc.traqClientSecret ?? ''), + authorizeUrl: String(rc.traqAuthorizeUrl ?? ''), + tokenUrl: String(rc.traqTokenUrl ?? ''), + userinfoUrl: String(rc.traqUserinfoUrl ?? ''), + scope: String(rc.traqScope ?? ''), + userIdField: String(rc.traqUserIdField || 'name'), + }, + mailer: { + driver: String(rc.mailerDriver || 'log') === 'sendgrid' ? 'sendgrid' : 'log', + from: String(rc.mailFrom || 'noreply@localhost'), + sendgridApiKey: String(rc.sendgridApiKey ?? '') || undefined, + }, + } + return cached +} diff --git a/apps/web/server/utils/auth.ts b/apps/web/server/utils/auth.ts new file mode 100644 index 0000000..be4b0c6 --- /dev/null +++ b/apps/web/server/utils/auth.ts @@ -0,0 +1,92 @@ +import type { H3Event } from 'h3' +import { + type Context, + createAuthHelpers, + createMailer, + createNotifier, + createStripeClient, + resolveSession, + safeEqual, +} from '@checkin/api' + +export const SESSION_COOKIE = '__Host-checkin_session' +export const CSRF_COOKIE = '__Host-checkin_csrf' +export const OAUTH_STATE_COOKIE = '__Host-checkin_oauth_state' +export const OAUTH_VERIFIER_COOKIE = '__Host-checkin_oauth_verifier' +export const OAUTH_REDIRECT_COOKIE = '__Host-checkin_oauth_redirect' + +/** Base attributes satisfying the `__Host-` cookie prefix (Secure, Path=/, no Domain). */ +const hostCookieBase = { secure: true, path: '/' as const, sameSite: 'lax' as const } + +/** Set the server-side session cookie (HttpOnly). */ +export function setSessionCookie(event: H3Event, token: string, ttlSec: number): void { + setCookie(event, SESSION_COOKIE, token, { ...hostCookieBase, httpOnly: true, maxAge: ttlSec }) +} + +/** Clear the session cookie (logout). */ +export function clearSessionCookie(event: H3Event): void { + deleteCookie(event, SESSION_COOKIE, { ...hostCookieBase, httpOnly: true }) +} + +/** Issue a CSRF cookie (readable by JS for double-submit) and return the token. */ +export function setCsrfCookie(event: H3Event, token: string, ttlSec: number): void { + setCookie(event, CSRF_COOKIE, token, { ...hostCookieBase, httpOnly: false, maxAge: ttlSec }) +} + +const OAUTH_TEMP_TTL = 600 + +/** Persist short-lived OAuth flow state across the redirect to traQ and back. */ +export function setOAuthCookies( + event: H3Event, + values: { state: string, verifier: string, redirect: string }, +): void { + const opts = { ...hostCookieBase, httpOnly: true, maxAge: OAUTH_TEMP_TTL } + setCookie(event, OAUTH_STATE_COOKIE, values.state, opts) + setCookie(event, OAUTH_VERIFIER_COOKIE, values.verifier, opts) + setCookie(event, OAUTH_REDIRECT_COOKIE, values.redirect, opts) +} + +export function getOAuthCookies(event: H3Event): { + state: string | undefined + verifier: string | undefined + redirect: string | undefined +} { + return { + state: getCookie(event, OAUTH_STATE_COOKIE), + verifier: getCookie(event, OAUTH_VERIFIER_COOKIE), + redirect: getCookie(event, OAUTH_REDIRECT_COOKIE), + } +} + +export function clearOAuthCookies(event: H3Event): void { + const opts = { ...hostCookieBase, httpOnly: true } + deleteCookie(event, OAUTH_STATE_COOKIE, opts) + deleteCookie(event, OAUTH_VERIFIER_COOKIE, opts) + deleteCookie(event, OAUTH_REDIRECT_COOKIE, opts) +} + +/** Compute double-submit CSRF validity from the cookie and `x-csrf-token` header. */ +export function isCsrfValid(event: H3Event): boolean { + const cookie = getCookie(event, CSRF_COOKIE) + const header = getHeader(event, 'x-csrf-token') + return !!cookie && !!header && safeEqual(cookie, header) +} + +/** + * Build the per-request oRPC Context: db handle, resolved auth + billing config, + * mailer + accountant notifier, a lazy Stripe adapter (no SDK is instantiated + * unless a billing procedure actually uses it, so non-billing requests work even + * without a Stripe key), the session restored from the cookie, and the + * CSRF-aware authorization helpers. + */ +export async function buildRequestContext(event: H3Event): Promise { + const db = useDatabase() + const config = resolveAuthConfig() + const billing = resolveBillingConfig() + const mailer = createMailer(config.mailer) + const notifier = createNotifier() + const stripe = createStripeClient(billing.stripeSecretKey) + const session = await resolveSession(db, getCookie(event, SESSION_COOKIE)) + const helpers = createAuthHelpers(session, isCsrfValid(event)) + return { db, config, billing, mailer, notifier, stripe, session, ...helpers } +} diff --git a/apps/web/server/utils/billing-config.ts b/apps/web/server/utils/billing-config.ts new file mode 100644 index 0000000..dc5014a --- /dev/null +++ b/apps/web/server/utils/billing-config.ts @@ -0,0 +1,34 @@ +import type { BillingConfig } from '@checkin/api' + +let cached: BillingConfig | undefined + +function num(value: unknown, fallback: number): number { + const n = Number(value) + return Number.isFinite(n) && n > 0 ? n : fallback +} + +/** + * Resolve the typed BillingConfig from Nuxt runtimeConfig (env-backed). Cached + * per server instance. The domain layer in @checkin/api only sees this typed + * shape; Stripe keys may be empty until billing is configured (the Stripe + * adapter is lazy, so non-billing requests are unaffected). + */ +export function resolveBillingConfig(): BillingConfig { + if (cached) { + return cached + } + const rc = useRuntimeConfig() + + cached = { + prices: { + shinkiZenki: String(rc.priceShinkiZenki ?? ''), + shinkiKouki: String(rc.priceShinkiKouki ?? ''), + keizokuStandard: String(rc.priceKeizokuStandard ?? ''), + keizokuSpecial: String(rc.priceKeizokuSpecial ?? ''), + }, + stripeSecretKey: String(rc.stripeSecretKey ?? ''), + stripeWebhookSecret: String(rc.stripeWebhookSecret ?? ''), + invoiceDaysUntilDue: num(rc.invoiceDaysUntilDue, 7), + } + return cached +} diff --git a/design.md b/design.md new file mode 100644 index 0000000..5a2d38e --- /dev/null +++ b/design.md @@ -0,0 +1,276 @@ +# Checkin(仮称)設計 + +> ステータス: 仕様整理中(第一弾スコープ確定) +> 最終更新: 2026-06-19 + +## 1. 概要と目的 + +traP の Stripe を使った **集金** と **払い戻し** をまとめて扱うシステム。実質的に入部フォームを兼ねられる。 + +第一弾スコープ: + +1. 入部費 / 部費の集金(請求書ベース) +2. 入出金の一覧(会計用) +3. Jomon で承認された振込依頼を connected account を使って Stripe で実行(払い戻し)。**承認・申請は Jomon、Checkin は実行のみを担う。** + +**原則: サークルが Stripe に依存せず、いつでも切り替えられること。** Stripe をオフにして口座振込を指示できるよう、ドメインと「人 ↔ Stripe オブジェクト」の対応表は **自前 DB を source of truth** とし、Stripe は薄いアダプタ越しに使う。 + +## 2. 基本方針(決定事項) + +- **Stripe API は v1 を使う。Accounts v2 は当面採用しない。** + - 欲しい統合(1 つの Account に customer〔請求先〕と recipient〔払い戻し先〕を同居させる)は今のところ public preview で、GA 日付の公表がなく breaking change も継続中。お金が外に出る処理を preview に乗せたくない。 + - 統合を実現する customer 構成が preview である以上、v2 に乗り換えても preview を踏まなければ結局 Customer と connected の 2 オブジェクトのままで、痛み(後述の対応表問題)が消えない。 + - 既に v1 Customer も connected も持っているので、移行ゼロで始められる。 +- **対応表は自前 DB が正。** 人 → `{stripe_customer_id, stripe_connected_account_id, onboarding 状態}` を DB に持つことで、現状のスプレッドシート手作業を排除する。 +- **本人識別キー = isct メールのハッシュ**(サービス内 ID、isct 限定)。集金側も払い戻し側も同じキーで 1 行に束ねる。メール平文は Stripe 側に保持。 +- **商品は Price ID で指定**(Product ではなく)。入部費は前期 ¥4,000 / 後期 ¥2,000 を Price として用意し、機械的に切り替える。 +- **認証**: 管理者(会計)は traQ OAuth + traQ グループ/環境変数で認可。利用者は isct メールのマジックリンクでログインし、**セッションは httpOnly cookie + double-submit CSRF(`__Host-checkin_csrf`)で保持**する(JWT クライアント保持案は不採用。traP の他サービス〔Jomon 等〕もセッション方式で一貫)。 +- **将来**: Global Payouts / Accounts-v2-as-customers が GA になったら v2 統合への移行を検討(§10)。 + +## 3. 全体アーキテクチャ + +```mermaid +flowchart LR + UI["Web UI(入部 / 部費 / 払い戻し)"] + API["Checkin Server(API)"] + DB[("DB(Users = 対応表)")] + ADP["Stripe アダプタ層"] + TRAQ["traQ(OAuth)"] + MAIL["メール送信(SendGrid 等)"] + STRIPE["Stripe(Customer / Invoice / Connect)"] + JOMON["Jomon(経費精算)"] + + UI --> API + API --> DB + API --> TRAQ + API --> MAIL + API --> ADP + ADP --> STRIPE + API <-->|"振込依頼の取得 / 結果の書き戻し(pull)"| JOMON + STRIPE -->|"Webhook: invoice.paid / account.updated"| API +``` + +ポイントは **Stripe アダプタ層** を境界にすること。ドメイン(誰がいくら払う/払い戻す)は Stripe 非依存に保ち、Stripe 呼び出しをアダプタに閉じ込めておけば、「Stripe オフ → 口座振込」への切り替えや、将来の v2 移行が**アダプタ差し替え**で済む。 + +## 4. データモデル + +### 4.1 本人の表現 + +- サービス内は `mail_hash`(isct メールのハッシュ)で識別する。 +- Stripe 側では、集金は **Customer**、払い戻しは **connected account** に分かれる。この 2 つを Users の 1 行で束ねるのが対応表の役割。 +- 再入部時に入力された traQ ID は**信頼できない**(凍結中は所有確認ができないため、他人の存在する ID を打たれてもミスに気付けない)。よって Customer への保存・参照には使わず、アカウント復旧対象の記録のみに使う(§5.1)。 + +### 4.2 Stripe オブジェクトの対応 + +| 役割 | Stripe オブジェクト | 備考 | +| --- | --- | --- | +| 部費の請求先 | Customer | mail_hash で DB と対応付け | +| 請求書 | Invoice + InvoiceItem(Price) | finalize / send で支払いページへ | +| 払い戻しの送金先 | Connected account(Connect) | JIT で onboarding | +| 入金検知 | Webhook `invoice.paid` | 会計へ通知 | +| onboarding 完了検知 | Webhook `account.updated`(新規) | 払い戻し可否の判定に使う | + +## 5. 機能別フロー + +### 5.1 集金(入部費 / 部費) + +部員でなくても申請者は誰でも使える(新規入部)。再入部・現役部員にも対応する。 + +**`/membership` の振り分け** + +```mermaid +flowchart TD + A["/membership にアクセス"] --> B{"ログイン済み?"} + B -->|Yes| C{"traQ ID に紐づく Customer がある?"} + C -->|Yes| D["現役部員として請求書情報入力へ"] + C -->|No| E{"isct アドレス確認済み?"} + E -->|Yes| D + E -->|No| F["/verify-email?redirect=/membership へ"] + B -->|No| G{"新規入部 / 再入部 / 現役 を選択"} + G -->|現役| H["/login?redirect=/membership へ"] + G -->|新規入部| I{"isct アドレス確認済み?"} + I -->|No| F + I -->|Yes| J["新規入部として請求書情報入力へ"] + G -->|再入部| K{"isct アドレス確認済み?"} + K -->|No| F + K -->|Yes| L["再入部として請求書情報入力へ"] +``` + +**請求書の発行 → 入金 → 通知(共通)** + +```mermaid +sequenceDiagram + actor U as 利用者 + participant FE as Web UI + participant API as Checkin Server + participant DB as DB + participant ST as Stripe + participant AC as 会計 + + U->>FE: isct アドレスを入力 + FE->>API: verify-email を要求 + API->>U: 確認メールを送信 + U->>API: メール内リンクで確認 + Note over API,U: isct アドレス所有を確認・セッション確立 + U->>FE: 名前 / 必要なら traQ ID を入力して確定 + FE->>API: 請求書発行を要求 + API->>DB: mail_hash で Customer を検索 + alt Customer 未登録 + API->>ST: Customer を作成 + API->>DB: customer_id を保存 + end + API->>ST: Invoice 作成(Price 指定)→ finalize / send + ST-->>U: 請求書メール(支払いページ) + U->>ST: 支払い + ST->>API: Webhook invoice.paid + API->>AC: 会計へ通知 +``` + +補足: + +- **入部費の額**: 前期 ¥4,000 / 後期 ¥2,000 を Price として用意し、機械的に決定。 +- **Customer の有無判定**: まず DB(mail_hash)→ 無ければ Stripe をメールで検索 → それでも無ければ作成し、`customer_id` を DB に保存する。 +- **再入部の traQ ID 取り扱い(バグ回避)**: 入力された traQ ID は **存在チェックのみ** に使い、Customer への保存・参照はしない。アカウント復旧の対象設定だけに使う。ID 不明の場合は役員対応へ誘導。 +- ログの可読性のため、Customer の `name` / `metadata` に traQ ID を入れるのは可(ただし参照キーには使わない)。 + +### 5.2 入出金一覧(会計のみ) + +- **請求書由来**(`/list/invoices`)と **決済ページ由来**(`/list/checkout-sessions`)の 2 系統。 +- フィルタ(status 等)+ ページネーション。 + - 補足: invoice list は `status`(draft / open / paid / uncollectible / void)で絞れる(当初の「state で絞れない」懸念は不要)。checkout sessions も `status` / `customer` / `payment_intent` で絞れる。 +- 各行に持つ項目: `id` / 金額 / 日時 / customer 参照(または traQ ID 等のデータ)/ 支払い状況 / 支払いの id(Dashboard URL を生成可)/ 商品参照。 + +### 5.3 払い戻し(Jomon 経費精算) + +> 種別の明確化: これは Stripe の **refund(カード返金)ではない**。立て替えた人の口座へ送る **transfer / payout**。本人の本人確認+口座登録(onboarding)が必須。 + +```mermaid +flowchart TD + S["Jomon:精算が承認される"] --> I["Checkin:承認済み精算を取込(API or スクレイピング)"] + I --> M["対応表で本人を特定(mail_hash)"] + M --> O{"connected の onboarding 状態は?"} + O -->|初めて / 依頼済み| L["Stripe ホスト型 onboarding リンクを発行・送付"] + L --> W["完了待ち(状態:依頼済み)"] + O -->|完了| P["transfer / payout を実行"] + W -.->|本人が登録完了| P + P --> B["Jomon に結果を書き戻し(送金済み / 失敗)"] +``` + +**onboarding の状態機械(JIT)** + +```mermaid +stateDiagram-v2 + state "初めて" as New + state "依頼済み" as Requested + state "完了" as Done + + [*] --> New + New --> Requested: onboarding リンクを発行 + Requested --> Done: 本人が登録完了 + Requested --> Requested: 催促・再送 + Done --> [*] +``` + +補足: + +- **onboarding は just-in-time**: 初回の払い戻し発生時に Stripe ホスト型 onboarding リンクを発行・送付する(Stripe も API onboarding より hosted / embedded を推奨)。完了後に送金。 +- これがドキュメント当初の「初めて / 依頼したがまだ / 作成済み」の正式版。 +- **onboarding 完了の検知(`account.updated` Webhook の取り回し)**: Stripe は「完了しました」という単発イベントを送ってこない。connected account の状態変化は `account.updated` で通知されるので、受信のたびに 署名検証 →(イベントの `account` で対象を特定)→ `payouts_enabled` などのフラグと未提出要件の有無で「払い出し可能か」を判定する。onboarding 中は何度も飛ぶため「来たら done」ではなく **フラグで判定** し、event id で **冪等** に状態を更新。`done` になったら `onboarding_waiting` だった払い戻しを実行に進める。 +- **Jomon 連携は API(pull 方式)で行う**([Jomon v2 API](https://apis.trap.jp/?urls.primaryName=Jomon%20v2%20API) 公開済み、スクレイピング不要)。**責任分界は確定**: 承認・申請は Jomon、Checkin は connected account を使った払い戻しの **実行のみ**。Checkin が Jomon から承認済みの振込依頼(誰に・いくら)を取得 → Stripe で onboarding/送金 → 結果(送金済み / 失敗)を Jomon に書き戻す([Issue #183](https://github.com/traPtitech/Jomon/issues/183) と整合)。即時性は不要なので pull で十分。**認証は Bearer サービストークン**(Checkin → Jomon の片方向、スコープ最小 + 内部ネットワーク制限)。 +- **資金繰り(残高補填)は本スコープ外**。 + +## 6. API 設計 + +| メソッド・パス | 用途 | 認可 | +| --- | --- | --- | +| `GET /csrf` | CSRF cookie 発行 | 公開 | +| `POST /verify-email` | isct メール確認メール送信 | 公開 | +| `GET /verify-email/confirm` | 確認ページ表示 | 公開(トークン) | +| `POST /verify-email/confirm` | トークン消費・ログイン確立 | 公開(トークン) | +| `GET /login` | traQ OAuth ログイン | 公開 | +| `GET /customer` | 自分の Customer 取得 | 利用者(自分のみ) | +| `POST /customer` | Customer 作成 | 利用者(自分のみ) | +| `PATCH /customer` | Customer 更新 | 利用者(自分のみ) | +| `POST /invoice` | 部費の請求書発行 | 利用者(自分のみ) | +| `GET /list/invoices` | 請求書由来の入金一覧 | 会計のみ | +| `GET /list/checkout-sessions` | 決済ページ由来の入金一覧 | 会計のみ | +| `GET /payouts` | 払い戻し対象一覧(Jomon 取込) | 会計のみ(新規) | +| `POST /payouts/{id}/onboarding-link` | onboarding リンク発行・送付 | 会計のみ(新規) | +| `POST /payouts/{id}/execute` | 送金(transfer / payout)実行 | 会計のみ(新規) | +| `POST /webhook/invoice-paid` | `invoice.paid` 受信 | Stripe 署名 | +| `POST /webhook/account-updated` | `account.updated` 受信(onboarding 検知) | Stripe 署名(新規) | +| `GET/POST/DELETE /admin` | 管理者管理 | 会計のみ(traQ グループ/env へ移行検討) | + +認可の考え方: + +- **利用者**: isct メールのマジックリンクで確立したセッションを使い、操作対象は常に「自分のもの」に限定。 +- **会計**: traQ OAuth + traQ グループ/環境変数で判定。 +- **Webhook**: Stripe 署名(`Stripe-Signature`)で検証。 + +## 7. DB 設計 + +**Users** + +| カラム | 説明 | +| --- | --- | +| `id` | PK | +| `mail_hash` | isct メールのハッシュ(unique) | +| `stripe_customer_id` | 集金側(nullable) | +| `stripe_connected_account_id` | 払い戻し側(nullable、新規) | +| `payout_onboarding_status` | `none` / `requested` / `done`(新規) | +| `created_at` / `updated_at` | | + +**Payouts**(新規) + +| カラム | 説明 | +| --- | --- | +| `id` | PK | +| `user_id` | FK → Users | +| `jomon_ref` | Jomon 側の識別子 | +| `amount` | 金額 | +| `status` | `pending` / `onboarding_waiting` / `paid` / `failed` | +| `stripe_transfer_id` | 送金の id(nullable) | +| `created_at` / `updated_at` | | + +- 管理者(会計)は **DB ではなく** traQ グループ/環境変数で管理する(旧 DB 管理者テーブルは deprecated)。 +- 本名は照合・表示に必要なら Users に持つかを要検討(Stripe 側にはあるが、UI での突き合わせ用)。 + +## 8. UI / ページ設計 + +| パス | 内容 | 公開範囲 | +| --- | --- | --- | +| `/` | 各機能へのリンク(ログイン状態・権限で出し分け) | 公開 | +| `/login` | traQ OAuth ログイン(redirect クエリ保持) | 公開 | +| `/verify-email` | isct アドレス所有確認(redirect クエリ保持) | 公開 | +| `/membership` | 入部 / 再入部 / 現役部員の部費支払い(§5.1 の振り分け) | 公開 | +| `/admins` | 管理者の一覧・登録・削除 | 会計のみ | +| `/payments` | 入出金一覧(フィルタ・ページネーション) | 会計のみ | + +ヘッダー: サービスロゴ/ログイン時はアイコン/可能ならヘルプ・お問い合わせ。 + +## 9. 未決事項 / TODO + +- ~~verify-email の認証実体~~ → **決定: cookie セッション**(httpOnly cookie + `__Host-checkin_csrf` の double-submit CSRF)。JWT クライアント保持は不採用。 +- ~~請求書作成パラメータ~~ → **決定: `price_id` を受け取る**。1 Product「入部費」の下に 前期 ¥4,000 / 後期 ¥2,000 の 2 Price をぶら下げ、期で機械的に選択。現状 API の `product_id` を置き換える(Product 単体では金額が一意に決まらないため)。 +- **`account.updated` Webhook の取り回し**: 実装タスク。完了は単発イベントではなくアカウントのフラグ(`payouts_enabled` 等)で判定し冪等に状態遷移(詳細は §5.3)。 +- ~~Jomon の API 有無~~ → **API あり**([Jomon v2 API](https://apis.trap.jp/?urls.primaryName=Jomon%20v2%20API))。スクレイピング不要、API 連携で行く。 +- ~~Jomon との責任分界~~ → **決定: 承認・申請は Jomon、Checkin は払い戻しの実行のみ**。連携は pull(Checkin が承認済み振込依頼を取得 → 実行 → 結果を書き戻し)。 +- ~~サービス間認証~~ → **決定: Bearer サービストークン**。Jomon が Checkin 用トークンを発行、Checkin は env で保持し `Authorization: Bearer` で送信。権限は「振込依頼の取得/ステータス更新」のみにスコープ + 内部ネットワーク制限を併用。pull のみなので認証は Checkin → Jomon の片方向。(将来 push を足す場合のみ HMAC 署名 webhook を追加。) +- **【新規・要調整】Jomon 側のトークン受け口**: Jomon の API は現状 traQ OAuth/セッション中心。Bearer サービストークンの **検証+スコープ** の口が既存になければ Jomon 側に小さめの追加が必要。早めに Jomon メンテナと握る。 +- ~~サンドボックス権限~~ → **付与予定**。webhook 判定・払い戻しは sandbox で検証する。 + +## 10. 将来: v2 移行の watch ポイント + +- **Global Payouts / use-Accounts-as-customers の GA** を watch する。 +- GA 時の利点: v1 Customer は v2 Account に自動同期される。集金(customer)と払い戻し(recipient)を **1 つの v2 Account に統合** でき、対応表が Stripe 側に寄る。 +- 注意点: + - 既存の v1 connected は v2 統合にそのまま乗らず、**作り直し(再オンボード)が必要**。 + - **Global Payouts と Connect-Billing は 1 つの Account で併用不可**(本件は自社請求書なので Connect-Billing には該当しない見込みだが要検証)。 +- ドメイン層を Stripe 非依存に保っておけば、移行は **アダプタ差し替え** で済む。 + +## 余裕があったら(将来機能) + +- 部費支払いフローのステップバー表示 +- 入部・再入部フォーム機能(現在 Google フォーム)の置き換え +- 部内物販向けの任意額請求書 +- Jomon 払い戻しの一般化、`transfers` 一覧 \ No newline at end of file diff --git a/openspec/changes/archive/2026-06-19-add-auth-foundation/.openspec.yaml b/openspec/changes/archive/2026-06-19-add-auth-foundation/.openspec.yaml new file mode 100644 index 0000000..ff1fbc8 --- /dev/null +++ b/openspec/changes/archive/2026-06-19-add-auth-foundation/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-19 diff --git a/openspec/changes/archive/2026-06-19-add-auth-foundation/design.md b/openspec/changes/archive/2026-06-19-add-auth-foundation/design.md new file mode 100644 index 0000000..a9f2aab --- /dev/null +++ b/openspec/changes/archive/2026-06-19-add-auth-foundation/design.md @@ -0,0 +1,116 @@ +## Context + +Checkin は Nuxt 4(`apps/web`、Nitro サーバ内包)+ oRPC(`packages/api`)+ Drizzle/MariaDB(`packages/db`)の pnpm モノレポ。現状は health probe と暫定 `users` テーブルのみで、認証・本人識別が存在しない。本 change は後続(集金・入出金一覧・払い戻し)の全機能が依存する土台を作る。 + +design.md(リポジトリ直下)の決定事項に従う: + +- 本人キー = isct メールのハッシュ(サービス内 ID、isct 限定)。集金側・払い戻し側を 1 行に束ねる。メール平文は DB に持たず Stripe 側に保持。 +- セッションは httpOnly cookie + double-submit CSRF(`__Host-checkin_csrf`)。JWT クライアント保持は不採用(traP 他サービスとセッション方式で一貫)。 +- 会計は traQ OAuth + traQ グループ/環境変数で認可。本 change では env の traQ ID 許可リストを採用。 +- 対応表は自前 DB が source of truth。 + +確定済みの設計選択(ユーザーとの合意): + +- isct ドメイン = `@m.isct.ac.jp` 単一ホワイトリスト(env で変更可)。 +- `mail_hash` = HMAC-SHA256(正規化メール, 環境変数の秘密鍵)。 +- 会計判定 = env の traQ ID 許可リスト(traQ グループ連携は後続 change)。 + +## Goals / Non-Goals + +**Goals:** +- `mail_hash` を Stripe 非依存に定義し、`users` を対応表の土台として確立する。 +- isct メールのマジックリンクで利用者セッションを確立できる。 +- traQ OAuth + env 許可リストで会計セッションを確立できる。 +- `__Host-` cookie セッション + double-submit CSRF と、`requireUser` / `requireAdmin` 認可ヘルパを oRPC Context に提供する。 +- メール送信を `Mailer` アダプタで抽象化し、dev/log と本番実装を差し替え可能にする。 + +**Non-Goals:** +- Stripe Customer / Invoice / 集金フロー(後続 change)。 +- 入出金一覧、Jomon 連携・払い戻し・Connect onboarding(後続 change)。 +- traQ グループによる会計判定(env 許可リストで代替、将来拡張)。 +- 本名など追加プロフィールの保持(必要になった時点で別途検討)。 +- レート制限・監査ログの本格運用(基本的な単回トークン・期限のみ本 change で担保)。 + +## Decisions + +### D1. データモデル(`packages/db/src/schema.ts`) + +暫定 `users(id, name, created_at)` を置き換え、3 テーブルを追加する。 + +- **`users`**: `id`(PK, ULID/UUID 文字列), `mail_hash`(varchar, **unique**), `created_at`, `updated_at`。Stripe 系カラム(`stripe_customer_id` 等)は本 change では追加せず、集金/払い戻し change で `ADDED`/`MODIFIED` する。 +- **`sessions`**: `id`(不透明トークン由来の値ではなく、cookie に載せる ID は別途ランダム生成。DB には ID のハッシュを保存), `actor_type`(`user` | `admin`), `user_id`(FK→users, nullable, user の場合のみ), `traq_id`(varchar, nullable, admin の場合のみ), `expires_at`, `created_at`。 +- **`email_verifications`**: `id`(PK), `token_hash`(varchar, unique, トークン平文は保存しない), `mail_hash`, `redirect`(nullable, 同一サイトパス), `expires_at`, `consumed_at`(nullable), `created_at`。 + +cookie/トークンの実値は DB に平文保存せず、SHA-256 等のハッシュで保存・照合する(漏洩時の被害低減)。`pnpm db:generate` でマイグレーションを生成しコミット。 + +### D2. mail_hash の導出 + +`normalize(email) = trim → ローカル部・ドメインを lowercase`。`mail_hash = HMAC_SHA256(secret = env.MAIL_HASH_SECRET, message = normalize(email))` を hex で表現。`MAIL_HASH_SECRET` 未設定なら起動時/導出時にエラー(弱い既定値へのフォールバック禁止)。Node 標準 `crypto` を使用、追加依存なし。**注意**: 秘密鍵を後から変えると全 `mail_hash` が変わり対応が壊れるため、鍵はローテーション対象外として運用(design.md の対応表方針と整合)。 + +### D3. セッションと cookie + +- セッション ID はサーバで CSPRNG 生成(高エントロピー)。cookie 名 `__Host-checkin_session`、属性 `HttpOnly; Secure; SameSite=Lax; Path=/`(`Domain` 無し)。 +- DB には ID のハッシュを保存し、照合時にハッシュ比較。`expires_at` で失効。ログアウトは行削除(または失効印)。 +- `SameSite=Lax` を基本とし、加えて double-submit CSRF を併用(OAuth/外部からの遷移と整合しつつ多層防御)。 +- CSRF: `GET /csrf` が `__Host-checkin_csrf`(`Secure; SameSite=Lax; Path=/`、**HttpOnly なし**)を発行しトークン値を返す。状態変更要求はヘッダ `x-csrf-token` を cookie 値と突き合わせる。GET には適用しない。 + +### D4. Nitro ルート vs oRPC の役割分担 + +ブラウザ遷移・GET ページ・OAuth コールバックは **Nitro ルート**(`apps/web/server/routes/...`)、データ操作は **oRPC プロシージャ**(`packages/api`)。 + +- Nitro: `GET /login`(traQ 認可へリダイレクト), `GET /login/callback`(code 交換・会計判定・セッション確立), `GET /verify-email/confirm`(トークン消費・セッション確立・redirect), `GET /csrf`(CSRF cookie 発行), `POST /logout`(セッション破棄・cookie 削除)。 + - **理由**: cookie の発行・削除は h3 の `event` を要するため、oRPC プロシージャ(Response を返すだけで cookie 操作が難しい)ではなく Nitro ルートに置く。 +- oRPC: `auth.requestEmailVerification`(`POST /verify-email`、メール送信のみで cookie 不要), `auth.me`(現在のアクター取得)。 +- セッション復元: Nitro の oRPC マウント箇所(`apps/web/server/routes/rpc/[...].ts`)と各 Nitro ルートで、cookie からセッションを引き、oRPC `Context` に `session`(`{ actor: 'user'|'admin', userId?, traqId? } | null`)と `requireUser()` / `requireAdmin()` を載せる。 + +### D5. oRPC Context 拡張(`packages/api/src/orpc.ts`) + +``` +interface Context { + db: Database + session: SessionActor | null // { actor: 'user', userId, mailHash } | { actor: 'admin', traqId } + requireUser(): UserActor // session が user でなければ ORPCError(UNAUTHORIZED) + requireAdmin(): AdminActor // session が admin でなければ ORPCError(FORBIDDEN/UNAUTHORIZED) +} +``` + +利用者プロシージャは `requireUser()` の戻り値(`userId`/`mailHash`)で「自分のもの」に限定する。CSRF 検証は Nitro 層(oRPC マウント前)でまとめて行う。 + +### D6. traQ OAuth + +traP の traQ OAuth2(authorization code + PKCE)。`GET /login` で `state`+`code_verifier` を生成し、短命の署名付き cookie かサーバ側に保存 → traQ authorize へリダイレクト。`GET /login/callback` で `state` 検証 → token 交換 → traQ の自分情報取得(traq_id/name)→ `CHECKIN_ACCOUNTANT_TRAQ_IDS` 許可リスト照合 → 会計セッション確立 → `redirect`(同一サイトのみ)へ。エンドポイント URL/client_id/secret/scope は env。HTTP 呼び出しは Nitro の `$fetch`/`ofetch` を使い追加依存を最小化。 + +### D7. Mailer アダプタ + +`interface Mailer { send(params): Promise }`。`LogMailer`(dev、内容をログ)と本番実装(SendGrid 等)を env(例 `MAILER_DRIVER`)で選択。ドメインロジックは `Mailer` のみに依存。SendGrid SDK 採用可否は実装時に確定(HTTP API 直叩きでも可)。 + +### D8. 環境変数(`.env.example` 追記) + +`MAIL_HASH_SECRET`, `ISCT_ALLOWED_EMAIL_DOMAINS`(既定 `m.isct.ac.jp`), `SESSION_*`(任意), `CHECKIN_ACCOUNTANT_TRAQ_IDS`(カンマ区切り), `TRAQ_OAUTH_CLIENT_ID`/`_SECRET`/`_AUTHORIZE_URL`/`_TOKEN_URL`/`_USERINFO_URL`/`_SCOPE`, `APP_ORIGIN`(redirect 検証・絶対 URL 生成), `MAILER_DRIVER`+送信元アドレス+(本番)API キー, `EMAIL_VERIFICATION_TTL`/`SESSION_TTL`。 + +## Risks / Trade-offs + +- **`__Host-` cookie は HTTPS 必須** → ローカル開発で Secure cookie が機能しない懸念。Mitigation: 開発時は `localhost` の Secure cookie 許容(主要ブラウザは localhost を安全とみなす)/必要なら dev 限定で cookie 名・属性を緩める切替を用意。 +- **MAIL_HASH_SECRET の紛失・変更で対応表が壊れる** → Mitigation: 鍵を不変運用とし、シークレット管理(漏洩時は移行手順が別途必要)を明文化。 +- **オープンリダイレクト**(`redirect` 悪用)→ Mitigation: `redirect` は同一サイトの相対パスのみ許可、それ以外は既定先へ。 +- **マジックリンクの不正利用**(リンク漏洩・列挙)→ Mitigation: 単回・短命・高エントロピートークン、トークンはハッシュ保存。将来レート制限を追加(本 change 範囲外)。 +- **CSRF と SameSite の二重実装の手間** → Mitigation: 多層防御として割り切る(traP 他サービスと整合)。 +- **traQ OAuth エンドポイント/scope の差異** → Open Question で確定。 + +## Migration Plan + +1. `packages/db` のスキーマ置換(暫定 `users` → 認証用 `users`+`sessions`+`email_verifications`)、`pnpm db:generate` でマイグレーション生成・コミット。 +2. まだ実データが無い前提(スキャフォルド)なので破壊的置換で可。既存環境がある場合は暫定 `users` を drop。 +3. oRPC Context 拡張 → Nitro でのセッション復元・CSRF 配線 → 認証フロー(email-verification / traQ OAuth)→ `.env.example` 更新の順で実装。 +4. ロールバック: 本 change のマイグレーションを revert し、Context 拡張前の状態へ戻す(後続 change 未着手のうちは影響局所)。 + +## Resolved (ユーザー確定) + +- **traQ OAuth**: クライアントは登録済み。接続情報(client_id/secret・authorize/token/userinfo URL・scope)は **env から読み込む**。コードに URL をハードコードしない。 +- **セッション保存先**: **MariaDB テーブル**で確定(将来トラフィック増時に KV/Redis を別途検討)。 +- **本番 Mailer**: **SendGrid** で確定。実装は dev の `LogMailer` から始め、本番ドライバとして SendGrid 実装を用意。 + +## Open Questions + +- traQ の userinfo レスポンスで traq_id を表すフィールド名(実装時に traP の traQ OAuth レスポンスで確定。env 化はしない)。 +- 利用者にもログアウト UI を出すか(API は用意。UI は後続の `/` ページ change で)。 diff --git a/openspec/changes/archive/2026-06-19-add-auth-foundation/proposal.md b/openspec/changes/archive/2026-06-19-add-auth-foundation/proposal.md new file mode 100644 index 0000000..424dc48 --- /dev/null +++ b/openspec/changes/archive/2026-06-19-add-auth-foundation/proposal.md @@ -0,0 +1,36 @@ +## Why + +Checkin の集金・入出金一覧・払い戻しは、いずれも「誰が操作しているか」と「その人が誰か(対応表の本人キー)」を前提にする。 +現状はこの土台が無く、本人識別はスプレッドシートの手作業に依存している。最初の change として、後続の全機能が乗る +**認証・本人識別の基盤**を用意する。design.md の原則「人 ↔ Stripe オブジェクトの対応表は自前 DB を source of truth」に従い、 +本人キー(`mail_hash`)を Stripe 非依存に定義する。 + +## What Changes + +- **本人識別モデルの導入**: `users` テーブルを `mail_hash`(= HMAC-SHA256(正規化した isct メール, 秘密鍵))を unique キーとして定義する。メール平文は DB に保持しない。 + - **BREAKING**: スキャフォルドの暫定 `users(id, name, created_at)` を、この本人識別用スキーマに置き換える。 +- **isct メール確認(マジックリンク)**: `@m.isct.ac.jp` のみ許可するホワイトリスト検証 → 確認メール送信 → メール内リンクで確認 → 利用者(isct 本人)セッションを確立し、`users` 行が無ければ作成する。メール送信は `Mailer` アダプタ抽象(dev/log 実装 + SendGrid 実装、env で切替)越しに行う。 +- **セッション**: サーバ側セッション保存 + httpOnly / Secure / SameSite の `__Host-` cookie。`__Host-checkin_csrf` を使った double-submit CSRF。セッションは「利用者(mail_hash)」と「会計(traQ)」の両アクターを表現できる。 +- **会計(管理者)認可**: `GET /login` で traQ OAuth(authorization code + PKCE)→ コールバックで traq_id を取得 → 環境変数の traQ ID 許可リストで会計判定 → 管理者セッションを確立する。`redirect` クエリを保持する。**旧 DB 管理者テーブルは作らない**(deprecated)。 +- **認可ヘルパ**: oRPC の `Context` に `requireUser` / `requireAdmin` を用意し、利用者操作は常に「自分のもの」に限定できるようにする。 + +明確に**スコープ外**(後続 change): Stripe Customer / Invoice / 集金フロー、入出金一覧、Jomon 連携・払い戻し・Connect onboarding。 + +## Capabilities + +### New Capabilities +- `identity`: サービス内の本人表現。`users` テーブル、`mail_hash` の正規化・導出規則(HMAC-SHA256 + 秘密鍵)、メール平文を保存しない方針、本人行の取得・作成(get-or-create)。 +- `email-verification`: isct メールのマジックリンク確認。ドメインホワイトリスト検証、単回・期限付きトークンの発行と消費、`Mailer` アダプタ抽象、確認成功時の利用者セッション確立。 +- `admin-authorization`: 会計の認証・認可。traQ OAuth(authorization code + PKCE)、`redirect` 保持、env の traQ ID 許可リストによる会計判定、管理者セッション確立。 +- `session`: セッションとアクセス制御の基盤。サーバ側セッション保存、`__Host-` cookie(httpOnly/Secure/SameSite)、`__Host-checkin_csrf` の double-submit CSRF、セッション確立・破棄、`requireUser` / `requireAdmin` 認可ヘルパ。 + +### Modified Capabilities + + +## Impact + +- **DB(`packages/db`)**: 暫定 `users` を置き換え、`sessions`・`email_verifications` テーブルを追加。Drizzle マイグレーションを生成・コミット。 +- **API(`packages/api`)**: oRPC `Context` を拡張(`session` / `requireUser` / `requireAdmin`)。`csrf`・`verify-email` 系の oRPC プロシージャを追加。 +- **Web/Nitro(`apps/web`)**: ブラウザ遷移・GET ページ・OAuth コールバックは Nitro ルート(`GET /login`, OAuth callback, `GET /verify-email/confirm`)。Nitro ハンドラで cookie からセッションを復元して `Context` を構築。 +- **依存・設定**: traQ OAuth クライアント情報、`Mailer`(SendGrid)API キー、`mail_hash` 秘密鍵、許可ドメイン、会計 traQ ID 許可リストを env に追加(`.env.example` 更新)。新規依存の候補: traQ OAuth/HTTP クライアント、メール送信 SDK。 +- **横断方針**: 以降の全 capability がこの認証・本人識別を前提にする(集金・一覧・払い戻し)。 diff --git a/openspec/changes/archive/2026-06-19-add-auth-foundation/specs/admin-authorization/spec.md b/openspec/changes/archive/2026-06-19-add-auth-foundation/specs/admin-authorization/spec.md new file mode 100644 index 0000000..ae84314 --- /dev/null +++ b/openspec/changes/archive/2026-06-19-add-auth-foundation/specs/admin-authorization/spec.md @@ -0,0 +1,43 @@ +## ADDED Requirements + +### Requirement: traQ OAuth による会計ログイン + +会計(管理者)は traQ OAuth の authorization code フロー(PKCE 併用)でログイン SHALL。`GET /login` は state と PKCE code_verifier を生成・保存して traQ の認可エンドポイントへリダイレクトし、コールバックで `state` を検証し、code を access token に交換して traQ のユーザー情報(traq_id)を取得 MUST。`state` 不一致や code 交換失敗時はセッションを確立せずエラーとする SHALL。 + +#### Scenario: 正常な OAuth ログイン + +- **WHEN** 会計が `GET /login` を開始し、traQ で認可してコールバックに戻る +- **THEN** `state` が検証され、code が token に交換され、traq_id が取得される + +#### Scenario: state 不一致は拒否 + +- **WHEN** コールバックの `state` が保存値と一致しない +- **THEN** セッションは確立されず、エラーとして扱われる + +### Requirement: env 許可リストによる会計判定 + +システムは、OAuth で得た traq_id が環境変数の会計 traQ ID 許可リストに含まれる場合のみ、管理者(会計)セッションを確立 SHALL。許可リスト外の traq_id には管理者権限を付与しては SHALL NOT。会計の管理は DB の管理者テーブルではなく、この許可リスト(将来 traQ グループ連携へ拡張可能)で行う MUST。 + +#### Scenario: 許可リスト内は会計になる + +- **WHEN** 許可リストに含まれる traq_id でログインが完了する +- **THEN** 管理者(会計)セッションが確立される + +#### Scenario: 許可リスト外は会計にならない + +- **WHEN** 許可リストに含まれない traq_id でログインが完了する +- **THEN** 管理者セッションは確立されず、会計権限は付与されない + +### Requirement: ログインの redirect 保持 + +`GET /login` は `redirect` クエリを受け取り、OAuth フローを通じて保持 SHALL。ログイン成立後、指定された **同一サイト内** の `redirect` 先へ遷移する MUST。オープンリダイレクトを防ぐため、外部 URL への `redirect` は拒否または無視する SHALL。 + +#### Scenario: 同一サイトの redirect は保持される + +- **WHEN** `GET /login?redirect=/payments` からログインが完了する +- **THEN** ログイン後に `/payments` へ遷移する + +#### Scenario: 外部 redirect は拒否される + +- **WHEN** `redirect` に外部 URL(別オリジン)が指定される +- **THEN** その遷移先は使われず、安全な既定先へ遷移する diff --git a/openspec/changes/archive/2026-06-19-add-auth-foundation/specs/email-verification/spec.md b/openspec/changes/archive/2026-06-19-add-auth-foundation/specs/email-verification/spec.md new file mode 100644 index 0000000..54bc3a1 --- /dev/null +++ b/openspec/changes/archive/2026-06-19-add-auth-foundation/specs/email-verification/spec.md @@ -0,0 +1,66 @@ +## ADDED Requirements + +### Requirement: isct ドメインのホワイトリスト検証 + +システムは、確認メールの送信を要求された際、入力アドレスが許可ドメイン(既定 `@m.isct.ac.jp`)に属する場合のみ受理 SHALL。許可ドメインは環境変数で設定可能とする。ドメイン外のアドレスに対しては確認メールを送信せず、エラーを返す MUST。 + +#### Scenario: 許可ドメインは受理される + +- **WHEN** 利用者が `someone@m.isct.ac.jp` で確認を要求する +- **THEN** システムは確認メールの送信処理に進む + +#### Scenario: ドメイン外は拒否される + +- **WHEN** 利用者が `someone@gmail.com` で確認を要求する +- **THEN** システムは確認メールを送信せず、ドメイン不許可のエラーを返す + +### Requirement: 単回・期限付きマジックリンクの発行 + +確認要求に対し、システムは単回利用(single-use)かつ有効期限付きのトークンを発行し、そのトークンを含むリンクを当該アドレスへメール送信 SHALL。DB にはトークンの平文ではなくハッシュと、対象の `mail_hash`、有効期限、消費状態を保存 MUST。トークンの推測が困難な十分なエントロピーを持つ MUST。 + +#### Scenario: 確認メールが送られる + +- **WHEN** 許可ドメインのアドレスで確認が要求される +- **THEN** 単回・期限付きトークンが発行され、確認リンクを含むメールが送信され、トークンのハッシュと `mail_hash`・有効期限が保存される + +### Requirement: 有効なトークンの消費でセッション確立 + +利用者がメール内リンク経由で有効・未消費・未期限切れのトークンを提示した場合、システムはトークンを消費(以後使用不可に)し、対応する `mail_hash` の本人行を get-or-create し、利用者セッションを確立 SHALL。 + +#### Scenario: 正常な確認 + +- **WHEN** 利用者が有効・未消費・期限内のトークンを提示する +- **THEN** トークンは消費され、`mail_hash` の本人行が存在することが保証され、利用者セッションが確立される + +#### Scenario: 確認後に redirect を保持して遷移する + +- **WHEN** 確認要求時に `redirect` が指定されており、トークンの消費が成功する +- **THEN** セッション確立後、指定された安全な(同一サイトの)`redirect` 先へ遷移する + +### Requirement: 無効・期限切れ・再利用トークンの拒否 + +システムは、存在しない/期限切れ/既に消費済みのトークンに対し、セッションを確立せずエラー(または無効表示)を返す SHALL。消費済みトークンの再提示でセッションが確立されては SHALL NOT。 + +#### Scenario: 期限切れトークン + +- **WHEN** 有効期限を過ぎたトークンが提示される +- **THEN** セッションは確立されず、無効として扱われる + +#### Scenario: 再利用の防止 + +- **WHEN** 一度消費されたトークンが再度提示される +- **THEN** セッションは確立されず、無効として扱われる + +### Requirement: メール送信は Mailer アダプタ越し + +確認メールの送信は `Mailer` アダプタ抽象を介して行う SHALL。実装は環境設定で切替可能とし、少なくとも開発用(ログ出力)実装と本番用(SendGrid 等の外部サービス)実装を差し替えられる MUST。アプリケーション側のドメインロジックは具体的な送信サービスに依存しては SHALL NOT。 + +#### Scenario: 開発時はログ実装で送信される + +- **WHEN** 開発環境で確認メール送信が要求される +- **THEN** 外部サービスを呼ばずに送信内容がログ実装に渡される + +#### Scenario: 本番は外部サービス実装に切り替わる + +- **WHEN** 本番設定で確認メール送信が要求される +- **THEN** 設定された外部メールサービス実装を通じて送信される diff --git a/openspec/changes/archive/2026-06-19-add-auth-foundation/specs/identity/spec.md b/openspec/changes/archive/2026-06-19-add-auth-foundation/specs/identity/spec.md new file mode 100644 index 0000000..f37be45 --- /dev/null +++ b/openspec/changes/archive/2026-06-19-add-auth-foundation/specs/identity/spec.md @@ -0,0 +1,52 @@ +## ADDED Requirements + +### Requirement: 本人キーは mail_hash + +システムは、サービス内の本人を `mail_hash` で識別 SHALL。`mail_hash` は isct メールアドレスから導出される不透明な識別子であり、集金側(Customer)と払い戻し側(connected account)を 1 つの本人行に束ねる対応表の主キーとして用いる。`mail_hash` は `users` テーブルで unique であり、同一人物(同一 isct メール)は常に同一の `mail_hash` を持つ MUST。 + +#### Scenario: 同一メールは同一の本人行に解決される + +- **WHEN** 同一の isct メールから 2 回 `mail_hash` を導出する +- **THEN** 同一の `mail_hash` が得られ、同一の `users` 行に解決される + +#### Scenario: 異なるメールは異なる本人行になる + +- **WHEN** 異なる 2 つの isct メールから `mail_hash` を導出する +- **THEN** 異なる `mail_hash` が得られ、別々の `users` 行になる + +### Requirement: mail_hash の正規化と導出 + +`mail_hash` は、メールアドレスを正規化(前後空白の除去、ローカル部とドメインの小文字化)したうえで、環境変数で与えられる秘密鍵を用いた **HMAC-SHA256** によって導出 SHALL。導出は決定的であり、同じ入力からは常に同じ出力を返す MUST。秘密鍵が未設定の場合、システムは起動・処理を継続せずエラーとする SHALL(弱い既定値へフォールバックしてはならない)。 + +#### Scenario: 大文字小文字・空白が異なっても同一になる + +- **WHEN** `" Foo.Bar@m.isct.ac.jp "` と `"foo.bar@m.isct.ac.jp"` をそれぞれ正規化して導出する +- **THEN** 両者の `mail_hash` は一致する + +#### Scenario: 秘密鍵が未設定なら処理を拒否する + +- **WHEN** `mail_hash` 用の秘密鍵が環境変数に設定されていない状態で導出が要求される +- **THEN** システムはエラーを返し、弱い既定値での導出は行わない + +### Requirement: メール平文を永続化しない + +システムは、isct メールの平文を DB に保存しては SHALL NOT。本人識別に必要なのは `mail_hash` のみであり、メール平文は確認メールの送信など処理中の一時的な利用に限る。メール平文の保持が必要な場面(請求先など)は Stripe 側に委ねる。 + +#### Scenario: 確認後に平文が残らない + +- **WHEN** isct メール確認が完了し本人行が作成される +- **THEN** `users` 行にはメール平文を保持する列が存在せず、`mail_hash` のみが保存される + +### Requirement: 本人行の get-or-create + +システムは、確認済みの `mail_hash` に対して本人行を取得し、無ければ作成する操作を提供 SHALL。作成は冪等であり、競合時にも `mail_hash` の unique 制約により重複行を作らない MUST。 + +#### Scenario: 初回は作成される + +- **WHEN** 既存行の無い `mail_hash` で get-or-create を呼ぶ +- **THEN** 新しい `users` 行が作成され、その行が返る + +#### Scenario: 2 回目は既存行が返る + +- **WHEN** 既に行のある `mail_hash` で get-or-create を呼ぶ +- **THEN** 新しい行は作られず、既存の行が返る diff --git a/openspec/changes/archive/2026-06-19-add-auth-foundation/specs/session/spec.md b/openspec/changes/archive/2026-06-19-add-auth-foundation/specs/session/spec.md new file mode 100644 index 0000000..7b983fd --- /dev/null +++ b/openspec/changes/archive/2026-06-19-add-auth-foundation/specs/session/spec.md @@ -0,0 +1,80 @@ +## ADDED Requirements + +### Requirement: サーバ側セッションと __Host- cookie + +システムは、サーバ側にセッション実体を保存し、不透明(推測困難)なセッション ID を `__Host-` 接頭辞付きの cookie で受け渡し SHALL。cookie 属性は `HttpOnly`・`Secure`・`SameSite`(既定 `Lax`)・`Path=/` とし、`Domain` 属性は付与しない MUST(`__Host-` の制約に従う)。セッションは有効期限を持ち、期限切れ・破棄後の ID では認証されては SHALL NOT。 + +#### Scenario: セッション cookie の属性 + +- **WHEN** セッションが確立され cookie が発行される +- **THEN** cookie は `__Host-` 接頭辞付きで `HttpOnly`・`Secure`・`SameSite`・`Path=/` を持ち、`Domain` を持たない + +#### Scenario: 期限切れセッションは無効 + +- **WHEN** 有効期限を過ぎたセッション ID でアクセスする +- **THEN** 認証されず、未ログインとして扱われる + +### Requirement: セッションは利用者・会計のいずれかのアクターを表す + +セッション実体は、確立元に応じて「利用者(`mail_hash` で識別)」または「会計(traq_id で識別)」のいずれのアクターであるかを保持 SHALL。両者を取り違えて権限を付与しては SHALL NOT。 + +#### Scenario: 利用者セッション + +- **WHEN** isct メール確認によりセッションが確立される +- **THEN** セッションは利用者アクターとして `mail_hash` に紐づく + +#### Scenario: 会計セッション + +- **WHEN** traQ OAuth + 許可リストによりセッションが確立される +- **THEN** セッションは会計アクターとして traq_id に紐づく + +### Requirement: CSRF cookie の発行(double-submit) + +システムは、`__Host-checkin_csrf` cookie を発行する手段(`GET /csrf`)を提供 SHALL。CSRF cookie は double-submit 方式で用い、JavaScript から読めるよう `HttpOnly` を付けない(ただし `Secure`・`Path=/`・`SameSite`)。値は推測困難であり、クライアントは状態変更要求時にこの値をヘッダ等で送り返す MUST。 + +#### Scenario: CSRF cookie を取得できる + +- **WHEN** クライアントが `GET /csrf` を呼ぶ +- **THEN** `__Host-checkin_csrf` cookie が発行され、対応するトークン値が返る + +### Requirement: 状態変更要求の CSRF 検証 + +システムは、状態を変更する要求(POST/PATCH/DELETE 等)に対し、リクエストヘッダ等で提示された CSRF トークンと `__Host-checkin_csrf` cookie の値が一致することを検証 SHALL。一致しない、または欠落している場合は処理を拒否する MUST。安全(冪等・副作用なし)な GET には適用しない。 + +#### Scenario: 一致すれば許可 + +- **WHEN** ヘッダの CSRF トークンが cookie の値と一致する状態変更要求が来る +- **THEN** 要求は CSRF 検証を通過する + +#### Scenario: 不一致・欠落は拒否 + +- **WHEN** CSRF トークンが欠落、または cookie の値と一致しない状態変更要求が来る +- **THEN** 要求は拒否される + +### Requirement: 認可ヘルパ requireUser / requireAdmin + +システムは、API のリクエスト Context にセッションを復元し、`requireUser` と `requireAdmin` の認可ヘルパを提供 SHALL。`requireUser` は有効な利用者セッションが無ければ拒否し、操作対象を当該利用者「自分のもの」に限定する手段を与える MUST。`requireAdmin` は会計セッションが無ければ拒否する MUST。 + +#### Scenario: 未ログインで requireUser + +- **WHEN** セッションの無いリクエストが `requireUser` を要する操作を呼ぶ +- **THEN** 認証エラーで拒否される + +#### Scenario: 利用者が requireAdmin + +- **WHEN** 利用者セッション(会計ではない)で `requireAdmin` を要する操作を呼ぶ +- **THEN** 認可エラーで拒否される + +#### Scenario: 会計が requireAdmin + +- **WHEN** 会計セッションで `requireAdmin` を要する操作を呼ぶ +- **THEN** 操作が許可される + +### Requirement: ログアウト + +システムは、現在のセッションを破棄する手段を提供 SHALL。破棄後はサーバ側セッション実体が無効化され、同じ cookie 値では認証されては SHALL NOT。 + +#### Scenario: ログアウトでセッション無効化 + +- **WHEN** ログイン中の利用者または会計がログアウトする +- **THEN** サーバ側セッションが無効化され、以後同じ cookie では認証されない diff --git a/openspec/changes/archive/2026-06-19-add-auth-foundation/tasks.md b/openspec/changes/archive/2026-06-19-add-auth-foundation/tasks.md new file mode 100644 index 0000000..04fea10 --- /dev/null +++ b/openspec/changes/archive/2026-06-19-add-auth-foundation/tasks.md @@ -0,0 +1,50 @@ +## 1. データモデル(packages/db) + +- [x] 1.1 暫定 `users(id, name, created_at)` を認証用 `users(id, mail_hash unique, created_at, updated_at)` に置き換える(Stripe 系カラムは追加しない) +- [x] 1.2 `sessions` テーブルを追加(`id`, `id_hash` unique, `actor_type`(user|admin), `user_id` FK nullable, `traq_id` nullable, `expires_at`, `created_at`) +- [x] 1.3 `email_verifications` テーブルを追加(`id`, `token_hash` unique, `mail_hash`, `redirect` nullable, `expires_at`, `consumed_at` nullable, `created_at`) +- [x] 1.4 `pnpm db:generate` でマイグレーションを生成し、`packages/db/drizzle/` をコミット(生成 SQL は手編集しない) + +## 2. 本人識別(identity) + +- [x] 2.1 メール正規化関数(trim + ローカル部・ドメイン lowercase)を実装 +- [x] 2.2 `mail_hash = HMAC-SHA256(MAIL_HASH_SECRET, 正規化メール)` を Node `crypto` で実装。`MAIL_HASH_SECRET` 未設定はエラー(弱い既定値へフォールバックしない) +- [x] 2.3 `mail_hash` での本人行 get-or-create(冪等、unique 制約で重複回避)を実装 +- [x] 2.4 identity 単体テスト(同一メール→同一 hash、空白/大小無視、秘密鍵未設定エラー、get-or-create 冪等) + +## 3. セッション基盤(session) + +- [x] 3.1 セッション ID の CSPRNG 生成と DB へのハッシュ保存・照合を実装 +- [x] 3.2 セッション確立/復元/破棄(ログアウト)と有効期限失効を実装 +- [x] 3.3 `__Host-checkin_session` cookie の発行・読取(HttpOnly/Secure/SameSite=Lax/Path=/、Domain なし)を実装。localhost 開発時の Secure cookie 方針を反映 +- [x] 3.4 Nitro `GET /csrf`(cookie 発行のため oRPC ではなく Nitro ルート)で `__Host-checkin_csrf`(HttpOnly なし、Secure/SameSite/Path=/)発行+トークン返却 +- [x] 3.5 状態変更要求の double-submit CSRF 検証(ヘッダ `x-csrf-token` と cookie 一致、GET は除外)を Nitro 層で実装 + +## 4. oRPC Context と認可ヘルパ + +- [x] 4.1 `packages/api/src/orpc.ts` の `Context` に `session` / `requireUser()` / `requireAdmin()` を追加 +- [x] 4.2 Nitro 側(`apps/web/server/...`)で cookie からセッションを復元し `Context` を構築するユーティリティを実装 +- [x] 4.3 `apps/web/server/routes/rpc/[...].ts` のマウントで CSRF 検証+セッション復元を配線 +- [x] 4.4 `requireUser` / `requireAdmin` の挙動テスト(未ログイン拒否、利用者→requireAdmin 拒否、会計→許可) + +## 5. isct メール確認(email-verification) + +- [x] 5.1 `Mailer` アダプタ抽象+`LogMailer`(dev)を実装し、env `MAILER_DRIVER` で選択 +- [x] 5.2 oRPC `auth.requestEmailVerification`(`POST /verify-email`): ドメインホワイトリスト検証 → 単回・期限付きトークン発行(ハッシュ保存)→ 確認リンクをメール送信。`redirect` を保持 +- [x] 5.3 Nitro `GET /verify-email/confirm`: トークン消費(単回・期限・再利用拒否)→ 本人行 get-or-create → 利用者セッション確立 → 同一サイトの `redirect` へ遷移 +- [x] 5.4 email-verification テスト(ドメイン外拒否、期限切れ拒否、再利用拒否、正常確認でセッション確立) + +## 6. 会計 traQ OAuth(admin-authorization) + +- [x] 6.1 Nitro `GET /login`: `state`+PKCE `code_verifier` 生成・保存、`redirect`(同一サイト)保持、traQ authorize へリダイレクト +- [x] 6.2 Nitro `GET /login/callback`: `state` 検証 → code→token 交換 → traq_id 取得 → `CHECKIN_ACCOUNTANT_TRAQ_IDS` 許可リスト照合 → 会計セッション確立 → `redirect` へ +- [x] 6.3 オープンリダイレクト対策(外部 URL の `redirect` は既定先へ)を `/login` と `/verify-email/confirm` 双方に適用 +- [x] 6.4 admin-authorization テスト(state 不一致拒否、許可リスト内→会計、許可リスト外→会計にならない、redirect 保持) + +## 7. 設定・配線・検証 + +- [x] 7.1 `.env.example` に新規 env(MAIL_HASH_SECRET, ISCT_ALLOWED_EMAIL_DOMAINS, CHECKIN_ACCOUNTANT_TRAQ_IDS, TRAQ_OAUTH_*, APP_ORIGIN, MAILER_*, TTL 各種)を追記 +- [x] 7.2 `auth.me`(現在のアクター取得)を oRPC に追加。ログアウトは cookie 削除のため Nitro `POST /logout`(CSRF 検証付き)として実装 +- [x] 7.5 クライアント oRPC プラグインに double-submit CSRF を統合(cookie を `x-csrf-token` で送信、無ければ `GET /csrf` を取得)— Codex レビュー指摘対応 +- [x] 7.3 `pnpm lint` / `pnpm typecheck` / `pnpm build` をグリーンにする +- [x] 7.4 ローカルで E2E 動作確認(メール確認でログイン→ requireUser 通過、traQ ログイン→ requireAdmin 通過、CSRF 欠落で拒否) diff --git a/openspec/changes/archive/2026-06-19-add-membership-collection/.openspec.yaml b/openspec/changes/archive/2026-06-19-add-membership-collection/.openspec.yaml new file mode 100644 index 0000000..ff1fbc8 --- /dev/null +++ b/openspec/changes/archive/2026-06-19-add-membership-collection/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-19 diff --git a/openspec/changes/archive/2026-06-19-add-membership-collection/design.md b/openspec/changes/archive/2026-06-19-add-membership-collection/design.md new file mode 100644 index 0000000..935f3aa --- /dev/null +++ b/openspec/changes/archive/2026-06-19-add-membership-collection/design.md @@ -0,0 +1,106 @@ +## Context + +[[add-auth-foundation]] でセッション・本人識別(`mail_hash`/`users`)・認可ヘルパ(`requireUser`/`requireAdmin`)・CSRF が整った。本 change はその上に第一弾スコープ①「集金」のバックエンドを乗せる。design.md(リポジトリ直下)§3/§5.1/§7 と整合。 + +確定済みの価格・運用(ユーザー合意): + +- 新規入部費: 前期(4–9月)¥4,000 / 後期(10–3月)¥2,000。活動年度 4/1–3/31、前期=4–9月・後期=10–3月。利用者が自己発行。 +- 継続(部費): 標準¥4,000(利用者が自己発行)/ 特別¥2,000(**管理者のみ**が対象指定で発行)。 +- 価格は `price_id` で指定(Product 単体では金額が定まらないため)。 + +制約: Stripe は test mode で開始。Stripe 依存はアダプタに隔離(切り替え可能性の担保)。メール平文は DB に保存しない。 + +## Goals / Non-Goals + +**Goals:** +- Stripe アダプタ層を境界として導入し、集金ドメインを Stripe 非依存に保つ。 +- `mail_hash` ↔ Stripe Customer の get-or-create と `stripe_customer_id` の永続化。 +- 費目・期・標準/特別から `price_id` を決定し、Invoice を作成→finalize→send。 +- 発行の認可(標準=本人のみ/特別=管理者のみ)。 +- `invoice.paid` Webhook の署名検証・冪等処理・会計通知。 + +**Non-Goals:** +- `/membership` の UI 振り分け・フォーム(後続 change)。 +- 入出金一覧 `/list/*`(後続、Stripe から直接取得)。 +- 払い戻し・Jomon 連携・Connect onboarding(後続)。 +- resend/催促、任意額請求書、資金繰り。 + +## Decisions + +### D1. 依存とディレクトリ + +- `stripe` 公式 SDK を `packages/api` に追加。 +- `packages/api/src/stripe/`(アダプタ): `customers.ts`(get-or-create/search/create)、`invoices.ts`(create+item+finalize+send)、`client.ts`(SDK 初期化、env から `STRIPE_SECRET_KEY`)、`webhook.ts`(`constructEvent` 署名検証)。 +- `packages/api/src/billing/`(ドメイン、Stripe 非依存): `term.ts`(期判定)、`pricing.ts`(費目・期・区分→`price_id`)、`authorize.ts`(発行認可)。 + +### D2. データモデル(`packages/db`) + +- `users` に `stripe_customer_id varchar(255) nullable` を追加(identity の MODIFIED と整合:非 PII 参照は保持可)。 +- `stripe_events` 追加(Webhook 冪等): `event_id varchar(255)` unique, `type varchar`, `received_at timestamp default now`。 +- 請求書自体は DB に持たない(source は Stripe。一覧は後続 change が Stripe から取得)。 +- `pnpm db:generate` でマイグレーション生成・コミット。**注意**: 既存マイグレーションとの列追加 diff で drizzle-kit が対話を求める場合は、列追加は新規 ALTER として生成(rename 誤検出に注意。必要なら明示的に新規列として進める)。 + +### D3. Customer の get-or-create(順序) + +1. `users.stripe_customer_id` があれば使用。 +2. 無ければ提出メールで Stripe 検索(`customers.search` か `customers.list({email})`)。一致あれば採用し DB 保存。 +3. 無ければ `customers.create({ email, name, metadata: { mail_hash, traq_id? } })` → DB 保存。 + +メール平文は (2)(3) の一時利用のみ。`traq_id` は metadata/ログ用で参照キーにはしない(信頼できないため)。 + +### D4. 価格決定(ドメイン) + +- env: `PRICE_SHINKI_ZENKI`(新規前期¥4,000) / `PRICE_SHINKI_KOUKI`(新規後期¥2,000) / `PRICE_KEIZOKU_STANDARD`(継続標準¥4,000) / `PRICE_KEIZOKU_SPECIAL`(継続特別¥2,000)。 +- `computeTerm(date)`: 月 4–9 → `zenki` / 10–12,1–3 → `kouki`(活動年度は会計年度ラベル用に併せて算出可)。 +- `selectPriceId({ feeType, term, variant })`: + - `feeType=new` → `term==='zenki' ? PRICE_SHINKI_ZENKI : PRICE_SHINKI_KOUKI` + - `feeType=continuation, variant=standard` → `PRICE_KEIZOKU_STANDARD` + - `feeType=continuation, variant=special` → `PRICE_KEIZOKU_SPECIAL` + +### D5. 発行 API(oRPC)と認可 + +- `membership.issueInvoice`(`requireUser` + `assertCsrf`): input `{ email, name, feeType: 'new' | 'continuation' }`。 + - `deriveMailHash(email, secret) === session.mailHash` を検証(他人の分を発行不可)。不一致は `FORBIDDEN`。 + - `variant='standard'`(特別は不可)。`feeType=new` は `computeTerm(now)` で price 決定。 + - Customer を get-or-create → Invoice 作成→finalize→send。 +- `membership.issueSpecialInvoice`(`requireAdmin` + `assertCsrf`): input `{ userId, name?, email? }`。 + - 対象 `users` 行を取得。`stripe_customer_id` があればそれを使用。無ければ `email`(必須)で Customer 作成。 + - `PRICE_KEIZOKU_SPECIAL` で Invoice 作成→finalize→send。 +- 返り値: `{ invoiceId, hostedInvoiceUrl? }`(Dashboard/支払い URL は Stripe レスポンスから)。 + +### D6. Webhook(Nitro) + +- `apps/web/server/routes/webhook/invoice-paid.post.ts`。**raw body** が必須(`readRawBody(event)`)→ `stripe.webhooks.constructEvent(raw, sig, STRIPE_WEBHOOK_SECRET)`。 +- 冪等: `stripe_events` に `event_id` を unique 挿入。重複(既処理)ならスキップ。 +- 副作用: `Notifier.notify(...)`(`LogNotifier` 実装で会計向けメッセージ。将来 traQ 通知等へ差し替え)。 +- CSRF/セッションは適用しない(Stripe→サーバの署名認証で代替)。oRPC マウントの CSRF 配線とは別ルート。 + +### D7. Notifier 抽象 + +`interface Notifier { notify(message): Promise }`、`LogNotifier`(dev/当面)。env で将来切替。`Mailer` と同様にドメインから具体実装を隠す。 + +### D8. 環境変数(`.env.example` 追記) + +`STRIPE_SECRET_KEY`(test)、`STRIPE_WEBHOOK_SECRET`、`PRICE_SHINKI_ZENKI`/`PRICE_SHINKI_KOUKI`/`PRICE_KEIZOKU_STANDARD`/`PRICE_KEIZOKU_SPECIAL`、`INVOICE_DAYS_UNTIL_DUE`(既定 7 等)。`AuthConfig` と並ぶ `BillingConfig` を `packages/api` に型定義し、Nitro が runtimeConfig から解決して Context に載せる。 + +## Risks / Trade-offs + +- **ログイン後にメール平文を持たない** → Customer 作成・送付に必要。Mitigation: 発行 API でメールを再提出させ、`mail_hash` 一致を検証(平文は一時利用、保存しない)。UX は後続 UI change で吸収。 +- **Stripe 二重発行**(連打)→ Mitigation: Invoice 作成に決定的な冪等キー(`idempotencyKey`)を付与し、連打を Stripe の冪等ウィンドウ内で重複排除する。**Follow-up(意図的に後続)**: 期をまたいだ「既に発行済み/支払済み」を判定する永続的な発行台帳は本 change のスコープ外。冪等キーは短時間の二重 submit のみを抑止するもので、台帳ではない。 +- **Webhook の順序・再送** → Mitigation: `event_id` 冪等。`invoice.paid` 以外は無視。 +- **drizzle-kit の列追加対話** → Mitigation: 非対話で生成できるよう列を新規追加として扱う(rename 誤検出回避)。 +- **テスト mode キー未投入**では E2E 不可 → Mitigation: 期判定・価格・認可はユニットテスト。アダプタは型・ビルドで担保し、キー投入後に E2E。 + +## Migration Plan + +1. `stripe` 追加、`users.stripe_customer_id` + `stripe_events` のスキーマ追加 → `pnpm db:generate` → migrate。 +2. Stripe アダプタ(client/customers/invoices/webhook)→ billing ドメイン(term/pricing/authorize)→ oRPC 発行 API → Webhook ルート → `.env.example`。 +3. ユニットテスト(term/pricing/authorize)。 +4. test mode キー投入後、Stripe sandbox で発行→支払い→`invoice.paid` 通知の E2E。 +5. ロールバック: 本 change のマイグレーション revert + 発行 API/Webhook を無効化(後続未着手なら影響局所)。 + +## Open Questions + +- `customers.search`(要 Search API 有効)か `customers.list({email})` か(実装で確定。list で十分な見込み)。 +- `hosted_invoice_url` を返却に含めるか(送付はメールだが、UI 表示用に返すと便利)。実装で含める方針。 +- 継続(部費)の「対象年度」表記を Invoice description/metadata にどう持たせるか(活動年度ラベル)。実装で metadata に付与。 diff --git a/openspec/changes/archive/2026-06-19-add-membership-collection/proposal.md b/openspec/changes/archive/2026-06-19-add-membership-collection/proposal.md new file mode 100644 index 0000000..587f4fd --- /dev/null +++ b/openspec/changes/archive/2026-06-19-add-membership-collection/proposal.md @@ -0,0 +1,35 @@ +## Why + +第一弾スコープ①「入部費 / 部費の集金(請求書ベース)」の土台を作る。認証・本人識別([[add-auth-foundation]] でアーカイブ済み)が整ったので、ログイン済み利用者が自分の請求書を発行し、Stripe の支払いページで支払えるようにする。原則「サークルが Stripe に依存せず切り替えられる」を守るため、ドメイン(誰がいくら払う)は Stripe 非依存に保ち、Stripe 呼び出しは薄いアダプタに閉じ込める。 + +本 change は **バックエンド中心**。`/membership` の新規/再入部/現役 UI 振り分けは後続 change とする。 + +## What Changes + +- **Stripe アダプタ層の導入**: Stripe SDK 呼び出しを `packages/api/src/stripe/` のアダプタに閉じ込め、ドメイン層は Price/金額/期の判定のみを担う(Stripe オフ→口座振込 や将来の v2 移行をアダプタ差し替えで吸収)。 +- **Customer の get-or-create**: `mail_hash` → DB の `stripe_customer_id` → 無ければ Stripe をメールで検索 → それでも無ければ作成し `customer_id` を DB に保存。 + - **BREAKING(identity 拡張)**: `users` に `stripe_customer_id`(nullable)を追加。identity の「メール平文を永続化しない」要件は維持しつつ、非 PII の Stripe 参照は保持してよいと明確化する。 +- **請求書の発行(finalize/send)**: ドメインで費目・期・標準/特別から `price_id` を決定し、Customer に対し Invoice + InvoiceItem(Price) を作成 → finalize → send。支払いページへ誘導。 + - 価格マトリクス: 新規入部費=前期(4–9月)¥4,000 / 後期(10–3月)¥2,000(活動年度 4/1–3/31 から日付で自動決定)。継続(部費)=標準¥4,000 / 特別¥2,000。 + - 認可: 標準(新規の前期/後期・継続標準)は**利用者が自分の分のみ**発行。特別¥2,000 は**管理者(会計)のみ**が対象を指定して発行。利用者発行時は提出メールの `mail_hash` がセッションと一致することを検証。 +- **入金検知 Webhook(`invoice.paid`)**: Stripe 署名検証 + event id による冪等処理 + 会計への通知(`Notifier` 抽象、当面ログ実装)。一覧表示自体は後続の「入出金一覧」change(Stripe から直接取得)に委ねる。 + +明確に**スコープ外**(後続 change): `/membership` UI 振り分け・フォーム、入出金一覧(`/list/*`)、払い戻し・Jomon 連携、resend/催促、任意額請求書。 + +## Capabilities + +### New Capabilities +- `stripe-customer`: `mail_hash` と Stripe Customer の対応付け。get-or-create(DB→メール検索→作成)、`stripe_customer_id` の永続化、Stripe アダプタ境界。 +- `membership-billing`: 集金ドメイン。費目(新規/継続)・期(前期/後期)・標準/特別から `price_id` を決定する規則、活動年度に基づく期判定、発行の認可(利用者は自分の標準、管理者は特別)、Invoice 作成→finalize→send。 +- `payment-webhook`: `invoice.paid` Webhook の受信。Stripe 署名検証、event id による冪等処理、会計への通知(`Notifier` 抽象)。 + +### Modified Capabilities +- `identity`: `users` に `stripe_customer_id`(nullable)を追加。「メール平文を永続化しない」要件を、非 PII の Stripe 参照(customer id)は保持してよい形に明確化(メール平文は引き続き保存しない)。 + +## Impact + +- **DB(`packages/db`)**: `users` に `stripe_customer_id` 追加、Webhook 冪等用 `stripe_events`(`event_id` unique)追加。マイグレーション生成・コミット。 +- **API(`packages/api`)**: Stripe アダプタ(`stripe/`)、集金ドメイン(費目・期・価格・認可)、oRPC `membership.issueInvoice`(利用者)/`membership.issueSpecialInvoice`(管理者)、`Notifier` 抽象。 +- **Web/Nitro(`apps/web`)**: `POST /webhook/invoice-paid`(raw body + Stripe 署名検証の Nitro ルート)。 +- **依存・設定**: `stripe` SDK を追加。env に `STRIPE_SECRET_KEY`(test)/`STRIPE_WEBHOOK_SECRET`/`PRICE_SHINKI_ZENKI`/`PRICE_SHINKI_KOUKI`/`PRICE_KEIZOKU_STANDARD`/`PRICE_KEIZOKU_SPECIAL`/`INVOICE_DAYS_UNTIL_DUE` を追加(`.env.example` 更新)。 +- **テスト/検証**: 期判定・価格選択・認可はユニットテスト。Stripe 連携は test mode キーが必要なため、E2E はキー投入後に実施(adapter はモックでユニット可能な範囲で検証)。 diff --git a/openspec/changes/archive/2026-06-19-add-membership-collection/specs/identity/spec.md b/openspec/changes/archive/2026-06-19-add-membership-collection/specs/identity/spec.md new file mode 100644 index 0000000..4ca88a4 --- /dev/null +++ b/openspec/changes/archive/2026-06-19-add-membership-collection/specs/identity/spec.md @@ -0,0 +1,15 @@ +## MODIFIED Requirements + +### Requirement: メール平文を永続化しない + +システムは、isct メールの平文を DB に保存しては SHALL NOT。本人識別に必要なのは `mail_hash` のみであり、メール平文は確認メールの送信や Stripe Customer 作成など処理中の一時的な利用に限る。メール平文の保持が必要な場面(請求先など)は Stripe 側に委ねる。一方で、本人行(`users`)が **非 PII の Stripe 参照**(例: `stripe_customer_id`)を保持することは許容される MUST(これらはメール平文ではない)。 + +#### Scenario: 確認後に平文が残らない + +- **WHEN** isct メール確認が完了し本人行が作成される +- **THEN** `users` 行にはメール平文を保持する列が存在せず、識別は `mail_hash` で行われる + +#### Scenario: Stripe 参照は保持してよい + +- **WHEN** 集金フローで Customer が作成され `stripe_customer_id` が保存される +- **THEN** `users` 行は `stripe_customer_id`(非 PII の参照)を保持できるが、メール平文の列は持たない diff --git a/openspec/changes/archive/2026-06-19-add-membership-collection/specs/membership-billing/spec.md b/openspec/changes/archive/2026-06-19-add-membership-collection/specs/membership-billing/spec.md new file mode 100644 index 0000000..0c50047 --- /dev/null +++ b/openspec/changes/archive/2026-06-19-add-membership-collection/specs/membership-billing/spec.md @@ -0,0 +1,74 @@ +## ADDED Requirements + +### Requirement: 活動年度に基づく期判定 + +システムは、活動年度を 4月1日〜翌年3月31日とし、4月1日〜9月30日を**前期**、10月1日〜翌年3月31日を**後期**として、与えられた日付から半期を決定 SHALL。判定は決定的で、月によって一意に定まる MUST。 + +#### Scenario: 前期の日付 + +- **WHEN** 判定対象の日付が 4〜9 月のいずれか +- **THEN** 期は「前期」と判定される + +#### Scenario: 後期の日付 + +- **WHEN** 判定対象の日付が 10〜12 月または 1〜3 月 +- **THEN** 期は「後期」と判定される + +### Requirement: 費目と期・区分から price を決定 + +システムは、費目と区分から請求に用いる `price_id` を決定 SHALL: +- **新規入部費**: 現在日付の期で自動決定(前期=¥4,000 の price / 後期=¥2,000 の price)。 +- **継続(部費)標準**: ¥4,000 の price。 +- **継続(部費)特別**: ¥2,000 の price。 + +各 `price_id` は環境変数で与えられる MUST。ドメインは金額の数値ではなく `price_id` を選ぶ(Product 単体では金額が一意に決まらないため、design.md の決定に従う)。 + +#### Scenario: 新規・前期は前期 price + +- **WHEN** 費目が新規入部費で、現在が前期 +- **THEN** 前期の `price_id`(¥4,000)が選択される + +#### Scenario: 新規・後期は後期 price + +- **WHEN** 費目が新規入部費で、現在が後期 +- **THEN** 後期の `price_id`(¥2,000)が選択される + +#### Scenario: 継続標準は標準 price + +- **WHEN** 費目が継続(部費)で標準 +- **THEN** 継続標準の `price_id`(¥4,000)が選択される + +### Requirement: 発行の認可(標準は本人、特別は管理者) + +システムは、請求書発行を次のように認可 SHALL: +- 標準系(新規入部費の前期/後期、継続標準 ¥4,000)は**ログイン利用者が自分の分のみ**発行できる。利用者は提出メールから導出した `mail_hash` がセッションの `mail_hash` と一致する場合に限り発行可能 MUST。 +- 特別 ¥2,000(継続特別)は**会計(管理者)のみ**が対象者を指定して発行できる SHALL。利用者は特別請求書を発行しては SHALL NOT。 + +#### Scenario: 利用者は自分の標準請求書を発行できる + +- **WHEN** ログイン利用者が、自分のメール(`mail_hash` がセッションと一致)で標準請求書を要求する +- **THEN** 発行が許可される + +#### Scenario: 利用者は他人の分を発行できない + +- **WHEN** ログイン利用者が、セッションの `mail_hash` と一致しないメールで請求書を要求する +- **THEN** 発行は拒否される + +#### Scenario: 利用者は特別請求書を発行できない + +- **WHEN** ログイン利用者(非管理者)が特別 ¥2,000 の請求書を要求する +- **THEN** 発行は拒否される + +#### Scenario: 管理者は特別請求書を発行できる + +- **WHEN** 会計(管理者)が対象者を指定して特別 ¥2,000 の請求書を要求する +- **THEN** 対象者の Customer に対して発行が許可される + +### Requirement: 請求書の作成・確定・送付 + +システムは、対象 Customer に対し Invoice + InvoiceItem(選択した Price)を作成し、finalize して send まで行う SHALL。送付後、利用者は Stripe の支払いページで支払える MUST。請求書はドメイン非依存のアダプタ越しに作成する。 + +#### Scenario: 発行で支払いページに到達できる + +- **WHEN** 認可された請求書発行が要求される +- **THEN** Customer に Invoice(Price 指定)が作成・finalize・send され、支払いページの案内(請求書メール/URL)が利用可能になる diff --git a/openspec/changes/archive/2026-06-19-add-membership-collection/specs/payment-webhook/spec.md b/openspec/changes/archive/2026-06-19-add-membership-collection/specs/payment-webhook/spec.md new file mode 100644 index 0000000..738e6d3 --- /dev/null +++ b/openspec/changes/archive/2026-06-19-add-membership-collection/specs/payment-webhook/spec.md @@ -0,0 +1,33 @@ +## ADDED Requirements + +### Requirement: Stripe 署名の検証 + +システムは、`invoice.paid` Webhook を受信する際、`Stripe-Signature` ヘッダと環境変数の Webhook 署名シークレットで署名を検証 SHALL。検証は raw(未加工)リクエストボディに対して行う MUST。署名が無効・欠落の場合は処理せず拒否(4xx)する SHALL。 + +#### Scenario: 有効な署名は受理 + +- **WHEN** 正しい署名付きの `invoice.paid` イベントが届く +- **THEN** 署名検証を通過し、イベント処理に進む + +#### Scenario: 無効な署名は拒否 + +- **WHEN** 署名が欠落または不正なリクエストが届く +- **THEN** イベントは処理されず、拒否(4xx)される + +### Requirement: event id による冪等処理 + +システムは、処理済みの Stripe event id を記録し、同一 event id の再送を二重処理しては SHALL NOT。Stripe は同一イベントを複数回送ることがあるため、event id 単位で一度だけ副作用(通知等)を発火 MUST。 + +#### Scenario: 同一イベントの再送は一度だけ処理 + +- **WHEN** 同じ event id の `invoice.paid` が 2 回届く +- **THEN** 副作用(会計への通知)は 1 回だけ発火し、2 回目はスキップされる + +### Requirement: 入金時に会計へ通知 + +システムは、`invoice.paid` を正常受信・検証したとき、会計へ通知 SHALL。通知は `Notifier` 抽象を介して行い、実装は差し替え可能とする(当面はログ実装でよい)。一覧表示自体は本 capability の責務ではなく、後続の入出金一覧で Stripe から取得する。 + +#### Scenario: 入金で通知が発火 + +- **WHEN** 検証済みの `invoice.paid`(未処理の event id)を受信する +- **THEN** `Notifier` を通じて会計向けの通知が 1 回発火する diff --git a/openspec/changes/archive/2026-06-19-add-membership-collection/specs/stripe-customer/spec.md b/openspec/changes/archive/2026-06-19-add-membership-collection/specs/stripe-customer/spec.md new file mode 100644 index 0000000..6424237 --- /dev/null +++ b/openspec/changes/archive/2026-06-19-add-membership-collection/specs/stripe-customer/spec.md @@ -0,0 +1,38 @@ +## ADDED Requirements + +### Requirement: Customer の get-or-create + +システムは、`mail_hash` に対応する Stripe Customer を次の順で解決 SHALL:(1) DB の `users.stripe_customer_id` があればそれを使う、(2) 無ければ提出されたメールで Stripe を検索して一致があれば採用、(3) それでも無ければ作成する。新規作成・検索採用のいずれでも、得た `customer_id` を `users` に保存 MUST。Customer 作成にはメール平文が必要であり、利用者発行フローでは提出メールから導出した `mail_hash` がセッションの `mail_hash` と一致することを前提とする。 + +#### Scenario: DB に customer_id があれば再利用 + +- **WHEN** `users.stripe_customer_id` が設定済みの利用者が請求書発行を要求する +- **THEN** Stripe 検索・作成を行わず、保存済みの Customer を使う + +#### Scenario: DB に無く Stripe に既存があれば採用して保存 + +- **WHEN** DB に `stripe_customer_id` が無く、提出メールで Stripe を検索して既存 Customer が見つかる +- **THEN** その Customer を採用し、`users.stripe_customer_id` に保存する + +#### Scenario: どちらにも無ければ作成して保存 + +- **WHEN** DB にも Stripe にも該当 Customer が無い +- **THEN** メール(+名前)で Customer を作成し、`users.stripe_customer_id` に保存する + +### Requirement: 参照キーは customer_id(traQ ID を参照に使わない) + +システムは、本人と Customer の対応付けの参照キーとして `stripe_customer_id`(DB 保存)を用いる SHALL。再入部時などに入力された traQ ID は信頼できないため、Customer の保存・参照キーには使っては SHALL NOT。ログ可読性のため Customer の `name` / `metadata` に `mail_hash` や traQ ID を入れるのは可とするが、参照には使わない。 + +#### Scenario: traQ ID は参照に使われない + +- **WHEN** 利用者が請求書発行時に traQ ID を入力する +- **THEN** その traQ ID は Customer の参照・保存キーには使われず、対応付けは `mail_hash`/`stripe_customer_id` で行われる + +### Requirement: Stripe 呼び出しはアダプタ境界に閉じ込める + +システムは、Stripe SDK の呼び出しをアダプタ層に隔離 SHALL。集金ドメイン(費目・期・価格・認可の判定)は Stripe 型に直接依存しては SHALL NOT。これにより「Stripe オフ→口座振込」や将来の API 移行をアダプタ差し替えで吸収できる。 + +#### Scenario: ドメインは Stripe 非依存 + +- **WHEN** 価格や期の判定ロジックを参照する +- **THEN** それらは Stripe SDK の型・呼び出しに依存せず、アダプタのインターフェース越しにのみ Stripe を使う diff --git a/openspec/changes/archive/2026-06-19-add-membership-collection/tasks.md b/openspec/changes/archive/2026-06-19-add-membership-collection/tasks.md new file mode 100644 index 0000000..7d39585 --- /dev/null +++ b/openspec/changes/archive/2026-06-19-add-membership-collection/tasks.md @@ -0,0 +1,41 @@ +## 1. 依存とデータモデル + +- [x] 1.1 `packages/api` に `stripe` SDK を追加(`pnpm install`) +- [x] 1.2 `packages/db` schema: `users` に `stripe_customer_id varchar(255)` nullable を追加 +- [x] 1.3 `packages/db` schema: `stripe_events`(`id` PK, `event_id` unique, `type`, `received_at`)を追加 +- [x] 1.4 `pnpm db:generate` でマイグレーション生成・コミット(列追加が rename 誤検出されないよう注意。生成 SQL は手編集しない) + +## 2. Stripe アダプタ層(packages/api/src/stripe) + +- [x] 2.1 `client.ts`: `STRIPE_SECRET_KEY` で Stripe クライアント初期化 +- [x] 2.2 `customers.ts`: get-or-create(DB→メール検索→作成)、`metadata` に `mail_hash`/`traq_id`、`customer_id` 返却 +- [x] 2.3 `invoices.ts`: Customer+`price_id` から Invoice+InvoiceItem 作成→finalize→send(`hosted_invoice_url` 返却) +- [x] 2.4 `webhook.ts`: raw body+署名で `constructEvent`(`invoice.paid` 判定) + +## 3. 集金ドメイン(Stripe 非依存, packages/api/src/billing) + +- [x] 3.1 `term.ts`: `computeTerm(date)`(4–9=前期 / 10–3=後期、活動年度 4/1–3/31) +- [x] 3.2 `pricing.ts`: `selectPriceId({feeType, term, variant})` を env の 4 つの price から決定 +- [x] 3.3 `authorize.ts`: 標準=本人(`mail_hash` 一致)のみ/特別=管理者のみ の判定 +- [x] 3.4 `BillingConfig` 型+解決(price_id 群・`STRIPE_*`・`INVOICE_DAYS_UNTIL_DUE`)。Context へ載せる経路を用意 + +## 4. 発行 API(oRPC) + +- [x] 4.1 `membership.issueInvoice`(`requireUser`+`assertCsrf`): メール一致検証→費目/期で price→Customer get-or-create→Invoice 発行 +- [x] 4.2 `membership.issueSpecialInvoice`(`requireAdmin`+`assertCsrf`): 対象 user→特別 price→Customer 解決→Invoice 発行 +- [x] 4.3 返り値に `invoiceId`/`hostedInvoiceUrl`。Context(`packages/api/src/orpc.ts`)に Stripe アダプタ/`BillingConfig`/`Notifier` を追加 + +## 5. 入金 Webhook(Nitro) + +- [x] 5.1 `Notifier` 抽象+`LogNotifier`(会計向け通知、当面ログ) +- [x] 5.2 `apps/web/server/routes/webhook/invoice-paid.post.ts`: raw body 署名検証→`stripe_events` で冪等→`Notifier.notify` +- [x] 5.3 不正署名は 4xx、`invoice.paid` 以外は無視、同一 event id は二重処理しない + +## 6. 設定・配線・検証 + +- [x] 6.1 `.env.example` に `STRIPE_SECRET_KEY`/`STRIPE_WEBHOOK_SECRET`/`PRICE_*`(4種)/`INVOICE_DAYS_UNTIL_DUE` を追記、`nuxt.config` runtimeConfig に対応キー追加 +- [x] 6.2 ユニットテスト: `computeTerm`(前期/後期境界)、`selectPriceId`(4 パターン)、`authorize`(本人一致/不一致/利用者の特別不可/管理者特別可) +- [x] 6.3 `pnpm lint` / `pnpm typecheck` / `pnpm build` / `pnpm test` をグリーンにする +- [x] 6.5 Codex レビュー指摘の修正(特別発行のメール一致検証 / Webhook を check-notify-record 順に / 発行冪等キー / draft クリーンアップ / 空 price_id ガード / Customer 検索前のメール正規化) +- [ ] 6.4 (test mode キー投入後)Stripe sandbox で E2E: 標準発行→支払い→`invoice.paid` 通知、管理者の特別発行、他人発行の拒否、CSRF 欠落の拒否 + (BLOCKED: この環境に Stripe test キーが無いためライブ E2E は未実施。キー投入後に実施する。) diff --git a/openspec/specs/admin-authorization/spec.md b/openspec/specs/admin-authorization/spec.md new file mode 100644 index 0000000..273aa7d --- /dev/null +++ b/openspec/specs/admin-authorization/spec.md @@ -0,0 +1,49 @@ +# admin-authorization + +## Purpose + +会計(管理者)を traQ OAuth で認証し、環境変数の traQ ID 許可リストで認可して管理者セッションを確立する。会計管理は DB の管理者テーブルではなく許可リスト(将来 traQ グループ連携へ拡張可能)で行う。 + +## Requirements + +### Requirement: traQ OAuth による会計ログイン + +会計(管理者)は traQ OAuth の authorization code フロー(PKCE 併用)でログイン SHALL。`GET /login` は state と PKCE code_verifier を生成・保存して traQ の認可エンドポイントへリダイレクトし、コールバックで `state` を検証し、code を access token に交換して traQ のユーザー情報(traq_id)を取得 MUST。`state` 不一致や code 交換失敗時はセッションを確立せずエラーとする SHALL。 + +#### Scenario: 正常な OAuth ログイン + +- **WHEN** 会計が `GET /login` を開始し、traQ で認可してコールバックに戻る +- **THEN** `state` が検証され、code が token に交換され、traq_id が取得される + +#### Scenario: state 不一致は拒否 + +- **WHEN** コールバックの `state` が保存値と一致しない +- **THEN** セッションは確立されず、エラーとして扱われる + +### Requirement: env 許可リストによる会計判定 + +システムは、OAuth で得た traq_id が環境変数の会計 traQ ID 許可リストに含まれる場合のみ、管理者(会計)セッションを確立 SHALL。許可リスト外の traq_id には管理者権限を付与しては SHALL NOT。会計の管理は DB の管理者テーブルではなく、この許可リスト(将来 traQ グループ連携へ拡張可能)で行う MUST。 + +#### Scenario: 許可リスト内は会計になる + +- **WHEN** 許可リストに含まれる traq_id でログインが完了する +- **THEN** 管理者(会計)セッションが確立される + +#### Scenario: 許可リスト外は会計にならない + +- **WHEN** 許可リストに含まれない traq_id でログインが完了する +- **THEN** 管理者セッションは確立されず、会計権限は付与されない + +### Requirement: ログインの redirect 保持 + +`GET /login` は `redirect` クエリを受け取り、OAuth フローを通じて保持 SHALL。ログイン成立後、指定された **同一サイト内** の `redirect` 先へ遷移する MUST。オープンリダイレクトを防ぐため、外部 URL への `redirect` は拒否または無視する SHALL。 + +#### Scenario: 同一サイトの redirect は保持される + +- **WHEN** `GET /login?redirect=/payments` からログインが完了する +- **THEN** ログイン後に `/payments` へ遷移する + +#### Scenario: 外部 redirect は拒否される + +- **WHEN** `redirect` に外部 URL(別オリジン)が指定される +- **THEN** その遷移先は使われず、安全な既定先へ遷移する diff --git a/openspec/specs/email-verification/spec.md b/openspec/specs/email-verification/spec.md new file mode 100644 index 0000000..400cf8f --- /dev/null +++ b/openspec/specs/email-verification/spec.md @@ -0,0 +1,72 @@ +# email-verification + +## Purpose + +isct メールのマジックリンクで本人(利用者)を確認し、利用者セッションを確立する。`@m.isct.ac.jp` のみを許可し、単回・期限付きトークンとメール送信アダプタを用いる。 + +## Requirements + +### Requirement: isct ドメインのホワイトリスト検証 + +システムは、確認メールの送信を要求された際、入力アドレスが許可ドメイン(既定 `@m.isct.ac.jp`)に属する場合のみ受理 SHALL。許可ドメインは環境変数で設定可能とする。ドメイン外のアドレスに対しては確認メールを送信せず、エラーを返す MUST。 + +#### Scenario: 許可ドメインは受理される + +- **WHEN** 利用者が `someone@m.isct.ac.jp` で確認を要求する +- **THEN** システムは確認メールの送信処理に進む + +#### Scenario: ドメイン外は拒否される + +- **WHEN** 利用者が `someone@gmail.com` で確認を要求する +- **THEN** システムは確認メールを送信せず、ドメイン不許可のエラーを返す + +### Requirement: 単回・期限付きマジックリンクの発行 + +確認要求に対し、システムは単回利用(single-use)かつ有効期限付きのトークンを発行し、そのトークンを含むリンクを当該アドレスへメール送信 SHALL。DB にはトークンの平文ではなくハッシュと、対象の `mail_hash`、有効期限、消費状態を保存 MUST。トークンの推測が困難な十分なエントロピーを持つ MUST。 + +#### Scenario: 確認メールが送られる + +- **WHEN** 許可ドメインのアドレスで確認が要求される +- **THEN** 単回・期限付きトークンが発行され、確認リンクを含むメールが送信され、トークンのハッシュと `mail_hash`・有効期限が保存される + +### Requirement: 有効なトークンの消費でセッション確立 + +利用者がメール内リンク経由で有効・未消費・未期限切れのトークンを提示した場合、システムはトークンを消費(以後使用不可に)し、対応する `mail_hash` の本人行を get-or-create し、利用者セッションを確立 SHALL。 + +#### Scenario: 正常な確認 + +- **WHEN** 利用者が有効・未消費・期限内のトークンを提示する +- **THEN** トークンは消費され、`mail_hash` の本人行が存在することが保証され、利用者セッションが確立される + +#### Scenario: 確認後に redirect を保持して遷移する + +- **WHEN** 確認要求時に `redirect` が指定されており、トークンの消費が成功する +- **THEN** セッション確立後、指定された安全な(同一サイトの)`redirect` 先へ遷移する + +### Requirement: 無効・期限切れ・再利用トークンの拒否 + +システムは、存在しない/期限切れ/既に消費済みのトークンに対し、セッションを確立せずエラー(または無効表示)を返す SHALL。消費済みトークンの再提示でセッションが確立されては SHALL NOT。 + +#### Scenario: 期限切れトークン + +- **WHEN** 有効期限を過ぎたトークンが提示される +- **THEN** セッションは確立されず、無効として扱われる + +#### Scenario: 再利用の防止 + +- **WHEN** 一度消費されたトークンが再度提示される +- **THEN** セッションは確立されず、無効として扱われる + +### Requirement: メール送信は Mailer アダプタ越し + +確認メールの送信は `Mailer` アダプタ抽象を介して行う SHALL。実装は環境設定で切替可能とし、少なくとも開発用(ログ出力)実装と本番用(SendGrid 等の外部サービス)実装を差し替えられる MUST。アプリケーション側のドメインロジックは具体的な送信サービスに依存しては SHALL NOT。 + +#### Scenario: 開発時はログ実装で送信される + +- **WHEN** 開発環境で確認メール送信が要求される +- **THEN** 外部サービスを呼ばずに送信内容がログ実装に渡される + +#### Scenario: 本番は外部サービス実装に切り替わる + +- **WHEN** 本番設定で確認メール送信が要求される +- **THEN** 設定された外部メールサービス実装を通じて送信される diff --git a/openspec/specs/identity/spec.md b/openspec/specs/identity/spec.md new file mode 100644 index 0000000..c952f4b --- /dev/null +++ b/openspec/specs/identity/spec.md @@ -0,0 +1,63 @@ +# identity + +## Purpose + +サービス内の本人を、メール平文に依存しない不透明キー `mail_hash` で識別する。集金側(Stripe Customer)と払い戻し側(connected account)を 1 つの本人行に束ねる「対応表」の基盤であり、対応表は自前 DB を source of truth とする。 + +## Requirements + +### Requirement: 本人キーは mail_hash + +システムは、サービス内の本人を `mail_hash` で識別 SHALL。`mail_hash` は isct メールアドレスから導出される不透明な識別子であり、集金側(Customer)と払い戻し側(connected account)を 1 つの本人行に束ねる対応表の主キーとして用いる。`mail_hash` は `users` テーブルで unique であり、同一人物(同一 isct メール)は常に同一の `mail_hash` を持つ MUST。 + +#### Scenario: 同一メールは同一の本人行に解決される + +- **WHEN** 同一の isct メールから 2 回 `mail_hash` を導出する +- **THEN** 同一の `mail_hash` が得られ、同一の `users` 行に解決される + +#### Scenario: 異なるメールは異なる本人行になる + +- **WHEN** 異なる 2 つの isct メールから `mail_hash` を導出する +- **THEN** 異なる `mail_hash` が得られ、別々の `users` 行になる + +### Requirement: mail_hash の正規化と導出 + +`mail_hash` は、メールアドレスを正規化(前後空白の除去、ローカル部とドメインの小文字化)したうえで、環境変数で与えられる秘密鍵を用いた **HMAC-SHA256** によって導出 SHALL。導出は決定的であり、同じ入力からは常に同じ出力を返す MUST。秘密鍵が未設定の場合、システムは起動・処理を継続せずエラーとする SHALL(弱い既定値へフォールバックしてはならない)。 + +#### Scenario: 大文字小文字・空白が異なっても同一になる + +- **WHEN** `" Foo.Bar@m.isct.ac.jp "` と `"foo.bar@m.isct.ac.jp"` をそれぞれ正規化して導出する +- **THEN** 両者の `mail_hash` は一致する + +#### Scenario: 秘密鍵が未設定なら処理を拒否する + +- **WHEN** `mail_hash` 用の秘密鍵が環境変数に設定されていない状態で導出が要求される +- **THEN** システムはエラーを返し、弱い既定値での導出は行わない + +### Requirement: メール平文を永続化しない + +システムは、isct メールの平文を DB に保存しては SHALL NOT。本人識別に必要なのは `mail_hash` のみであり、メール平文は確認メールの送信や Stripe Customer 作成など処理中の一時的な利用に限る。メール平文の保持が必要な場面(請求先など)は Stripe 側に委ねる。一方で、本人行(`users`)が **非 PII の Stripe 参照**(例: `stripe_customer_id`)を保持することは許容される MUST(これらはメール平文ではない)。 + +#### Scenario: 確認後に平文が残らない + +- **WHEN** isct メール確認が完了し本人行が作成される +- **THEN** `users` 行にはメール平文を保持する列が存在せず、識別は `mail_hash` で行われる + +#### Scenario: Stripe 参照は保持してよい + +- **WHEN** 集金フローで Customer が作成され `stripe_customer_id` が保存される +- **THEN** `users` 行は `stripe_customer_id`(非 PII の参照)を保持できるが、メール平文の列は持たない + +### Requirement: 本人行の get-or-create + +システムは、確認済みの `mail_hash` に対して本人行を取得し、無ければ作成する操作を提供 SHALL。作成は冪等であり、競合時にも `mail_hash` の unique 制約により重複行を作らない MUST。 + +#### Scenario: 初回は作成される + +- **WHEN** 既存行の無い `mail_hash` で get-or-create を呼ぶ +- **THEN** 新しい `users` 行が作成され、その行が返る + +#### Scenario: 2 回目は既存行が返る + +- **WHEN** 既に行のある `mail_hash` で get-or-create を呼ぶ +- **THEN** 新しい行は作られず、既存の行が返る diff --git a/openspec/specs/membership-billing/spec.md b/openspec/specs/membership-billing/spec.md new file mode 100644 index 0000000..eb9fd04 --- /dev/null +++ b/openspec/specs/membership-billing/spec.md @@ -0,0 +1,95 @@ +# membership-billing + +## Purpose + +集金(入部費 / 部費)のドメイン。活動年度に基づく期判定、費目・期・標準/特別から `price_id` を決定する規則、発行の認可(標準は本人・特別は管理者)、Invoice の作成→finalize→send を Stripe 非依存に定義する。 + +## Requirements + +### Requirement: 活動年度に基づく期判定 + +システムは、活動年度を 4月1日〜翌年3月31日とし、4月1日〜9月30日を**前期**、10月1日〜翌年3月31日を**後期**として、与えられた日付から半期を決定 SHALL。判定は決定的で、月によって一意に定まる MUST。 + +#### Scenario: 前期の日付 + +- **WHEN** 判定対象の日付が 4〜9 月のいずれか +- **THEN** 期は「前期」と判定される + +#### Scenario: 後期の日付 + +- **WHEN** 判定対象の日付が 10〜12 月または 1〜3 月 +- **THEN** 期は「後期」と判定される + +### Requirement: 費目と期・区分から price を決定 + +システムは、費目と区分から請求に用いる `price_id` を決定 SHALL: +- **新規入部費**: 現在日付の期で自動決定(前期=¥4,000 の price / 後期=¥2,000 の price)。 +- **継続(部費)標準**: ¥4,000 の price。 +- **継続(部費)特別**: ¥2,000 の price。 + +各 `price_id` は環境変数で与えられる MUST。ドメインは金額の数値ではなく `price_id` を選ぶ(Product 単体では金額が一意に決まらないため、design.md の決定に従う)。選択した `price_id` が未設定(空)の場合は、Stripe 呼び出しに進まず設定エラーとする SHALL。 + +#### Scenario: 新規・前期は前期 price + +- **WHEN** 費目が新規入部費で、現在が前期 +- **THEN** 前期の `price_id`(¥4,000)が選択される + +#### Scenario: 新規・後期は後期 price + +- **WHEN** 費目が新規入部費で、現在が後期 +- **THEN** 後期の `price_id`(¥2,000)が選択される + +#### Scenario: 継続標準は標準 price + +- **WHEN** 費目が継続(部費)で標準 +- **THEN** 継続標準の `price_id`(¥4,000)が選択される + +#### Scenario: price_id 未設定は設定エラー + +- **WHEN** 選択対象の `price_id` が環境変数で未設定(空) +- **THEN** Stripe 呼び出しに進まず、設定エラーを返す + +### Requirement: 発行の認可(標準は本人、特別は管理者) + +システムは、請求書発行を次のように認可 SHALL: +- 標準系(新規入部費の前期/後期、継続標準 ¥4,000)は**ログイン利用者が自分の分のみ**発行できる。利用者は提出メールから導出した `mail_hash` がセッションの `mail_hash` と一致する場合に限り発行可能 MUST。 +- 特別 ¥2,000(継続特別)は**会計(管理者)のみ**が対象者を指定して発行できる SHALL。利用者は特別請求書を発行しては SHALL NOT。管理者が未連携の対象者にメールを指定して Customer を作成する場合、そのメールから導出した `mail_hash` が対象者の `mail_hash` と一致することを検証 MUST。 + +#### Scenario: 利用者は自分の標準請求書を発行できる + +- **WHEN** ログイン利用者が、自分のメール(`mail_hash` がセッションと一致)で標準請求書を要求する +- **THEN** 発行が許可される + +#### Scenario: 利用者は他人の分を発行できない + +- **WHEN** ログイン利用者が、セッションの `mail_hash` と一致しないメールで請求書を要求する +- **THEN** 発行は拒否される + +#### Scenario: 利用者は特別請求書を発行できない + +- **WHEN** ログイン利用者(非管理者)が特別 ¥2,000 の請求書を要求する +- **THEN** 発行は拒否される + +#### Scenario: 管理者は特別請求書を発行できる + +- **WHEN** 会計(管理者)が対象者を指定して特別 ¥2,000 の請求書を要求する +- **THEN** 対象者の Customer に対して発行が許可される + +#### Scenario: 管理者が指定したメールが対象者と不一致なら拒否 + +- **WHEN** 会計が、Customer 未連携の対象者に対し、対象者の `mail_hash` と一致しないメールを指定して発行を要求する +- **THEN** 発行は拒否される + +### Requirement: 請求書の作成・確定・送付 + +システムは、対象 Customer に対し Invoice + InvoiceItem(選択した Price)を作成し、finalize して send まで行う SHALL。送付後、利用者は Stripe の支払いページで支払える MUST。請求書はドメイン非依存のアダプタ越しに作成する。短時間の二重 submit を防ぐため、決定的な冪等キーを Invoice 作成に付与 SHALL。作成途中で失敗した場合は、できる範囲で未確定の Invoice を後始末(void/削除)してエラーを返す MUST。 + +#### Scenario: 発行で支払いページに到達できる + +- **WHEN** 認可された請求書発行が要求される +- **THEN** Customer に Invoice(Price 指定)が作成・finalize・send され、支払いページの案内(請求書メール/URL)が利用可能になる + +#### Scenario: 連打しても重複請求書を作らない(冪等ウィンドウ内) + +- **WHEN** 同一条件の発行要求が短時間に複数回届く +- **THEN** 冪等キーにより Stripe 側で重複が抑止され、同一の Invoice が返る diff --git a/openspec/specs/payment-webhook/spec.md b/openspec/specs/payment-webhook/spec.md new file mode 100644 index 0000000..3d86745 --- /dev/null +++ b/openspec/specs/payment-webhook/spec.md @@ -0,0 +1,44 @@ +# payment-webhook + +## Purpose + +Stripe の `invoice.paid` Webhook を受信し、署名検証・event id による冪等処理を行ったうえで会計へ通知する。入金検知の入口であり、一覧表示自体は後続の入出金一覧(Stripe から取得)に委ねる。 + +## Requirements + +### Requirement: Stripe 署名の検証 + +システムは、`invoice.paid` Webhook を受信する際、`Stripe-Signature` ヘッダと環境変数の Webhook 署名シークレットで署名を検証 SHALL。検証は raw(未加工)リクエストボディに対して行う MUST。署名が無効・欠落の場合は処理せず拒否(4xx)する SHALL。 + +#### Scenario: 有効な署名は受理 + +- **WHEN** 正しい署名付きの `invoice.paid` イベントが届く +- **THEN** 署名検証を通過し、イベント処理に進む + +#### Scenario: 無効な署名は拒否 + +- **WHEN** 署名が欠落または不正なリクエストが届く +- **THEN** イベントは処理されず、拒否(4xx)される + +### Requirement: event id による冪等処理 + +システムは、処理済みの Stripe event id を記録し、同一 event id の再送を二重処理しては SHALL NOT。Stripe は同一イベントを複数回送ることがあるため、event id 単位で副作用(通知)を抑止 MUST。通知が成功した後に event id を記録することで、通知失敗時は記録されず Stripe の再送で再試行される(at-least-once)。 + +#### Scenario: 同一イベントの再送は二重処理しない + +- **WHEN** 通知済みの event id を持つ `invoice.paid` が再送される +- **THEN** 既処理として扱われ、通知は再発火しない + +#### Scenario: 通知失敗時は記録されず再試行できる + +- **WHEN** 受信した未処理イベントで通知が失敗する +- **THEN** event id は記録されず、Stripe の再送で通知が再試行される + +### Requirement: 入金時に会計へ通知 + +システムは、`invoice.paid` を正常受信・検証したとき、会計へ通知 SHALL。通知は `Notifier` 抽象を介して行い、実装は差し替え可能とする(当面はログ実装でよい)。一覧表示自体は本 capability の責務ではなく、後続の入出金一覧で Stripe から取得する。 + +#### Scenario: 入金で通知が発火 + +- **WHEN** 検証済みの `invoice.paid`(未処理の event id)を受信する +- **THEN** `Notifier` を通じて会計向けの通知が発火する diff --git a/openspec/specs/session/spec.md b/openspec/specs/session/spec.md new file mode 100644 index 0000000..5055287 --- /dev/null +++ b/openspec/specs/session/spec.md @@ -0,0 +1,86 @@ +# session + +## Purpose + +利用者・会計の双方を表現するサーバ側セッションとアクセス制御の基盤。`__Host-` cookie、double-submit CSRF、`requireUser` / `requireAdmin` 認可ヘルパを提供する。 + +## Requirements + +### Requirement: サーバ側セッションと __Host- cookie + +システムは、サーバ側にセッション実体を保存し、不透明(推測困難)なセッション ID を `__Host-` 接頭辞付きの cookie で受け渡し SHALL。cookie 属性は `HttpOnly`・`Secure`・`SameSite`(既定 `Lax`)・`Path=/` とし、`Domain` 属性は付与しない MUST(`__Host-` の制約に従う)。セッションは有効期限を持ち、期限切れ・破棄後の ID では認証されては SHALL NOT。 + +#### Scenario: セッション cookie の属性 + +- **WHEN** セッションが確立され cookie が発行される +- **THEN** cookie は `__Host-` 接頭辞付きで `HttpOnly`・`Secure`・`SameSite`・`Path=/` を持ち、`Domain` を持たない + +#### Scenario: 期限切れセッションは無効 + +- **WHEN** 有効期限を過ぎたセッション ID でアクセスする +- **THEN** 認証されず、未ログインとして扱われる + +### Requirement: セッションは利用者・会計のいずれかのアクターを表す + +セッション実体は、確立元に応じて「利用者(`mail_hash` で識別)」または「会計(traq_id で識別)」のいずれのアクターであるかを保持 SHALL。両者を取り違えて権限を付与しては SHALL NOT。 + +#### Scenario: 利用者セッション + +- **WHEN** isct メール確認によりセッションが確立される +- **THEN** セッションは利用者アクターとして `mail_hash` に紐づく + +#### Scenario: 会計セッション + +- **WHEN** traQ OAuth + 許可リストによりセッションが確立される +- **THEN** セッションは会計アクターとして traq_id に紐づく + +### Requirement: CSRF cookie の発行(double-submit) + +システムは、`__Host-checkin_csrf` cookie を発行する手段(`GET /csrf`)を提供 SHALL。CSRF cookie は double-submit 方式で用い、JavaScript から読めるよう `HttpOnly` を付けない(ただし `Secure`・`Path=/`・`SameSite`)。値は推測困難であり、クライアントは状態変更要求時にこの値をヘッダ等で送り返す MUST。 + +#### Scenario: CSRF cookie を取得できる + +- **WHEN** クライアントが `GET /csrf` を呼ぶ +- **THEN** `__Host-checkin_csrf` cookie が発行され、対応するトークン値が返る + +### Requirement: 状態変更要求の CSRF 検証 + +システムは、状態を変更する要求(POST/PATCH/DELETE 等)に対し、リクエストヘッダ等で提示された CSRF トークンと `__Host-checkin_csrf` cookie の値が一致することを検証 SHALL。一致しない、または欠落している場合は処理を拒否する MUST。安全(冪等・副作用なし)な GET には適用しない。 + +#### Scenario: 一致すれば許可 + +- **WHEN** ヘッダの CSRF トークンが cookie の値と一致する状態変更要求が来る +- **THEN** 要求は CSRF 検証を通過する + +#### Scenario: 不一致・欠落は拒否 + +- **WHEN** CSRF トークンが欠落、または cookie の値と一致しない状態変更要求が来る +- **THEN** 要求は拒否される + +### Requirement: 認可ヘルパ requireUser / requireAdmin + +システムは、API のリクエスト Context にセッションを復元し、`requireUser` と `requireAdmin` の認可ヘルパを提供 SHALL。`requireUser` は有効な利用者セッションが無ければ拒否し、操作対象を当該利用者「自分のもの」に限定する手段を与える MUST。`requireAdmin` は会計セッションが無ければ拒否する MUST。 + +#### Scenario: 未ログインで requireUser + +- **WHEN** セッションの無いリクエストが `requireUser` を要する操作を呼ぶ +- **THEN** 認証エラーで拒否される + +#### Scenario: 利用者が requireAdmin + +- **WHEN** 利用者セッション(会計ではない)で `requireAdmin` を要する操作を呼ぶ +- **THEN** 認可エラーで拒否される + +#### Scenario: 会計が requireAdmin + +- **WHEN** 会計セッションで `requireAdmin` を要する操作を呼ぶ +- **THEN** 操作が許可される + +### Requirement: ログアウト + +システムは、現在のセッションを破棄する手段を提供 SHALL。破棄後はサーバ側セッション実体が無効化され、同じ cookie 値では認証されては SHALL NOT。 + +#### Scenario: ログアウトでセッション無効化 + +- **WHEN** ログイン中の利用者または会計がログアウトする +- **THEN** サーバ側セッションが無効化され、以後同じ cookie では認証されない diff --git a/openspec/specs/stripe-customer/spec.md b/openspec/specs/stripe-customer/spec.md new file mode 100644 index 0000000..e4c97a2 --- /dev/null +++ b/openspec/specs/stripe-customer/spec.md @@ -0,0 +1,44 @@ +# stripe-customer + +## Purpose + +`mail_hash` と Stripe Customer を対応付ける。get-or-create(DB→メール検索→作成)で `stripe_customer_id` を自前 DB に永続化し、Stripe 呼び出しはアダプタ境界に閉じ込めて集金ドメインを Stripe 非依存に保つ。 + +## Requirements + +### Requirement: Customer の get-or-create + +システムは、`mail_hash` に対応する Stripe Customer を次の順で解決 SHALL:(1) DB の `users.stripe_customer_id` があればそれを使う、(2) 無ければ提出されたメールで Stripe を検索して一致があれば採用、(3) それでも無ければ作成する。新規作成・検索採用のいずれでも、得た `customer_id` を `users` に保存 MUST。Customer 作成にはメール平文が必要であり、利用者発行フローでは提出メールから導出した `mail_hash` がセッションの `mail_hash` と一致することを前提とする。 + +#### Scenario: DB に customer_id があれば再利用 + +- **WHEN** `users.stripe_customer_id` が設定済みの利用者が請求書発行を要求する +- **THEN** Stripe 検索・作成を行わず、保存済みの Customer を使う + +#### Scenario: DB に無く Stripe に既存があれば採用して保存 + +- **WHEN** DB に `stripe_customer_id` が無く、提出メールで Stripe を検索して既存 Customer が見つかる +- **THEN** その Customer を採用し、`users.stripe_customer_id` に保存する + +#### Scenario: どちらにも無ければ作成して保存 + +- **WHEN** DB にも Stripe にも該当 Customer が無い +- **THEN** メール(+名前)で Customer を作成し、`users.stripe_customer_id` に保存する + +### Requirement: 参照キーは customer_id(traQ ID を参照に使わない) + +システムは、本人と Customer の対応付けの参照キーとして `stripe_customer_id`(DB 保存)を用いる SHALL。再入部時などに入力された traQ ID は信頼できないため、Customer の保存・参照キーには使っては SHALL NOT。ログ可読性のため Customer の `name` / `metadata` に `mail_hash` や traQ ID を入れるのは可とするが、参照には使わない。 + +#### Scenario: traQ ID は参照に使われない + +- **WHEN** 利用者が請求書発行時に traQ ID を入力する +- **THEN** その traQ ID は Customer の参照・保存キーには使われず、対応付けは `mail_hash`/`stripe_customer_id` で行われる + +### Requirement: Stripe 呼び出しはアダプタ境界に閉じ込める + +システムは、Stripe SDK の呼び出しをアダプタ層に隔離 SHALL。集金ドメイン(費目・期・価格・認可の判定)は Stripe 型に直接依存しては SHALL NOT。これにより「Stripe オフ→口座振込」や将来の API 移行をアダプタ差し替えで吸収できる。 + +#### Scenario: ドメインは Stripe 非依存 + +- **WHEN** 価格や期の判定ロジックを参照する +- **THEN** それらは Stripe SDK の型・呼び出しに依存せず、アダプタのインターフェース越しにのみ Stripe を使う diff --git a/package.json b/package.json index 4a7c561..7eeaa82 100644 --- a/package.json +++ b/package.json @@ -13,14 +13,17 @@ "lint": "eslint .", "lint:fix": "eslint . --fix", "typecheck": "pnpm -r --parallel typecheck", + "test": "vitest run", "db:generate": "pnpm --filter @checkin/db db:generate", "db:migrate": "pnpm --filter @checkin/db db:migrate", "db:push": "pnpm --filter @checkin/db db:push", "db:studio": "pnpm --filter @checkin/db db:studio" }, "devDependencies": { + "@fission-ai/openspec": "1.4.1", "@nuxt/eslint": "^1.16.0", "eslint": "^9.39.0", - "typescript": "^5.9.0" + "typescript": "^5.9.0", + "vitest": "^2.1.9" } } diff --git a/packages/api/package.json b/packages/api/package.json index fd4ffc7..c659205 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -11,7 +11,10 @@ }, "dependencies": { "@checkin/db": "workspace:*", - "@orpc/server": "^1.14.0" + "@orpc/server": "^1.14.0", + "drizzle-orm": "^0.45.0", + "stripe": "^18.0.0", + "zod": "^3.24.0" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/packages/api/src/auth/auth.test.ts b/packages/api/src/auth/auth.test.ts new file mode 100644 index 0000000..e0be6cb --- /dev/null +++ b/packages/api/src/auth/auth.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from 'vitest' +import { normalizeEmail, deriveMailHash, safeEqual } from './crypto' +import { sanitizeRedirect } from './redirect' +import { isAllowedEmailDomain, isAccountant } from './config' +import { createAuthHelpers } from './context' +import type { SessionActor } from './session' + +const SECRET = 'test-secret' + +describe('crypto: mail_hash', () => { + it('normalizes whitespace and case', () => { + expect(normalizeEmail(' Foo.Bar@m.isct.ac.jp ')).toBe('foo.bar@m.isct.ac.jp') + }) + + it('is deterministic and case/whitespace insensitive', () => { + const a = deriveMailHash(' Foo.Bar@m.isct.ac.jp ', SECRET) + const b = deriveMailHash('foo.bar@m.isct.ac.jp', SECRET) + expect(a).toBe(b) + expect(a).toMatch(/^[0-9a-f]{64}$/) + }) + + it('produces different hashes for different emails', () => { + expect(deriveMailHash('a@m.isct.ac.jp', SECRET)).not.toBe(deriveMailHash('b@m.isct.ac.jp', SECRET)) + }) + + it('depends on the secret', () => { + expect(deriveMailHash('a@m.isct.ac.jp', 'k1')).not.toBe(deriveMailHash('a@m.isct.ac.jp', 'k2')) + }) + + it('refuses to derive without a secret', () => { + expect(() => deriveMailHash('a@m.isct.ac.jp', '')).toThrow() + }) +}) + +describe('crypto: safeEqual', () => { + it('matches equal strings and rejects others (incl. length diffs)', () => { + expect(safeEqual('abc', 'abc')).toBe(true) + expect(safeEqual('abc', 'abd')).toBe(false) + expect(safeEqual('abc', 'abcd')).toBe(false) + }) +}) + +describe('redirect: sanitizeRedirect', () => { + it('allows same-site absolute paths', () => { + expect(sanitizeRedirect('/payments')).toBe('/payments') + expect(sanitizeRedirect('/membership?x=1#y')).toBe('/membership?x=1#y') + }) + + it('falls back for external / protocol-relative / empty', () => { + expect(sanitizeRedirect('https://evil.com')).toBe('/') + expect(sanitizeRedirect('//evil.com')).toBe('/') + expect(sanitizeRedirect('/\\evil.com')).toBe('/') + expect(sanitizeRedirect('relative/path')).toBe('/') + expect(sanitizeRedirect(undefined)).toBe('/') + expect(sanitizeRedirect('', '/home')).toBe('/home') + }) +}) + +describe('config: allow-lists', () => { + it('checks email domain case-insensitively', () => { + const domains = ['m.isct.ac.jp'] + expect(isAllowedEmailDomain('a@m.isct.ac.jp', domains)).toBe(true) + expect(isAllowedEmailDomain('a@M.ISCT.AC.JP', domains)).toBe(true) + expect(isAllowedEmailDomain('a@gmail.com', domains)).toBe(false) + expect(isAllowedEmailDomain('not-an-email', domains)).toBe(false) + }) + + it('checks accountant allow-list', () => { + expect(isAccountant('alice', ['alice', 'bob'])).toBe(true) + expect(isAccountant('mallory', ['alice', 'bob'])).toBe(false) + }) +}) + +describe('context: auth helpers', () => { + const user: SessionActor = { actor: 'user', userId: 'u1', mailHash: 'h1' } + const admin: SessionActor = { actor: 'admin', traqId: 'alice' } + + it('requireUser: rejects anonymous, accepts user', () => { + expect(() => createAuthHelpers(null, true).requireUser()).toThrow() + expect(createAuthHelpers(user, true).requireUser()).toEqual({ userId: 'u1', mailHash: 'h1' }) + }) + + it('requireAdmin: anonymous and plain user are rejected, admin accepted', () => { + expect(() => createAuthHelpers(null, true).requireAdmin()).toThrow() + expect(() => createAuthHelpers(user, true).requireAdmin()).toThrow() + expect(createAuthHelpers(admin, true).requireAdmin()).toEqual({ traqId: 'alice' }) + }) + + it('assertCsrf: throws when invalid, passes when valid', () => { + expect(() => createAuthHelpers(user, false).assertCsrf()).toThrow() + expect(() => createAuthHelpers(user, true).assertCsrf()).not.toThrow() + }) +}) diff --git a/packages/api/src/auth/config.ts b/packages/api/src/auth/config.ts new file mode 100644 index 0000000..cbfe039 --- /dev/null +++ b/packages/api/src/auth/config.ts @@ -0,0 +1,51 @@ +/** + * Auth configuration. Values originate from environment variables and are + * resolved by the host (Nuxt/Nitro reads runtimeConfig). The domain layer only + * depends on this typed shape, never on `process.env` or the framework. + */ +export interface TraqOAuthConfig { + clientId: string + clientSecret: string + authorizeUrl: string + tokenUrl: string + userinfoUrl: string + scope: string + /** Field in the userinfo response that holds the traQ ID (username). */ + userIdField: string +} + +export interface MailerConfig { + driver: 'log' | 'sendgrid' + from: string + sendgridApiKey?: string +} + +export interface AuthConfig { + /** HMAC secret for mail_hash. Required; empty is a hard error at derive time. */ + mailHashSecret: string + /** Allowed isct email domains, e.g. ['m.isct.ac.jp']. */ + allowedEmailDomains: string[] + /** Absolute origin used to build links and validate same-site redirects. */ + appOrigin: string + emailVerificationTtlSec: number + sessionTtlSec: number + /** traQ IDs (usernames) granted accountant (admin) access. */ + accountantTraqIds: string[] + traq: TraqOAuthConfig + mailer: MailerConfig +} + +/** Whether an email's domain is in the allow-list (case-insensitive). */ +export function isAllowedEmailDomain(email: string, domains: string[]): boolean { + const at = email.lastIndexOf('@') + if (at < 0) { + return false + } + const domain = email.slice(at + 1).trim().toLowerCase() + return domains.some(d => d.trim().toLowerCase() === domain) +} + +/** Whether a traQ ID is in the accountant allow-list. */ +export function isAccountant(traqId: string, allow: string[]): boolean { + return allow.includes(traqId) +} diff --git a/packages/api/src/auth/context.ts b/packages/api/src/auth/context.ts new file mode 100644 index 0000000..a51fc7c --- /dev/null +++ b/packages/api/src/auth/context.ts @@ -0,0 +1,50 @@ +import { ORPCError } from '@orpc/server' +import type { SessionActor } from './session' + +export interface UserActor { + userId: string + mailHash: string +} + +export interface AdminActor { + traqId: string +} + +export interface AuthHelpers { + /** Require a logged-in isct user. Throws UNAUTHORIZED otherwise. */ + requireUser(): UserActor + /** Require an accountant (admin). Throws UNAUTHORIZED/FORBIDDEN otherwise. */ + requireAdmin(): AdminActor + /** Enforce double-submit CSRF on a state-changing call. Throws FORBIDDEN if invalid. */ + assertCsrf(): void +} + +/** + * Build the per-request authorization helpers from the resolved session and + * CSRF validity. (session spec: §認可ヘルパ requireUser / requireAdmin, + * §状態変更要求の CSRF 検証) + */ +export function createAuthHelpers(session: SessionActor | null, csrfValid: boolean): AuthHelpers { + return { + requireUser() { + if (session?.actor === 'user') { + return { userId: session.userId, mailHash: session.mailHash } + } + throw new ORPCError('UNAUTHORIZED', { message: 'login required' }) + }, + requireAdmin() { + if (session?.actor === 'admin') { + return { traqId: session.traqId } + } + if (session) { + throw new ORPCError('FORBIDDEN', { message: 'accountant access required' }) + } + throw new ORPCError('UNAUTHORIZED', { message: 'login required' }) + }, + assertCsrf() { + if (!csrfValid) { + throw new ORPCError('FORBIDDEN', { message: 'CSRF token missing or invalid' }) + } + }, + } +} diff --git a/packages/api/src/auth/crypto.ts b/packages/api/src/auth/crypto.ts new file mode 100644 index 0000000..7ad645f --- /dev/null +++ b/packages/api/src/auth/crypto.ts @@ -0,0 +1,46 @@ +import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto' + +/** + * Normalize an email for stable hashing: trim surrounding whitespace and + * lowercase both local-part and domain. (identity spec: §正規化と導出) + */ +export function normalizeEmail(email: string): string { + return email.trim().toLowerCase() +} + +/** + * Derive the service-internal person key `mail_hash` from an isct email. + * + * `mail_hash = HMAC-SHA256(secret, normalize(email))` as lowercase hex. + * The secret MUST be configured; we never fall back to a weak default, + * because a changed/empty secret would silently break the correspondence + * table. (identity spec: §秘密鍵が未設定なら処理を拒否する) + */ +export function deriveMailHash(email: string, secret: string): string { + if (!secret) { + throw new Error('MAIL_HASH_SECRET is not set; refusing to derive mail_hash') + } + return createHmac('sha256', secret).update(normalizeEmail(email)).digest('hex') +} + +/** SHA-256 hex digest — used to store hashes of opaque tokens (never the raw token). */ +export function sha256hex(value: string): string { + return createHash('sha256').update(value).digest('hex') +} + +/** Generate a high-entropy, URL-safe opaque token (sessions, magic links, CSRF). */ +export function generateToken(bytes = 32): string { + return randomBytes(bytes).toString('base64url') +} + +/** + * Constant-time string comparison. Both inputs are hashed to a fixed-length + * digest first, so the comparison time does not depend on input length (an + * early length check would leak through a different code path). Callers must + * still reject empty values separately where "present" matters. + */ +export function safeEqual(a: string, b: string): boolean { + const ah = createHash('sha256').update(a).digest() + const bh = createHash('sha256').update(b).digest() + return timingSafeEqual(ah, bh) +} diff --git a/packages/api/src/auth/email-verification.ts b/packages/api/src/auth/email-verification.ts new file mode 100644 index 0000000..30eb76d --- /dev/null +++ b/packages/api/src/auth/email-verification.ts @@ -0,0 +1,80 @@ +import { randomUUID } from 'node:crypto' +import { and, eq, isNull } from 'drizzle-orm' +import { schema, type Database } from '@checkin/db' +import { generateToken, sha256hex } from './crypto' + +/** + * Issue a single-use, time-limited magic-link token for a `mail_hash` and + * return the raw token (to embed in the emailed link). Only the hash is stored. + * (email-verification spec: §単回・期限付きマジックリンクの発行) + */ +export async function createEmailVerification( + db: Database, + params: { mailHash: string, redirect: string | null, ttlSec: number }, +): Promise { + const token = generateToken() + await db.insert(schema.emailVerifications).values({ + id: randomUUID(), + tokenHash: sha256hex(token), + mailHash: params.mailHash, + redirect: params.redirect, + expiresAt: new Date(Date.now() + params.ttlSec * 1000), + }) + return token +} + +export interface ConsumedVerification { + mailHash: string + redirect: string | null +} + +/** + * Consume a magic-link token: valid, unexpired and unused tokens are marked + * consumed and their `mail_hash`/`redirect` returned. Invalid, expired or + * already-consumed tokens return null and establish nothing. + * (email-verification spec: §有効なトークンの消費 / §無効・期限切れ・再利用トークンの拒否) + */ +export async function consumeEmailVerification( + db: Database, + token: string, +): Promise { + const tokenHash = sha256hex(token) + const now = new Date() + + // Atomically claim the token: only succeeds if currently unconsumed. + const result = await db + .update(schema.emailVerifications) + .set({ consumedAt: now }) + .where(and( + eq(schema.emailVerifications.tokenHash, tokenHash), + isNull(schema.emailVerifications.consumedAt), + )) + + // mysql2 returns affectedRows; if nothing was claimed it was missing or reused. + const affected = (result as unknown as { affectedRows?: number })?.affectedRows ?? 0 + const claimed = Array.isArray(result) + ? ((result[0] as { affectedRows?: number })?.affectedRows ?? 0) + : affected + if (!claimed) { + return null + } + + const [row] = await db + .select({ + mailHash: schema.emailVerifications.mailHash, + redirect: schema.emailVerifications.redirect, + expiresAt: schema.emailVerifications.expiresAt, + }) + .from(schema.emailVerifications) + .where(eq(schema.emailVerifications.tokenHash, tokenHash)) + .limit(1) + + if (!row) { + return null + } + // Expired tokens: we claimed it (so it can't be reused) but reject the login. + if (row.expiresAt.getTime() <= now.getTime()) { + return null + } + return { mailHash: row.mailHash, redirect: row.redirect } +} diff --git a/packages/api/src/auth/identity.ts b/packages/api/src/auth/identity.ts new file mode 100644 index 0000000..94ddc17 --- /dev/null +++ b/packages/api/src/auth/identity.ts @@ -0,0 +1,54 @@ +import { randomUUID } from 'node:crypto' +import { eq } from 'drizzle-orm' +import { schema, type Database } from '@checkin/db' + +export interface UserRow { + id: string + mailHash: string +} + +/** A person row including its (nullable) Stripe Customer link — for billing. */ +export interface BillingUserRow { + id: string + mailHash: string + stripeCustomerId: string | null +} + +/** Fetch a person row by id, including its Stripe Customer link, or null. */ +export async function getUserById(db: Database, id: string): Promise { + const [row] = await db + .select({ + id: schema.users.id, + mailHash: schema.users.mailHash, + stripeCustomerId: schema.users.stripeCustomerId, + }) + .from(schema.users) + .where(eq(schema.users.id, id)) + .limit(1) + return row ?? null +} + +/** + * Get the person row for a `mail_hash`, creating it if absent. Idempotent and + * race-safe: the `INSERT ... ON DUPLICATE KEY UPDATE` no-op plus the unique + * `mail_hash` constraint guarantee a single row even under concurrency. + * (identity spec: §本人行の get-or-create) + */ +export async function getOrCreateUserByMailHash(db: Database, mailHash: string): Promise { + const id = randomUUID() + await db + .insert(schema.users) + .values({ id, mailHash }) + .onDuplicateKeyUpdate({ set: { mailHash } }) + + const [row] = await db + .select({ id: schema.users.id, mailHash: schema.users.mailHash }) + .from(schema.users) + .where(eq(schema.users.mailHash, mailHash)) + .limit(1) + + if (!row) { + throw new Error('failed to get-or-create user by mail_hash') + } + return row +} diff --git a/packages/api/src/auth/index.ts b/packages/api/src/auth/index.ts new file mode 100644 index 0000000..c4fb09a --- /dev/null +++ b/packages/api/src/auth/index.ts @@ -0,0 +1,9 @@ +export * from './config' +export * from './crypto' +export * from './redirect' +export * from './identity' +export * from './session' +export * from './email-verification' +export * from './mailer' +export * from './traq' +export * from './context' diff --git a/packages/api/src/auth/mailer.ts b/packages/api/src/auth/mailer.ts new file mode 100644 index 0000000..5a2ce97 --- /dev/null +++ b/packages/api/src/auth/mailer.ts @@ -0,0 +1,66 @@ +import type { MailerConfig } from './config' + +export interface MailMessage { + to: string + subject: string + text: string + html?: string +} + +/** + * Mail transport abstraction. Domain logic depends only on this interface, so + * the concrete provider (dev log vs SendGrid) is swappable by configuration. + * (email-verification spec: §メール送信は Mailer アダプタ越し) + */ +export interface Mailer { + send(message: MailMessage): Promise +} + +/** Development transport: logs the message instead of sending it. */ +export class LogMailer implements Mailer { + async send(message: MailMessage): Promise { + console.info('[LogMailer] email (not actually sent):', { + to: message.to, + subject: message.subject, + text: message.text, + }) + } +} + +/** Production transport: SendGrid v3 mail send HTTP API. */ +export class SendgridMailer implements Mailer { + constructor(private readonly apiKey: string, private readonly from: string) {} + + async send(message: MailMessage): Promise { + const res = await fetch('https://api.sendgrid.com/v3/mail/send', { + method: 'POST', + headers: { + 'authorization': `Bearer ${this.apiKey}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + personalizations: [{ to: [{ email: message.to }] }], + from: { email: this.from }, + subject: message.subject, + content: [ + { type: 'text/plain', value: message.text }, + ...(message.html ? [{ type: 'text/html', value: message.html }] : []), + ], + }), + }) + if (!res.ok) { + throw new Error(`SendGrid send failed: ${res.status} ${await res.text()}`) + } + } +} + +/** Build the configured mailer. */ +export function createMailer(config: MailerConfig): Mailer { + if (config.driver === 'sendgrid') { + if (!config.sendgridApiKey) { + throw new Error('SENDGRID_API_KEY is required when MAILER_DRIVER=sendgrid') + } + return new SendgridMailer(config.sendgridApiKey, config.from) + } + return new LogMailer() +} diff --git a/packages/api/src/auth/redirect.ts b/packages/api/src/auth/redirect.ts new file mode 100644 index 0000000..26d8634 --- /dev/null +++ b/packages/api/src/auth/redirect.ts @@ -0,0 +1,27 @@ +/** + * Sanitize a post-login `redirect` target to a same-site path, preventing open + * redirects. Only same-origin absolute-path URLs (starting with a single `/`) + * are allowed; anything else falls back to `fallback`. + * + * Rejected: external URLs, protocol-relative `//evil.com`, and backslash tricks. + */ +export function sanitizeRedirect(redirect: string | null | undefined, fallback = '/'): string { + if (!redirect) { + return fallback + } + // Must be an absolute path on this site, not a protocol-relative or external URL. + if (!redirect.startsWith('/') || redirect.startsWith('//') || redirect.startsWith('/\\')) { + return fallback + } + // Defense in depth: reject anything that still parses as having a host. + try { + const url = new URL(redirect, 'http://placeholder.invalid') + if (url.origin !== 'http://placeholder.invalid') { + return fallback + } + return url.pathname + url.search + url.hash + } + catch { + return fallback + } +} diff --git a/packages/api/src/auth/session.ts b/packages/api/src/auth/session.ts new file mode 100644 index 0000000..02c7ac7 --- /dev/null +++ b/packages/api/src/auth/session.ts @@ -0,0 +1,83 @@ +import { randomUUID } from 'node:crypto' +import { eq } from 'drizzle-orm' +import { schema, type Database } from '@checkin/db' +import { generateToken, sha256hex } from './crypto' + +/** The authenticated principal a session represents. Exactly one actor kind. */ +export type SessionActor + = | { actor: 'user', userId: string, mailHash: string } + | { actor: 'admin', traqId: string } + +/** What to store when creating a session. */ +export type NewSessionActor + = | { actor: 'user', userId: string } + | { actor: 'admin', traqId: string } + +/** + * Create a server-side session and return the opaque token to put in the cookie. + * Only the token's hash is persisted, so a DB leak cannot reconstruct live cookies. + */ +export async function createSession( + db: Database, + actor: NewSessionActor, + ttlSec: number, +): Promise { + const token = generateToken() + const expiresAt = new Date(Date.now() + ttlSec * 1000) + await db.insert(schema.sessions).values({ + id: randomUUID(), + idHash: sha256hex(token), + actorType: actor.actor, + userId: actor.actor === 'user' ? actor.userId : null, + traqId: actor.actor === 'admin' ? actor.traqId : null, + expiresAt, + }) + return token +} + +/** + * Resolve a session token to its actor, or null if missing/expired. Expired + * sessions are best-effort deleted. (session spec: §期限切れセッションは無効) + */ +export async function resolveSession(db: Database, token: string | undefined): Promise { + if (!token) { + return null + } + const idHash = sha256hex(token) + const [row] = await db + .select({ + actorType: schema.sessions.actorType, + userId: schema.sessions.userId, + traqId: schema.sessions.traqId, + expiresAt: schema.sessions.expiresAt, + mailHash: schema.users.mailHash, + }) + .from(schema.sessions) + .leftJoin(schema.users, eq(schema.sessions.userId, schema.users.id)) + .where(eq(schema.sessions.idHash, idHash)) + .limit(1) + + if (!row) { + return null + } + if (row.expiresAt.getTime() <= Date.now()) { + await db.delete(schema.sessions).where(eq(schema.sessions.idHash, idHash)) + return null + } + + if (row.actorType === 'admin' && row.traqId) { + return { actor: 'admin', traqId: row.traqId } + } + if (row.actorType === 'user' && row.userId && row.mailHash) { + return { actor: 'user', userId: row.userId, mailHash: row.mailHash } + } + return null +} + +/** Invalidate a session (logout). Safe to call with an unknown/expired token. */ +export async function destroySession(db: Database, token: string | undefined): Promise { + if (!token) { + return + } + await db.delete(schema.sessions).where(eq(schema.sessions.idHash, sha256hex(token))) +} diff --git a/packages/api/src/auth/traq.ts b/packages/api/src/auth/traq.ts new file mode 100644 index 0000000..507ef0e --- /dev/null +++ b/packages/api/src/auth/traq.ts @@ -0,0 +1,77 @@ +import { createHash } from 'node:crypto' +import type { TraqOAuthConfig } from './config' +import { generateToken } from './crypto' + +export interface PkcePair { + verifier: string + challenge: string +} + +/** Generate a PKCE verifier and its S256 challenge (base64url of SHA-256). */ +export function generatePkce(): PkcePair { + const verifier = generateToken(32) + const challenge = createHash('sha256').update(verifier).digest('base64url') + return { verifier, challenge } +} + +/** Build the traQ authorization URL for the authorization-code + PKCE flow. */ +export function buildAuthorizeUrl( + config: TraqOAuthConfig, + params: { state: string, codeChallenge: string, redirectUri: string }, +): string { + const url = new URL(config.authorizeUrl) + url.searchParams.set('response_type', 'code') + url.searchParams.set('client_id', config.clientId) + url.searchParams.set('redirect_uri', params.redirectUri) + url.searchParams.set('scope', config.scope) + url.searchParams.set('state', params.state) + url.searchParams.set('code_challenge', params.codeChallenge) + url.searchParams.set('code_challenge_method', 'S256') + return url.toString() +} + +/** Exchange an authorization code for an access token. */ +export async function exchangeCodeForToken( + config: TraqOAuthConfig, + params: { code: string, codeVerifier: string, redirectUri: string }, +): Promise { + const body = new URLSearchParams({ + grant_type: 'authorization_code', + code: params.code, + redirect_uri: params.redirectUri, + client_id: config.clientId, + code_verifier: params.codeVerifier, + }) + if (config.clientSecret) { + body.set('client_secret', config.clientSecret) + } + const res = await fetch(config.tokenUrl, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body, + }) + if (!res.ok) { + throw new Error(`traQ token exchange failed: ${res.status} ${await res.text()}`) + } + const json = (await res.json()) as { access_token?: string } + if (!json.access_token) { + throw new Error('traQ token exchange returned no access_token') + } + return json.access_token +} + +/** Fetch the authenticated traQ user and extract its traQ ID (username). */ +export async function fetchTraqUserId(config: TraqOAuthConfig, accessToken: string): Promise { + const res = await fetch(config.userinfoUrl, { + headers: { authorization: `Bearer ${accessToken}` }, + }) + if (!res.ok) { + throw new Error(`traQ userinfo failed: ${res.status} ${await res.text()}`) + } + const json = (await res.json()) as Record + const traqId = json[config.userIdField] + if (typeof traqId !== 'string' || !traqId) { + throw new Error(`traQ userinfo missing field '${config.userIdField}'`) + } + return traqId +} diff --git a/packages/api/src/billing/authorize.ts b/packages/api/src/billing/authorize.ts new file mode 100644 index 0000000..98f66ed --- /dev/null +++ b/packages/api/src/billing/authorize.ts @@ -0,0 +1,40 @@ +import type { Variant } from './pricing' + +/** + * Whether a logged-in user may issue a **standard** invoice for the submitted + * email. Allowed only for one's own person: the `mail_hash` derived from the + * submitted email MUST equal the session's `mail_hash`. + * (membership-billing spec: §発行の認可 — 標準は本人) + */ +export function canIssueStandard(sessionMailHash: string, submittedMailHash: string): boolean { + return sessionMailHash === submittedMailHash +} + +/** + * Whether an actor may issue a **special** (継続特別 ¥2,000) invoice. Only the + * accountant (admin) may; ordinary users may not, regardless of target. + * (membership-billing spec: §発行の認可 — 特別は管理者) + */ +export function canIssueSpecial(isAdmin: boolean): boolean { + return isAdmin +} + +/** + * Gate an issuance by variant. Returns whether the actor is authorized: + * - `standard` → caller must be the person (mail_hash match). + * - `special` → caller must be admin. + */ +export function authorizeIssuance(input: { + variant: Variant + isAdmin: boolean + sessionMailHash?: string + submittedMailHash?: string +}): boolean { + if (input.variant === 'special') { + return canIssueSpecial(input.isAdmin) + } + if (input.sessionMailHash === undefined || input.submittedMailHash === undefined) { + return false + } + return canIssueStandard(input.sessionMailHash, input.submittedMailHash) +} diff --git a/packages/api/src/billing/billing.test.ts b/packages/api/src/billing/billing.test.ts new file mode 100644 index 0000000..33174f3 --- /dev/null +++ b/packages/api/src/billing/billing.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest' +import type { BillingConfig } from './config' +import { computeTerm } from './term' +import { selectPriceId } from './pricing' +import { authorizeIssuance } from './authorize' + +const config: BillingConfig = { + prices: { + shinkiZenki: 'price_shinki_zenki', + shinkiKouki: 'price_shinki_kouki', + keizokuStandard: 'price_keizoku_standard', + keizokuSpecial: 'price_keizoku_special', + }, + stripeSecretKey: 'sk_test_x', + stripeWebhookSecret: 'whsec_x', + invoiceDaysUntilDue: 7, +} + +/** Build a date at the 1st of `month` (1-based) in a fixed year. */ +function dateInMonth(month: number): Date { + return new Date(2026, month - 1, 1) +} + +describe('computeTerm', () => { + it('treats March (3) as kouki (後期)', () => { + expect(computeTerm(dateInMonth(3))).toBe('kouki') + }) + + it('treats April (4) — the activity-year boundary — as zenki (前期)', () => { + expect(computeTerm(dateInMonth(4))).toBe('zenki') + }) + + it('treats September (9) as zenki (前期)', () => { + expect(computeTerm(dateInMonth(9))).toBe('zenki') + }) + + it('treats October (10) as kouki (後期)', () => { + expect(computeTerm(dateInMonth(10))).toBe('kouki') + }) +}) + +describe('selectPriceId', () => { + it('new + zenki → 新規前期 price', () => { + expect(selectPriceId(config, { feeType: 'new', term: 'zenki' })).toBe('price_shinki_zenki') + }) + + it('new + kouki → 新規後期 price', () => { + expect(selectPriceId(config, { feeType: 'new', term: 'kouki' })).toBe('price_shinki_kouki') + }) + + it('continuation + standard → 継続標準 price', () => { + expect(selectPriceId(config, { feeType: 'continuation', variant: 'standard' })).toBe('price_keizoku_standard') + }) + + it('continuation + special → 継続特別 price', () => { + expect(selectPriceId(config, { feeType: 'continuation', variant: 'special' })).toBe('price_keizoku_special') + }) + + it('throws when the resolved price id is empty (unconfigured PRICE_* env)', () => { + const empty: BillingConfig = { ...config, prices: { ...config.prices, keizokuStandard: '' } } + expect(() => selectPriceId(empty, { feeType: 'continuation', variant: 'standard' })) + .toThrow(/missing price id/) + }) +}) + +describe('authorizeIssuance', () => { + it('allows standard when the submitted mail_hash matches the session', () => { + expect(authorizeIssuance({ + variant: 'standard', + isAdmin: false, + sessionMailHash: 'hash_self', + submittedMailHash: 'hash_self', + })).toBe(true) + }) + + it('denies standard when the mail_hash does not match (issuing for another)', () => { + expect(authorizeIssuance({ + variant: 'standard', + isAdmin: false, + sessionMailHash: 'hash_self', + submittedMailHash: 'hash_other', + })).toBe(false) + }) + + it('denies special for a non-admin user', () => { + expect(authorizeIssuance({ variant: 'special', isAdmin: false })).toBe(false) + }) + + it('allows special for an admin (accountant)', () => { + expect(authorizeIssuance({ variant: 'special', isAdmin: true })).toBe(true) + }) +}) diff --git a/packages/api/src/billing/config.ts b/packages/api/src/billing/config.ts new file mode 100644 index 0000000..a6e1bc6 --- /dev/null +++ b/packages/api/src/billing/config.ts @@ -0,0 +1,25 @@ +/** + * Billing configuration. Like {@link AuthConfig}, values originate from + * environment variables and are resolved by the host (Nitro reads runtimeConfig). + * The domain layer depends only on this typed shape — never on `process.env`, + * the framework, or the Stripe SDK. + */ +export interface BillingConfig { + /** The four Price ids that the pricing rule selects between. */ + prices: { + /** 新規入部費・前期 (¥4,000). */ + shinkiZenki: string + /** 新規入部費・後期 (¥2,000). */ + shinkiKouki: string + /** 継続(部費)標準 (¥4,000). */ + keizokuStandard: string + /** 継続(部費)特別 (¥2,000). 管理者のみ発行可. */ + keizokuSpecial: string + } + /** Stripe secret key (test mode to start). Empty until billing is configured. */ + stripeSecretKey: string + /** Stripe webhook signing secret for `invoice.paid` verification. */ + stripeWebhookSecret: string + /** Days until an issued invoice is due (`send_invoice` collection). */ + invoiceDaysUntilDue: number +} diff --git a/packages/api/src/billing/index.ts b/packages/api/src/billing/index.ts new file mode 100644 index 0000000..120279e --- /dev/null +++ b/packages/api/src/billing/index.ts @@ -0,0 +1,4 @@ +export * from './config' +export * from './term' +export * from './pricing' +export * from './authorize' diff --git a/packages/api/src/billing/pricing.ts b/packages/api/src/billing/pricing.ts new file mode 100644 index 0000000..7a37665 --- /dev/null +++ b/packages/api/src/billing/pricing.ts @@ -0,0 +1,50 @@ +import type { BillingConfig } from './config' +import type { Term } from './term' + +/** 費目: 新規入部費 (`new`) か 継続(部費) (`continuation`). */ +export type FeeType = 'new' | 'continuation' + +/** 区分: 標準 (`standard`) か 特別 (`special`). 特別は継続のみ・管理者発行. */ +export type Variant = 'standard' | 'special' + +export interface SelectPriceInput { + feeType: FeeType + /** Required for `feeType: 'new'` (前期/後期 で price が変わる). */ + term?: Term + /** Defaults to `standard`. `special` only valid with `continuation`. */ + variant?: Variant +} + +/** + * Map 費目・期・区分 to a configured Price id (membership-billing spec: + * §費目と期・区分から price を決定): + * - new → term==='zenki' ? 前期(¥4,000) : 後期(¥2,000) + * - continuation + standard → 継続標準 (¥4,000) + * - continuation + special → 継続特別 (¥2,000) + * + * The domain selects the `price_id`, never a raw amount — a Product alone does + * not pin an amount (design.md D4). + */ +export function selectPriceId(config: BillingConfig, input: SelectPriceInput): string { + const variant: Variant = input.variant ?? 'standard' + + let priceId: string + if (input.feeType === 'new') { + if (!input.term) { + throw new Error('term is required to price a new-membership fee') + } + priceId = input.term === 'zenki' ? config.prices.shinkiZenki : config.prices.shinkiKouki + } + else { + // continuation (部費) + priceId = variant === 'special' ? config.prices.keizokuSpecial : config.prices.keizokuStandard + } + + // Guard against an unconfigured Price: issuing with an empty id would fail + // opaquely at Stripe, so fail clearly here pointing at the missing env var. + if (!priceId) { + const term = input.term ?? '-' + throw new Error(`missing price id for ${input.feeType}/${variant}/${term}; check PRICE_* env`) + } + return priceId +} diff --git a/packages/api/src/billing/term.ts b/packages/api/src/billing/term.ts new file mode 100644 index 0000000..28bacb7 --- /dev/null +++ b/packages/api/src/billing/term.ts @@ -0,0 +1,25 @@ +/** A half of the activity year. (membership-billing spec: §活動年度に基づく期判定) */ +export type Term = 'zenki' | 'kouki' + +/** + * Decide the half-term for a date. The activity year runs Apr 1 – Mar 31: + * - Apr–Sep (months 4–9) → `zenki` (前期) + * - Oct–Dec & Jan–Mar → `kouki` (後期) + * + * Deterministic and uniquely determined by the month alone. + */ +export function computeTerm(date: Date): Term { + const month = date.getMonth() + 1 // getMonth() is 0-based + return month >= 4 && month <= 9 ? 'zenki' : 'kouki' +} + +/** + * The activity year a date falls in, labelled by its starting calendar year. + * Apr–Dec belong to the year that started this Apr; Jan–Mar belong to the year + * that started the *previous* Apr. Useful as an invoice metadata label. + */ +export function computeActivityYear(date: Date): number { + const year = date.getFullYear() + const month = date.getMonth() + 1 + return month >= 4 ? year : year - 1 +} diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 053ccbd..327f114 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -3,5 +3,20 @@ import type { appRouter } from './router' export { appRouter } from './router' export { pub, type Context } from './orpc' +/** Auth / identity domain utilities (used by the Nitro host to build context). */ +export * from './auth' + +/** Membership-collection domain (Stripe-independent: term/pricing/authorize + config). */ +export * from './billing' + +/** Stripe adapter layer (all Stripe SDK usage is isolated here). */ +export * from './stripe' + +/** Accountant notification abstraction (used by the payment webhook). */ +export * from './notify' + +/** Webhook idempotency ledger helpers. */ +export * from './webhook' + /** Type of the application router — import this (type-only) on the client. */ export type AppRouter = typeof appRouter diff --git a/packages/api/src/notify/index.ts b/packages/api/src/notify/index.ts new file mode 100644 index 0000000..5bc2f5f --- /dev/null +++ b/packages/api/src/notify/index.ts @@ -0,0 +1 @@ +export * from './notifier' diff --git a/packages/api/src/notify/notifier.ts b/packages/api/src/notify/notifier.ts new file mode 100644 index 0000000..f5663b0 --- /dev/null +++ b/packages/api/src/notify/notifier.ts @@ -0,0 +1,21 @@ +/** + * Accountant notification abstraction. Like {@link Mailer}, the domain depends + * only on this interface so the concrete channel (dev log now, traQ/email later) + * is swappable by configuration. (payment-webhook spec: §入金時に会計へ通知, + * design.md D7) + */ +export interface Notifier { + notify(message: string): Promise +} + +/** Development notifier: logs the accountant message instead of delivering it. */ +export class LogNotifier implements Notifier { + async notify(message: string): Promise { + console.info('[LogNotifier] accountant notification (not actually delivered):', message) + } +} + +/** Build the configured notifier. Only the log driver exists for now. */ +export function createNotifier(): Notifier { + return new LogNotifier() +} diff --git a/packages/api/src/orpc.ts b/packages/api/src/orpc.ts index 75acfd0..30a6e5b 100644 --- a/packages/api/src/orpc.ts +++ b/packages/api/src/orpc.ts @@ -1,13 +1,29 @@ import { os } from '@orpc/server' import type { Database } from '@checkin/db' +import type { AuthConfig } from './auth/config' +import type { Mailer } from './auth/mailer' +import type { SessionActor } from './auth/session' +import type { AuthHelpers } from './auth/context' +import type { BillingConfig } from './billing/config' +import type { StripeClient } from './stripe/client' +import type { Notifier } from './notify/notifier' /** * Request context shared by every procedure. The Nitro handler builds this - * per request (e.g. injecting the database handle and the authenticated user - * once auth is specced out). + * per request: it injects the database handle, resolved auth + billing config, + * the mailer + accountant notifier, a (lazy) Stripe adapter, the authenticated + * session (from the session cookie), and the authorization helpers (CSRF-aware). */ -export interface Context { +export interface Context extends AuthHelpers { db: Database + config: AuthConfig + billing: BillingConfig + mailer: Mailer + notifier: Notifier + /** Lazy Stripe adapter — only instantiates the SDK when a procedure uses it. */ + stripe: StripeClient + /** The authenticated actor, or null when unauthenticated. */ + session: SessionActor | null } /** Base procedure builder — start all procedures from here. */ diff --git a/packages/api/src/router.ts b/packages/api/src/router.ts index 1008fcb..5a3aa06 100644 --- a/packages/api/src/router.ts +++ b/packages/api/src/router.ts @@ -1,13 +1,200 @@ +import { z } from 'zod' +import { ORPCError } from '@orpc/server' import { pub } from './orpc' +import { + isAllowedEmailDomain, + deriveMailHash, + sanitizeRedirect, + createEmailVerification, + getUserById, +} from './auth' +import { computeActivityYear, computeTerm, selectPriceId } from './billing' +import { getOrCreateCustomer, issueInvoice } from './stripe' /** - * Application router. - * - * Only a `health.check` probe exists for now; real procedures are added per - * feature via OpenSpec change proposals. Keep procedures grouped by capability. + * Application router. Procedures are grouped by capability. Browser-redirect / + * cookie-mutating endpoints (csrf, login, verify-email/confirm, logout) live as + * Nitro routes; only data operations live here as oRPC procedures. */ export const appRouter = { health: { check: pub.handler(() => ({ ok: true, timestamp: new Date().toISOString() })), }, + + auth: { + /** POST /verify-email — send an isct magic-link. Sends mail only; sets no cookie. */ + requestEmailVerification: pub + .input(z.object({ + email: z.string().email(), + redirect: z.string().optional(), + })) + .handler(async ({ input, context }) => { + context.assertCsrf() + + if (!isAllowedEmailDomain(input.email, context.config.allowedEmailDomains)) { + throw new ORPCError('BAD_REQUEST', { + message: 'email domain is not allowed', + }) + } + + const mailHash = deriveMailHash(input.email, context.config.mailHashSecret) + const redirect = input.redirect ? sanitizeRedirect(input.redirect) : null + const token = await createEmailVerification(context.db, { + mailHash, + redirect, + ttlSec: context.config.emailVerificationTtlSec, + }) + + const link = `${context.config.appOrigin}/verify-email/confirm?token=${encodeURIComponent(token)}` + await context.mailer.send({ + to: input.email, + subject: 'Checkin: メールアドレスの確認', + text: `以下のリンクを開いてログインを完了してください(${Math.round( + context.config.emailVerificationTtlSec / 60, + )}分間有効):\n\n${link}\n\n心当たりがない場合はこのメールを破棄してください。`, + }) + + return { ok: true as const } + }), + + /** Return the current actor (own info only). */ + me: pub.handler(({ context }) => { + const session = context.session + if (!session) { + return { actor: null } + } + if (session.actor === 'admin') { + return { actor: 'admin' as const, traqId: session.traqId } + } + return { actor: 'user' as const } + }), + }, + + membership: { + /** + * Issue a **standard** invoice for the logged-in user's own person. The + * submitted email must hash to the session's `mail_hash` (no issuing for + * others). `new` fees auto-price by the current term; `continuation` uses + * the standard price. (membership-billing spec: §発行の認可 — 標準は本人) + */ + issueInvoice: pub + .input(z.object({ + email: z.string().email(), + name: z.string().min(1), + feeType: z.enum(['new', 'continuation']), + })) + .handler(async ({ input, context }) => { + const user = context.requireUser() + context.assertCsrf() + + // Authorize: the submitted email must be the caller's own (mail_hash match). + const submittedMailHash = deriveMailHash(input.email, context.config.mailHashSecret) + if (submittedMailHash !== user.mailHash) { + throw new ORPCError('FORBIDDEN', { message: 'cannot issue an invoice for another person' }) + } + + // Standard issuance only (special is admin-only via issueSpecialInvoice). + const now = new Date() + const term = computeTerm(now) + const priceId = selectPriceId(context.billing, { + feeType: input.feeType, + term, + variant: 'standard', + }) + + // Resolve the Customer (DB → Stripe search → create) and persist its id. + const row = await getUserById(context.db, user.userId) + if (!row) { + throw new ORPCError('NOT_FOUND', { message: 'user not found' }) + } + const customerId = await getOrCreateCustomer(context.stripe, context.db, { + userId: row.id, + stripeCustomerId: row.stripeCustomerId, + email: input.email, + name: input.name, + mailHash: user.mailHash, + }) + + const activityYear = computeActivityYear(now) + const result = await issueInvoice(context.stripe, { + customerId, + priceId, + daysUntilDue: context.billing.invoiceDaysUntilDue, + // Deterministic key so a rapid double-submit dedupes within Stripe's + // idempotency window (not a cross-time issuance ledger — see design Risks). + idempotencyKey: `inv:${customerId}:${priceId}:${activityYear}:${input.feeType}:standard`, + metadata: { + fee_type: input.feeType, + term, + activity_year: String(activityYear), + }, + }) + return result + }), + + /** + * Issue a **special** (継続特別 ¥2,000) invoice for a target user. Admin + * (accountant) only. Reuses the target's `stripe_customer_id` if linked, + * otherwise creates the Customer from the supplied email (required if none). + * (membership-billing spec: §発行の認可 — 特別は管理者) + */ + issueSpecialInvoice: pub + .input(z.object({ + userId: z.string().min(1), + name: z.string().min(1).optional(), + email: z.string().email().optional(), + })) + .handler(async ({ input, context }) => { + context.requireAdmin() + context.assertCsrf() + + const row = await getUserById(context.db, input.userId) + if (!row) { + throw new ORPCError('NOT_FOUND', { message: 'target user not found' }) + } + + // Reuse the linked Customer; otherwise an email is required to create one. + if (!row.stripeCustomerId && !input.email) { + throw new ORPCError('BAD_REQUEST', { + message: 'email is required to create a Stripe Customer for this user', + }) + } + // When we will create a Customer from the supplied email, the email must + // belong to the target user (mail_hash match). Otherwise we would + // permanently link the user row to the wrong email/Customer. + if (!row.stripeCustomerId && input.email + && deriveMailHash(input.email, context.config.mailHashSecret) !== row.mailHash) { + throw new ORPCError('BAD_REQUEST', { message: 'email does not match the target user' }) + } + const customerId = await getOrCreateCustomer(context.stripe, context.db, { + userId: row.id, + stripeCustomerId: row.stripeCustomerId, + // email is only used when no Customer exists yet (guarded above). + email: input.email ?? '', + name: input.name, + mailHash: row.mailHash, + }) + + const now = new Date() + const priceId = selectPriceId(context.billing, { + feeType: 'continuation', + variant: 'special', + }) + const activityYear = computeActivityYear(now) + const result = await issueInvoice(context.stripe, { + customerId, + priceId, + daysUntilDue: context.billing.invoiceDaysUntilDue, + // Deterministic key so a rapid double-submit dedupes within Stripe's + // idempotency window (not a cross-time issuance ledger — see design Risks). + idempotencyKey: `inv:${customerId}:${priceId}:${activityYear}:continuation:special`, + metadata: { + fee_type: 'continuation', + variant: 'special', + activity_year: String(activityYear), + }, + }) + return result + }), + }, } diff --git a/packages/api/src/stripe/client.ts b/packages/api/src/stripe/client.ts new file mode 100644 index 0000000..619bf0b --- /dev/null +++ b/packages/api/src/stripe/client.ts @@ -0,0 +1,35 @@ +import Stripe from 'stripe' + +/** + * Stripe SDK initialization, isolated to the adapter layer. + * + * The client is created **lazily**: a request that never touches billing must + * not fail just because `STRIPE_SECRET_KEY` is absent (e.g. local auth-only + * dev). We only throw when something actually tries to use Stripe without a key. + * (stripe-customer spec: §Stripe 呼び出しはアダプタ境界に閉じ込める) + */ +export class StripeClient { + private client: Stripe | undefined + + constructor(private readonly secretKey: string) {} + + /** The underlying SDK handle, created on first use. */ + get sdk(): Stripe { + if (!this.client) { + if (!this.secretKey) { + throw new Error('STRIPE_SECRET_KEY is not set; refusing to call Stripe') + } + this.client = new Stripe(this.secretKey) + } + return this.client + } +} + +/** + * Build a lazy Stripe adapter. Constructing it is cheap and key-free, so the + * Nitro host can attach one to every request Context; the SDK is only + * instantiated when a billing procedure actually reaches out to Stripe. + */ +export function createStripeClient(secretKey: string): StripeClient { + return new StripeClient(secretKey) +} diff --git a/packages/api/src/stripe/customers.ts b/packages/api/src/stripe/customers.ts new file mode 100644 index 0000000..d9a7459 --- /dev/null +++ b/packages/api/src/stripe/customers.ts @@ -0,0 +1,78 @@ +import { eq } from 'drizzle-orm' +import { schema, type Database } from '@checkin/db' +import { normalizeEmail } from '../auth/crypto' +import type { StripeClient } from './client' + +/** Inputs to resolve (and possibly create) the Stripe Customer for a person. */ +export interface ResolveCustomerInput { + /** The person row id (`users.id`) whose `stripe_customer_id` we read/persist. */ + userId: string + /** Existing `users.stripe_customer_id`, or null if not yet linked. */ + stripeCustomerId: string | null + /** Plaintext email — used only transiently to search/create the Customer. */ + email: string + /** Display name for the Customer (for human-readable Stripe dashboards). */ + name?: string + /** Non-PII labels for log readability; never used as a lookup key. */ + mailHash: string + /** Optional traQ ID for metadata only (untrusted; not a reference key). */ + traqId?: string +} + +/** + * Get-or-create the Stripe Customer for a person and persist its id. + * + * Resolution order (stripe-customer spec: §Customer の get-or-create): + * 1. `users.stripe_customer_id` present → reuse it (no Stripe round-trip). + * 2. Else search Stripe by the submitted email; adopt a match and save it. + * 3. Else create a Customer (email + name + metadata) and save it. + * + * The email plaintext is only used for (2)/(3); it is never persisted. The + * customer id is the sole reference key — the traQ ID is metadata only. + */ +export async function getOrCreateCustomer( + stripe: StripeClient, + db: Database, + input: ResolveCustomerInput, +): Promise { + // 1. Reuse the linked Customer if we already have one. + if (input.stripeCustomerId) { + return input.stripeCustomerId + } + + const metadata: Record = { mail_hash: input.mailHash } + if (input.traqId) { + metadata.traq_id = input.traqId + } + + // Normalize (trim + lowercase) so case/whitespace variants don't create + // duplicate Customers. mail_hash already normalizes internally, so the + // router's mail_hash match is unaffected. + const email = normalizeEmail(input.email) + + // 2. Look for an existing Customer by email before creating a new one. We use + // list({email}) rather than search() so no Search API enablement is required. + const existing = await stripe.sdk.customers.list({ email, limit: 1 }) + const found = existing.data[0] + if (found) { + await persistCustomerId(db, input.userId, found.id) + return found.id + } + + // 3. Nothing on either side: create the Customer and persist its id. + const created = await stripe.sdk.customers.create({ + email, + name: input.name, + metadata, + }) + await persistCustomerId(db, input.userId, created.id) + return created.id +} + +/** Persist the resolved Customer id on the person row (`users.stripe_customer_id`). */ +async function persistCustomerId(db: Database, userId: string, customerId: string): Promise { + await db + .update(schema.users) + .set({ stripeCustomerId: customerId }) + .where(eq(schema.users.id, userId)) +} diff --git a/packages/api/src/stripe/index.ts b/packages/api/src/stripe/index.ts new file mode 100644 index 0000000..c89cec2 --- /dev/null +++ b/packages/api/src/stripe/index.ts @@ -0,0 +1,4 @@ +export * from './client' +export * from './customers' +export * from './invoices' +export * from './webhook' diff --git a/packages/api/src/stripe/invoices.ts b/packages/api/src/stripe/invoices.ts new file mode 100644 index 0000000..2c5d628 --- /dev/null +++ b/packages/api/src/stripe/invoices.ts @@ -0,0 +1,88 @@ +import type { StripeClient } from './client' + +/** Inputs for issuing a single-line invoice for one Price to a Customer. */ +export interface IssueInvoiceInput { + /** Target Stripe Customer id (resolved via get-or-create). */ + customerId: string + /** The Price id chosen by the domain (費目・期・区分 → price). */ + priceId: string + /** Days until the invoice is due (`send_invoice` collection). */ + daysUntilDue: number + /** Optional metadata (e.g. activity-year label) attached to the Invoice. */ + metadata?: Record + /** + * Optional deterministic Stripe idempotency key. Dedupes a rapid double-submit + * within Stripe's window; it is not a cross-time issuance ledger (design Risks). + */ + idempotencyKey?: string +} + +/** What the caller needs to direct the user to the hosted payment page. */ +export interface IssuedInvoice { + invoiceId: string + hostedInvoiceUrl: string | null +} + +/** + * Create a one-line Invoice for the given Price, finalize it, and send it. + * + * Steps (membership-billing spec: §請求書の作成・確定・送付): + * 1. Create a draft Invoice for the Customer (`send_invoice` collection). + * 2. Attach an InvoiceItem for the Price to that draft. + * 3. Finalize → send. The returned `hosted_invoice_url` is the payment page. + */ +export async function issueInvoice( + stripe: StripeClient, + input: IssueInvoiceInput, +): Promise { + // The idempotency key (when supplied) is passed as Stripe request options so a + // rapid double-submit collapses to a single invoice within Stripe's window. + const requestOptions = input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : undefined + + // 1. Draft invoice. `send_invoice` + days_until_due gives the user a payable + // hosted invoice (vs charge_automatically which needs a saved payment method). + const draft = await stripe.sdk.invoices.create({ + customer: input.customerId, + collection_method: 'send_invoice', + days_until_due: input.daysUntilDue, + metadata: input.metadata, + }, requestOptions) + if (!draft.id) { + throw new Error('Stripe did not return an invoice id') + } + const invoiceId = draft.id + + // Once the draft exists, clean it up if any later step fails so we don't leave + // an orphaned draft/finalized invoice behind, then rethrow the original error. + try { + // 2. Add the single priced line to the draft. + await stripe.sdk.invoiceItems.create({ + customer: input.customerId, + invoice: invoiceId, + pricing: { price: input.priceId }, + }, requestOptions) + + // 3. Finalize then send so the customer receives the hosted invoice / email. + await stripe.sdk.invoices.finalizeInvoice(invoiceId) + const sent = await stripe.sdk.invoices.sendInvoice(invoiceId) + + return { invoiceId: sent.id ?? invoiceId, hostedInvoiceUrl: sent.hosted_invoice_url ?? null } + } + catch (err) { + // Best-effort cleanup: delete a still-draft invoice, or void it if finalized. + // Swallow cleanup failures so the original error is what surfaces. + try { + const current = await stripe.sdk.invoices.retrieve(invoiceId) + if (current.status === 'draft') { + await stripe.sdk.invoices.del(invoiceId) + } + else { + await stripe.sdk.invoices.voidInvoice(invoiceId) + } + } + catch { + // ignore cleanup errors; surface the original failure below + } + throw err + } +} diff --git a/packages/api/src/stripe/webhook.ts b/packages/api/src/stripe/webhook.ts new file mode 100644 index 0000000..e5044d8 --- /dev/null +++ b/packages/api/src/stripe/webhook.ts @@ -0,0 +1,30 @@ +import type Stripe from 'stripe' +import type { StripeClient } from './client' + +/** A verified Stripe event reduced to what the webhook handler needs. */ +export interface VerifiedEvent { + id: string + type: string +} + +/** + * Verify a Stripe webhook signature over the raw request body and return the + * decoded event. Throws when the signature is missing/invalid — callers map that + * to a 4xx. The raw (unparsed) body is required for the HMAC to match. + * (payment-webhook spec: §Stripe 署名の検証) + */ +export function constructEvent( + stripe: StripeClient, + rawBody: string | Buffer, + signature: string | undefined, + webhookSecret: string, +): VerifiedEvent { + if (!signature) { + throw new Error('missing Stripe-Signature header') + } + if (!webhookSecret) { + throw new Error('STRIPE_WEBHOOK_SECRET is not set; refusing to verify webhook') + } + const event: Stripe.Event = stripe.sdk.webhooks.constructEvent(rawBody, signature, webhookSecret) + return { id: event.id, type: event.type } +} diff --git a/packages/api/src/webhook/events.ts b/packages/api/src/webhook/events.ts new file mode 100644 index 0000000..603fe48 --- /dev/null +++ b/packages/api/src/webhook/events.ts @@ -0,0 +1,55 @@ +import { randomUUID } from 'node:crypto' +import { eq } from 'drizzle-orm' +import { schema, type Database } from '@checkin/db' + +/** + * Whether a Stripe event id has already been recorded as processed. Used for a + * check-then-notify-then-record flow so a notify failure leaves the event + * unrecorded and Stripe will retry. (payment-webhook spec: §event id による冪等処理) + */ +export async function hasProcessedStripeEvent(db: Database, eventId: string): Promise { + const rows = await db + .select({ id: schema.stripeEvents.id }) + .from(schema.stripeEvents) + .where(eq(schema.stripeEvents.eventId, eventId)) + .limit(1) + return rows.length > 0 +} + +/** + * Record a Stripe event id once, race-safely. Returns `true` if this call was + * the first to see the event (caller should run side effects), or `false` if + * the event was already recorded (duplicate delivery — skip side effects). + * + * Idempotency rides on the `stripe_events.event_id` UNIQUE constraint: a + * concurrent/duplicate insert fails with a duplicate-key error, which we treat + * as "already processed". (payment-webhook spec: §event id による冪等処理) + */ +export async function recordStripeEventOnce( + db: Database, + event: { id: string, type: string }, +): Promise { + try { + await db.insert(schema.stripeEvents).values({ + id: randomUUID(), + eventId: event.id, + type: event.type, + }) + return true + } + catch (err) { + if (isDuplicateKeyError(err)) { + return false + } + throw err + } +} + +/** Whether an error is a MySQL/MariaDB duplicate-key (ER_DUP_ENTRY = 1062). */ +function isDuplicateKeyError(err: unknown): boolean { + if (typeof err !== 'object' || err === null) { + return false + } + const e = err as { code?: string, errno?: number } + return e.code === 'ER_DUP_ENTRY' || e.errno === 1062 +} diff --git a/packages/api/src/webhook/index.ts b/packages/api/src/webhook/index.ts new file mode 100644 index 0000000..def94ce --- /dev/null +++ b/packages/api/src/webhook/index.ts @@ -0,0 +1 @@ +export * from './events' diff --git a/packages/db/drizzle/0000_black_khan.sql b/packages/db/drizzle/0000_black_khan.sql new file mode 100644 index 0000000..ac1100a --- /dev/null +++ b/packages/db/drizzle/0000_black_khan.sql @@ -0,0 +1,34 @@ +CREATE TABLE `email_verifications` ( + `id` varchar(36) NOT NULL, + `token_hash` varchar(64) NOT NULL, + `mail_hash` varchar(64) NOT NULL, + `redirect` varchar(1024), + `expires_at` timestamp NOT NULL, + `consumed_at` timestamp, + `created_at` timestamp NOT NULL DEFAULT (now()), + CONSTRAINT `email_verifications_id` PRIMARY KEY(`id`), + CONSTRAINT `email_verifications_token_hash_unique` UNIQUE(`token_hash`) +); +--> statement-breakpoint +CREATE TABLE `sessions` ( + `id` varchar(36) NOT NULL, + `id_hash` varchar(64) NOT NULL, + `actor_type` enum('user','admin') NOT NULL, + `user_id` varchar(36), + `traq_id` varchar(255), + `expires_at` timestamp NOT NULL, + `created_at` timestamp NOT NULL DEFAULT (now()), + CONSTRAINT `sessions_id` PRIMARY KEY(`id`), + CONSTRAINT `sessions_id_hash_unique` UNIQUE(`id_hash`) +); +--> statement-breakpoint +CREATE TABLE `users` ( + `id` varchar(36) NOT NULL, + `mail_hash` varchar(64) NOT NULL, + `created_at` timestamp NOT NULL DEFAULT (now()), + `updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `users_id` PRIMARY KEY(`id`), + CONSTRAINT `users_mail_hash_unique` UNIQUE(`mail_hash`) +); +--> statement-breakpoint +ALTER TABLE `sessions` ADD CONSTRAINT `sessions_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE no action ON UPDATE no action; \ No newline at end of file diff --git a/packages/db/drizzle/0000_giant_selene.sql b/packages/db/drizzle/0000_giant_selene.sql deleted file mode 100644 index 9182155..0000000 --- a/packages/db/drizzle/0000_giant_selene.sql +++ /dev/null @@ -1,6 +0,0 @@ -CREATE TABLE `users` ( - `id` varchar(36) NOT NULL, - `name` varchar(255) NOT NULL, - `created_at` timestamp NOT NULL DEFAULT (now()), - CONSTRAINT `users_id` PRIMARY KEY(`id`) -); diff --git a/packages/db/drizzle/0001_naive_stryfe.sql b/packages/db/drizzle/0001_naive_stryfe.sql new file mode 100644 index 0000000..cdfae49 --- /dev/null +++ b/packages/db/drizzle/0001_naive_stryfe.sql @@ -0,0 +1,10 @@ +CREATE TABLE `stripe_events` ( + `id` varchar(36) NOT NULL, + `event_id` varchar(255) NOT NULL, + `type` varchar(255) NOT NULL, + `received_at` timestamp NOT NULL DEFAULT (now()), + CONSTRAINT `stripe_events_id` PRIMARY KEY(`id`), + CONSTRAINT `stripe_events_event_id_unique` UNIQUE(`event_id`) +); +--> statement-breakpoint +ALTER TABLE `users` ADD `stripe_customer_id` varchar(255); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0000_snapshot.json b/packages/db/drizzle/meta/0000_snapshot.json index 52c53fa..b839d38 100644 --- a/packages/db/drizzle/meta/0000_snapshot.json +++ b/packages/db/drizzle/meta/0000_snapshot.json @@ -1,9 +1,171 @@ { "version": "5", "dialect": "mysql", - "id": "f567a4d8-8d94-4094-8092-9dd686571350", + "id": "f9930cfd-55d9-4df4-b002-3f8480d1bf74", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { + "email_verifications": { + "name": "email_verifications", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mail_hash": { + "name": "mail_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect": { + "name": "redirect", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "email_verifications_id": { + "name": "email_verifications_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "email_verifications_token_hash_unique": { + "name": "email_verifications_token_hash_unique", + "columns": [ + "token_hash" + ] + } + }, + "checkConstraint": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id_hash": { + "name": "id_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_type": { + "name": "actor_type", + "type": "enum('user','admin')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "traq_id": { + "name": "traq_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sessions_id": { + "name": "sessions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "sessions_id_hash_unique": { + "name": "sessions_id_hash_unique", + "columns": [ + "id_hash" + ] + } + }, + "checkConstraint": {} + }, "users": { "name": "users", "columns": { @@ -14,9 +176,9 @@ "notNull": true, "autoincrement": false }, - "name": { - "name": "name", - "type": "varchar(255)", + "mail_hash": { + "name": "mail_hash", + "type": "varchar(64)", "primaryKey": false, "notNull": true, "autoincrement": false @@ -28,6 +190,15 @@ "notNull": true, "autoincrement": false, "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" } }, "indexes": {}, @@ -40,7 +211,14 @@ ] } }, - "uniqueConstraints": {}, + "uniqueConstraints": { + "users_mail_hash_unique": { + "name": "users_mail_hash_unique", + "columns": [ + "mail_hash" + ] + } + }, "checkConstraint": {} } }, diff --git a/packages/db/drizzle/meta/0001_snapshot.json b/packages/db/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..c5a5d69 --- /dev/null +++ b/packages/db/drizzle/meta/0001_snapshot.json @@ -0,0 +1,295 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "cc721e21-f36d-4104-9dd4-742376c7ae6c", + "prevId": "f9930cfd-55d9-4df4-b002-3f8480d1bf74", + "tables": { + "email_verifications": { + "name": "email_verifications", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mail_hash": { + "name": "mail_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect": { + "name": "redirect", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "email_verifications_id": { + "name": "email_verifications_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "email_verifications_token_hash_unique": { + "name": "email_verifications_token_hash_unique", + "columns": [ + "token_hash" + ] + } + }, + "checkConstraint": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id_hash": { + "name": "id_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_type": { + "name": "actor_type", + "type": "enum('user','admin')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "traq_id": { + "name": "traq_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sessions_id": { + "name": "sessions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "sessions_id_hash_unique": { + "name": "sessions_id_hash_unique", + "columns": [ + "id_hash" + ] + } + }, + "checkConstraint": {} + }, + "stripe_events": { + "name": "stripe_events", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "received_at": { + "name": "received_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "stripe_events_id": { + "name": "stripe_events_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "stripe_events_event_id_unique": { + "name": "stripe_events_event_id_unique", + "columns": [ + "event_id" + ] + } + }, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mail_hash": { + "name": "mail_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "users_mail_hash_unique": { + "name": "users_mail_hash_unique", + "columns": [ + "mail_hash" + ] + } + }, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index a1bddde..2d955ac 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -5,8 +5,15 @@ { "idx": 0, "version": "5", - "when": 1781854772858, - "tag": "0000_giant_selene", + "when": 1781874606502, + "tag": "0000_black_khan", + "breakpoints": true + }, + { + "idx": 1, + "version": "5", + "when": 1781877502412, + "tag": "0001_naive_stryfe", "breakpoints": true } ] diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index a8b88d8..17f5cdf 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -1,15 +1,67 @@ -import { mysqlTable, varchar, timestamp } from 'drizzle-orm/mysql-core' +import { mysqlTable, varchar, timestamp, mysqlEnum } from 'drizzle-orm/mysql-core' /** - * Schema placeholder. + * Auth / identity foundation (OpenSpec change: add-auth-foundation). * - * Real tables are added per feature through OpenSpec change proposals - * (`/opsx:propose`). The `users` table below only exists so that the - * Drizzle/migration tooling has something to generate against and the - * end-to-end wiring can be verified. Replace/extend it as specs land. + * `users` is the service-internal correspondence table ("対応表"): a person is + * identified solely by `mail_hash` (HMAC-SHA256 of their normalized isct email). + * Plaintext email is never stored here. Stripe-side columns (customer / connected + * account) are added by later changes. */ export const users = mysqlTable('users', { id: varchar('id', { length: 36 }).primaryKey(), - name: varchar('name', { length: 255 }).notNull(), + // hex HMAC-SHA256 → 64 chars. Unique: one row per person. + mailHash: varchar('mail_hash', { length: 64 }).notNull().unique(), + // Stripe Customer reference (non-PII). Added by add-membership-collection: the + // collection flow does a get-or-create against Stripe and persists the id here + // so subsequent invoices reuse the same Customer. Plaintext email is still + // never stored. (identity spec: §Stripe 参照は保持してよい) + stripeCustomerId: varchar('stripe_customer_id', { length: 255 }), createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow().onUpdateNow(), +}) + +/** + * Server-side sessions. The opaque session id lives in the `__Host-checkin_session` + * cookie; only its hash is stored here so a DB leak does not expose live cookies. + * A session represents exactly one actor: a `user` (isct member, by `user_id`) or + * an `admin` (accountant, by `traq_id`). + */ +export const sessions = mysqlTable('sessions', { + id: varchar('id', { length: 36 }).primaryKey(), + idHash: varchar('id_hash', { length: 64 }).notNull().unique(), + actorType: mysqlEnum('actor_type', ['user', 'admin']).notNull(), + userId: varchar('user_id', { length: 36 }).references(() => users.id), + traqId: varchar('traq_id', { length: 255 }), + expiresAt: timestamp('expires_at').notNull(), + createdAt: timestamp('created_at').notNull().defaultNow(), +}) + +/** + * Single-use, time-limited isct email magic-link tokens. Only the token hash is + * stored. On successful confirmation we get-or-create the `users` row for + * `mail_hash` and establish a user session, then navigate to `redirect`. + */ +export const emailVerifications = mysqlTable('email_verifications', { + id: varchar('id', { length: 36 }).primaryKey(), + tokenHash: varchar('token_hash', { length: 64 }).notNull().unique(), + mailHash: varchar('mail_hash', { length: 64 }).notNull(), + redirect: varchar('redirect', { length: 1024 }), + expiresAt: timestamp('expires_at').notNull(), + consumedAt: timestamp('consumed_at'), + createdAt: timestamp('created_at').notNull().defaultNow(), +}) + +/** + * Idempotency ledger for Stripe webhooks (OpenSpec change: add-membership-collection). + * Stripe may deliver the same event multiple times, so each `event_id` is inserted + * once (unique); a duplicate insert means the event was already handled and its + * side effect (accountant notification) must not fire again. + * (payment-webhook spec: §event id による冪等処理) + */ +export const stripeEvents = mysqlTable('stripe_events', { + id: varchar('id', { length: 36 }).primaryKey(), + eventId: varchar('event_id', { length: 255 }).notNull().unique(), + type: varchar('type', { length: 255 }).notNull(), + receivedAt: timestamp('received_at').notNull().defaultNow(), }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6b2910c..ed15559 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: devDependencies: + '@fission-ai/openspec': + specifier: 1.4.1 + version: 1.4.1(@types/node@26.0.0) '@nuxt/eslint': specifier: ^1.16.0 version: 1.16.0(@typescript-eslint/utils@8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(@vue/compiler-sfc@3.5.38)(crossws@0.4.6(srvx@0.11.16))(eslint@9.39.4(jiti@2.7.0))(magicast@0.5.3)(oxc-parser@0.133.0)(typescript@5.9.3)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) @@ -17,6 +20,9 @@ importers: typescript: specifier: ^5.9.0 version: 5.9.3 + vitest: + specifier: ^2.1.9 + version: 2.1.9(@types/node@26.0.0)(terser@5.48.0) apps/web: dependencies: @@ -57,6 +63,15 @@ importers: '@orpc/server': specifier: ^1.14.0 version: 1.14.6(crossws@0.4.6(srvx@0.11.16))(ws@8.21.0) + drizzle-orm: + specifier: ^0.45.0 + version: 0.45.2(mysql2@3.22.5(@types/node@22.19.21)) + stripe: + specifier: ^18.0.0 + version: 18.5.0(@types/node@22.19.21) + zod: + specifier: ^3.24.0 + version: 3.25.76 devDependencies: '@types/node': specifier: ^22.0.0 @@ -302,6 +317,12 @@ packages: resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} deprecated: 'Merged into tsx: https://tsx.is' + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} @@ -326,6 +347,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.25.12': resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} engines: {node: '>=18'} @@ -350,6 +377,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.25.12': resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} engines: {node: '>=18'} @@ -374,6 +407,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.25.12': resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} engines: {node: '>=18'} @@ -398,6 +437,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.25.12': resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} engines: {node: '>=18'} @@ -422,6 +467,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.25.12': resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} engines: {node: '>=18'} @@ -446,6 +497,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.25.12': resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} engines: {node: '>=18'} @@ -470,6 +527,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.25.12': resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} engines: {node: '>=18'} @@ -494,6 +557,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.25.12': resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} engines: {node: '>=18'} @@ -518,6 +587,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.25.12': resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} engines: {node: '>=18'} @@ -542,6 +617,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.25.12': resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} engines: {node: '>=18'} @@ -566,6 +647,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.25.12': resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} engines: {node: '>=18'} @@ -590,6 +677,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.25.12': resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} engines: {node: '>=18'} @@ -614,6 +707,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.25.12': resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} engines: {node: '>=18'} @@ -638,6 +737,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.25.12': resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} engines: {node: '>=18'} @@ -662,6 +767,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.25.12': resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} engines: {node: '>=18'} @@ -686,6 +797,12 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.25.12': resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} engines: {node: '>=18'} @@ -728,6 +845,12 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.25.12': resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} engines: {node: '>=18'} @@ -770,6 +893,12 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.25.12': resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} engines: {node: '>=18'} @@ -812,6 +941,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.25.12': resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} engines: {node: '>=18'} @@ -836,6 +971,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.25.12': resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} engines: {node: '>=18'} @@ -860,6 +1001,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.25.12': resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} engines: {node: '>=18'} @@ -884,6 +1031,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.25.12': resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} engines: {node: '>=18'} @@ -973,6 +1126,11 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@fission-ai/openspec@1.4.1': + resolution: {integrity: sha512-C/NQsybgjqtSr29QAv4NbO1bZTgozu8GAUSiONthenZ5W4TQ2bvyj8LVmr76qb90iGeTLEFkcdnI+iYYaFLKyA==} + engines: {node: '>=20.19.0'} + hasBin: true + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -993,6 +1151,140 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@inquirer/ansi@1.0.2': + resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} + engines: {node: '>=18'} + + '@inquirer/checkbox@4.3.2': + resolution: {integrity: sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/confirm@5.1.21': + resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@10.3.2': + resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/editor@4.2.23': + resolution: {integrity: sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/expand@4.0.23': + resolution: {integrity: sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/external-editor@1.0.3': + resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@1.0.15': + resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} + engines: {node: '>=18'} + + '@inquirer/input@4.3.1': + resolution: {integrity: sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/number@3.0.23': + resolution: {integrity: sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/password@4.0.23': + resolution: {integrity: sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@7.10.1': + resolution: {integrity: sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/rawlist@4.1.11': + resolution: {integrity: sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/search@3.2.2': + resolution: {integrity: sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/select@4.4.2': + resolution: {integrity: sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/type@3.0.10': + resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@ioredis/commands@1.10.0': resolution: {integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==} @@ -1839,6 +2131,12 @@ packages: '@poppinss/exception@1.2.3': resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + '@posthog/core@1.35.2': + resolution: {integrity: sha512-aV6nkkKCF//NnR9SyQD00+sPg0A4v1m3Xk/fcsnYs+z3qCRXgjwOhfQX2lceAKWlru3o5l9Yn/480GovLOnqKA==} + + '@posthog/types@1.390.1': + resolution: {integrity: sha512-GPyZY8eOa/c/EKFLW0Aa1ECVysa1TFLenGPgYbRz/PX/nrzd1IiQPlHAYLkeifE6oBVxHiv5AwkTyzX/fdOYSA==} + '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} @@ -2314,6 +2612,35 @@ packages: vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 vue: ^3.2.25 + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + '@volar/language-core@2.4.28': resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} @@ -2466,6 +2793,10 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + ast-kit@2.2.0: resolution: {integrity: sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==} engines: {node: '>=20.19.0'} @@ -2620,6 +2951,14 @@ packages: resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} engines: {node: '>=20.19.0'} + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -2630,13 +2969,28 @@ packages: caniuse-lite@1.0.30001799: resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + change-case@5.4.4: resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} + chardet@2.1.1: + resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + chokidar@5.0.0: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} @@ -2655,6 +3009,18 @@ packages: citty@0.2.2: resolution: {integrity: sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==} + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + cliui@9.0.1: resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} engines: {node: '>=20'} @@ -2674,6 +3040,10 @@ packages: resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} engines: {node: '>=16'} + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -2831,6 +3201,10 @@ packages: supports-color: optional: true + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -3004,6 +3378,10 @@ packages: sqlite3: optional: true + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + duplexer@0.1.2: resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} @@ -3043,18 +3421,34 @@ packages: errx@0.1.0: resolution: {integrity: sha512-fZmsRiDNv07K6s2KkKFTiD2aIvECa7++PKyD5NC32tpRw46qZA3sOz+aM+/V9V0GDHxVTKLziveV4JhzBHDp9Q==} + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + es-errors@1.3.0: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-module-lexer@2.1.0: resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + esbuild@0.18.20: resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} engines: {node: '>=12'} hasBin: true + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + esbuild@0.25.12: resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} engines: {node: '>=18'} @@ -3248,6 +3642,10 @@ packages: resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} engines: {node: '>=16.17'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + exsolve@1.0.8: resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} @@ -3363,9 +3761,17 @@ packages: resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} engines: {node: '>=18'} + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + get-port-please@3.2.0: resolution: {integrity: sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==} + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + get-stream@8.0.1: resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} engines: {node: '>=16'} @@ -3410,6 +3816,10 @@ packages: resolution: {integrity: sha512-QrJia2qDf5BB/V6HYlDTs0I0lBahyjLzpGQg3KT7FnCdTonAyPy2RtY802m2k4ALx6Dp752f82WsOczEVr3l6Q==} engines: {node: '>=20'} + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -3434,6 +3844,10 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + hasown@2.0.4: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} @@ -3551,6 +3965,10 @@ packages: resolution: {integrity: sha512-K55T22lfpQ63N4KEN57jZUAaAYqYHEe8veb/TycJRk9DdSCLLcovXz/mL6mOnhQaZsQGwPhuFopdQIlqGSEjiQ==} engines: {node: '>=18'} + is-interactive@2.0.0: + resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} + engines: {node: '>=12'} + is-module@1.0.0: resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==} @@ -3576,6 +3994,14 @@ packages: resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + is-unicode-supported@1.3.0: + resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==} + engines: {node: '>=12'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + is-wsl@3.1.1: resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} engines: {node: '>=16'} @@ -3684,9 +4110,16 @@ packages: lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + log-symbols@6.0.0: + resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} + engines: {node: '>=18'} + long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -3714,6 +4147,10 @@ packages: magicast@0.5.3: resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + mdn-data@2.0.28: resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} @@ -3748,6 +4185,10 @@ packages: resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} engines: {node: '>=12'} + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -3787,6 +4228,10 @@ packages: muggle-string@0.4.1: resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + mute-stream@2.0.0: + resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} + engines: {node: ^18.17.0 || >=20.5.0} + mysql2@3.22.5: resolution: {integrity: sha512-95uZ2TrPWAZdwpB3vvvDbmEMcNG8yIeNCyu6GUcr/QnWEE/wXm7+mhOCsdQfWQDTV7qYT/PDUZ4U4UPP4AsXqQ==} engines: {node: '>= 8.0'} @@ -3897,6 +4342,10 @@ packages: object-deep-merge@2.0.1: resolution: {integrity: sha512-aKttDKcU3pyZqKcCkDhsMn70WmZFG2JGDQLP9EcLyTSIFQRCPWLAmBZRLJnrVUrhPG1jETEEbfdgbNtJf1LyMg==} + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + obug@2.1.3: resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} engines: {node: '>=12.20.0'} @@ -3922,6 +4371,10 @@ packages: resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} engines: {node: '>=12'} + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + open@11.0.0: resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} engines: {node: '>=20'} @@ -3933,6 +4386,10 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + ora@8.2.0: + resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} + engines: {node: '>=18'} + oxc-minify@0.133.0: resolution: {integrity: sha512-6bNsYU+5WNIaNHB16zHnL24cUaJuKiPzUvjENoMale3+U8ZBMbGYgdgt//frx0ge7UcgEGIpqtukGGNPT0kxfQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4028,6 +4485,10 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + perfect-debounce@2.1.0: resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} @@ -4225,6 +4686,15 @@ packages: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} + posthog-node@5.38.1: + resolution: {integrity: sha512-qjcy8pGrgcycDjUOHv8xOPBJ/5PdjqmeB3LhwDt428LEwMgjLWjUdK6n+g0mjSXhLD9WO6NKFMZH85z4ukKM+w==} + engines: {node: ^20.20.0 || >=22.22.0} + peerDependencies: + rxjs: ^7.0.0 + peerDependenciesMeta: + rxjs: + optional: true + powershell-utils@0.1.0: resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} engines: {node: '>=20'} @@ -4251,6 +4721,10 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qs@6.15.2: + resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} + engines: {node: '>=0.6'} + quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} @@ -4329,6 +4803,10 @@ packages: engines: {node: '>= 0.4'} hasBin: true + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + retry@0.12.0: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} @@ -4428,6 +4906,25 @@ packages: resolution: {integrity: sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==} engines: {node: '>= 0.4'} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} @@ -4490,6 +4987,9 @@ packages: resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==} engines: {node: '>=12.0.0'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standard-as-callback@2.1.0: resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} @@ -4497,9 +4997,16 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + stdin-discarder@0.2.2: + resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} + engines: {node: '>=18'} + streamx@2.28.0: resolution: {integrity: sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==} @@ -4544,6 +5051,15 @@ packages: strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + stripe@18.5.0: + resolution: {integrity: sha512-Hp+wFiEQtCB0LlNgcFh5uVyKznpDjzyUZ+CNVEf+I3fhlYvh7rZruIg+jOwzJRCpy0ZTPMjlzm7J2/M2N6d+DA==} + engines: {node: '>=12.*'} + peerDependencies: + '@types/node': '>=12.x.x' + peerDependenciesMeta: + '@types/node': + optional: true + structured-clone-es@2.0.0: resolution: {integrity: sha512-5UuAHmBLXYPCl22xWJrFuGmIhBKQzxISPVz6E7nmTmTcAOpUzlbjKJsRrCE4vADmMQ0dzeCnlWn9XufnAGf76Q==} @@ -4595,10 +5111,16 @@ packages: tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinyclip@0.1.14: resolution: {integrity: sha512-F1oWdz8tjT17qe1d5JgDK6z03WGOhYYAN0lK3/D/fzNiy93xswLLEw7pk+3g05onhAy6Bsc6PLNUGhdgVjemMQ==} engines: {node: ^16.14.0 || >= 17.3.0} + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyexec@1.2.4: resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} engines: {node: '>=18'} @@ -4607,6 +5129,18 @@ packages: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -4824,6 +5358,11 @@ packages: peerDependencies: vite: ^2.6.0 || ^3.0.0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0 || ^8.0.0 + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + vite-node@5.3.0: resolution: {integrity: sha512-8f20COPYJujc3OKPX6OuyBy3ZIv2det4eRRU4GY1y2MjbeGSUmPjedxg1b72KnTagCofwvZ65ThzjxDW2AtQFQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4876,6 +5415,37 @@ packages: vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0 vue: ^3.5.0 + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + vite@7.3.5: resolution: {integrity: sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4916,6 +5486,31 @@ packages: yaml: optional: true + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vscode-uri@3.1.0: resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} @@ -4982,10 +5577,19 @@ packages: engines: {node: ^20.17.0 || >=22.9.0} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -5050,6 +5654,10 @@ packages: resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} engines: {node: '>=12.20'} + yoctocolors-cjs@2.1.3: + resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} + engines: {node: '>=18'} + youch-core@0.3.3: resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} @@ -5060,6 +5668,12 @@ packages: resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} engines: {node: '>= 14'} + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + snapshots: '@antfu/install-pkg@1.1.0': @@ -5334,6 +5948,9 @@ snapshots: '@esbuild-kit/core-utils': 3.3.2 get-tsconfig: 4.14.0 + '@esbuild/aix-ppc64@0.21.5': + optional: true + '@esbuild/aix-ppc64@0.25.12': optional: true @@ -5346,6 +5963,9 @@ snapshots: '@esbuild/android-arm64@0.18.20': optional: true + '@esbuild/android-arm64@0.21.5': + optional: true + '@esbuild/android-arm64@0.25.12': optional: true @@ -5358,6 +5978,9 @@ snapshots: '@esbuild/android-arm@0.18.20': optional: true + '@esbuild/android-arm@0.21.5': + optional: true + '@esbuild/android-arm@0.25.12': optional: true @@ -5370,6 +5993,9 @@ snapshots: '@esbuild/android-x64@0.18.20': optional: true + '@esbuild/android-x64@0.21.5': + optional: true + '@esbuild/android-x64@0.25.12': optional: true @@ -5382,6 +6008,9 @@ snapshots: '@esbuild/darwin-arm64@0.18.20': optional: true + '@esbuild/darwin-arm64@0.21.5': + optional: true + '@esbuild/darwin-arm64@0.25.12': optional: true @@ -5394,6 +6023,9 @@ snapshots: '@esbuild/darwin-x64@0.18.20': optional: true + '@esbuild/darwin-x64@0.21.5': + optional: true + '@esbuild/darwin-x64@0.25.12': optional: true @@ -5406,6 +6038,9 @@ snapshots: '@esbuild/freebsd-arm64@0.18.20': optional: true + '@esbuild/freebsd-arm64@0.21.5': + optional: true + '@esbuild/freebsd-arm64@0.25.12': optional: true @@ -5418,6 +6053,9 @@ snapshots: '@esbuild/freebsd-x64@0.18.20': optional: true + '@esbuild/freebsd-x64@0.21.5': + optional: true + '@esbuild/freebsd-x64@0.25.12': optional: true @@ -5430,6 +6068,9 @@ snapshots: '@esbuild/linux-arm64@0.18.20': optional: true + '@esbuild/linux-arm64@0.21.5': + optional: true + '@esbuild/linux-arm64@0.25.12': optional: true @@ -5442,6 +6083,9 @@ snapshots: '@esbuild/linux-arm@0.18.20': optional: true + '@esbuild/linux-arm@0.21.5': + optional: true + '@esbuild/linux-arm@0.25.12': optional: true @@ -5454,6 +6098,9 @@ snapshots: '@esbuild/linux-ia32@0.18.20': optional: true + '@esbuild/linux-ia32@0.21.5': + optional: true + '@esbuild/linux-ia32@0.25.12': optional: true @@ -5466,6 +6113,9 @@ snapshots: '@esbuild/linux-loong64@0.18.20': optional: true + '@esbuild/linux-loong64@0.21.5': + optional: true + '@esbuild/linux-loong64@0.25.12': optional: true @@ -5478,6 +6128,9 @@ snapshots: '@esbuild/linux-mips64el@0.18.20': optional: true + '@esbuild/linux-mips64el@0.21.5': + optional: true + '@esbuild/linux-mips64el@0.25.12': optional: true @@ -5490,6 +6143,9 @@ snapshots: '@esbuild/linux-ppc64@0.18.20': optional: true + '@esbuild/linux-ppc64@0.21.5': + optional: true + '@esbuild/linux-ppc64@0.25.12': optional: true @@ -5502,6 +6158,9 @@ snapshots: '@esbuild/linux-riscv64@0.18.20': optional: true + '@esbuild/linux-riscv64@0.21.5': + optional: true + '@esbuild/linux-riscv64@0.25.12': optional: true @@ -5514,6 +6173,9 @@ snapshots: '@esbuild/linux-s390x@0.18.20': optional: true + '@esbuild/linux-s390x@0.21.5': + optional: true + '@esbuild/linux-s390x@0.25.12': optional: true @@ -5526,6 +6188,9 @@ snapshots: '@esbuild/linux-x64@0.18.20': optional: true + '@esbuild/linux-x64@0.21.5': + optional: true + '@esbuild/linux-x64@0.25.12': optional: true @@ -5547,6 +6212,9 @@ snapshots: '@esbuild/netbsd-x64@0.18.20': optional: true + '@esbuild/netbsd-x64@0.21.5': + optional: true + '@esbuild/netbsd-x64@0.25.12': optional: true @@ -5568,6 +6236,9 @@ snapshots: '@esbuild/openbsd-x64@0.18.20': optional: true + '@esbuild/openbsd-x64@0.21.5': + optional: true + '@esbuild/openbsd-x64@0.25.12': optional: true @@ -5589,6 +6260,9 @@ snapshots: '@esbuild/sunos-x64@0.18.20': optional: true + '@esbuild/sunos-x64@0.21.5': + optional: true + '@esbuild/sunos-x64@0.25.12': optional: true @@ -5601,6 +6275,9 @@ snapshots: '@esbuild/win32-arm64@0.18.20': optional: true + '@esbuild/win32-arm64@0.21.5': + optional: true + '@esbuild/win32-arm64@0.25.12': optional: true @@ -5613,6 +6290,9 @@ snapshots: '@esbuild/win32-ia32@0.18.20': optional: true + '@esbuild/win32-ia32@0.21.5': + optional: true + '@esbuild/win32-ia32@0.25.12': optional: true @@ -5625,6 +6305,9 @@ snapshots: '@esbuild/win32-x64@0.18.20': optional: true + '@esbuild/win32-x64@0.21.5': + optional: true + '@esbuild/win32-x64@0.25.12': optional: true @@ -5714,6 +6397,22 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@fission-ai/openspec@1.4.1(@types/node@26.0.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@26.0.0) + '@inquirer/prompts': 7.10.1(@types/node@26.0.0) + chalk: 5.6.2 + commander: 14.0.3 + cross-spawn: 7.0.6 + fast-glob: 3.3.3 + ora: 8.2.0 + posthog-node: 5.38.1 + yaml: 2.9.0 + zod: 4.4.3 + transitivePeerDependencies: + - '@types/node' + - rxjs + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -5730,6 +6429,131 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@inquirer/ansi@1.0.2': {} + + '@inquirer/checkbox@4.3.2(@types/node@26.0.0)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@26.0.0) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@26.0.0) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 26.0.0 + + '@inquirer/confirm@5.1.21(@types/node@26.0.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@26.0.0) + '@inquirer/type': 3.0.10(@types/node@26.0.0) + optionalDependencies: + '@types/node': 26.0.0 + + '@inquirer/core@10.3.2(@types/node@26.0.0)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@26.0.0) + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 26.0.0 + + '@inquirer/editor@4.2.23(@types/node@26.0.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@26.0.0) + '@inquirer/external-editor': 1.0.3(@types/node@26.0.0) + '@inquirer/type': 3.0.10(@types/node@26.0.0) + optionalDependencies: + '@types/node': 26.0.0 + + '@inquirer/expand@4.0.23(@types/node@26.0.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@26.0.0) + '@inquirer/type': 3.0.10(@types/node@26.0.0) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 26.0.0 + + '@inquirer/external-editor@1.0.3(@types/node@26.0.0)': + dependencies: + chardet: 2.1.1 + iconv-lite: 0.7.2 + optionalDependencies: + '@types/node': 26.0.0 + + '@inquirer/figures@1.0.15': {} + + '@inquirer/input@4.3.1(@types/node@26.0.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@26.0.0) + '@inquirer/type': 3.0.10(@types/node@26.0.0) + optionalDependencies: + '@types/node': 26.0.0 + + '@inquirer/number@3.0.23(@types/node@26.0.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@26.0.0) + '@inquirer/type': 3.0.10(@types/node@26.0.0) + optionalDependencies: + '@types/node': 26.0.0 + + '@inquirer/password@4.0.23(@types/node@26.0.0)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@26.0.0) + '@inquirer/type': 3.0.10(@types/node@26.0.0) + optionalDependencies: + '@types/node': 26.0.0 + + '@inquirer/prompts@7.10.1(@types/node@26.0.0)': + dependencies: + '@inquirer/checkbox': 4.3.2(@types/node@26.0.0) + '@inquirer/confirm': 5.1.21(@types/node@26.0.0) + '@inquirer/editor': 4.2.23(@types/node@26.0.0) + '@inquirer/expand': 4.0.23(@types/node@26.0.0) + '@inquirer/input': 4.3.1(@types/node@26.0.0) + '@inquirer/number': 3.0.23(@types/node@26.0.0) + '@inquirer/password': 4.0.23(@types/node@26.0.0) + '@inquirer/rawlist': 4.1.11(@types/node@26.0.0) + '@inquirer/search': 3.2.2(@types/node@26.0.0) + '@inquirer/select': 4.4.2(@types/node@26.0.0) + optionalDependencies: + '@types/node': 26.0.0 + + '@inquirer/rawlist@4.1.11(@types/node@26.0.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@26.0.0) + '@inquirer/type': 3.0.10(@types/node@26.0.0) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 26.0.0 + + '@inquirer/search@3.2.2(@types/node@26.0.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@26.0.0) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@26.0.0) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 26.0.0 + + '@inquirer/select@4.4.2(@types/node@26.0.0)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@26.0.0) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@26.0.0) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 26.0.0 + + '@inquirer/type@3.0.10(@types/node@26.0.0)': + optionalDependencies: + '@types/node': 26.0.0 + '@ioredis/commands@1.10.0': {} '@isaacs/cliui@8.0.2': @@ -6585,6 +7409,12 @@ snapshots: '@poppinss/exception@1.2.3': {} + '@posthog/core@1.35.2': + dependencies: + '@posthog/types': 1.390.1 + + '@posthog/types@1.390.1': {} + '@rolldown/pluginutils@1.0.1': {} '@rollup/plugin-alias@6.0.0(rollup@4.62.0)': @@ -6983,6 +7813,46 @@ snapshots: vite: 7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) vue: 3.5.38(typescript@5.9.3) + '@vitest/expect@2.1.9': + dependencies: + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 + + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@26.0.0)(terser@5.48.0))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@26.0.0)(terser@5.48.0) + + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/runner@2.1.9': + dependencies: + '@vitest/utils': 2.1.9 + pathe: 1.1.2 + + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 + + '@vitest/spy@2.1.9': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 + '@volar/language-core@2.4.28': dependencies: '@volar/source-map': 2.4.28 @@ -7189,6 +8059,8 @@ snapshots: argparse@2.0.1: {} + assertion-error@2.0.1: {} + ast-kit@2.2.0: dependencies: '@babel/parser': 7.29.7 @@ -7329,6 +8201,16 @@ snapshots: cac@7.0.0: {} + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + callsites@3.1.0: {} caniuse-api@4.0.0: @@ -7338,13 +8220,27 @@ snapshots: caniuse-lite@1.0.30001799: {} + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 supports-color: 7.2.0 + chalk@5.6.2: {} + change-case@5.4.4: {} + chardet@2.1.1: {} + + check-error@2.1.3: {} + chokidar@5.0.0: dependencies: readdirp: 5.0.0 @@ -7359,6 +8255,14 @@ snapshots: citty@0.2.2: {} + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-spinners@2.9.2: {} + + cli-width@4.1.0: {} + cliui@9.0.1: dependencies: string-width: 7.2.0 @@ -7375,6 +8279,8 @@ snapshots: commander@11.1.0: {} + commander@14.0.3: {} + commander@2.20.3: {} comment-parser@1.4.7: {} @@ -7518,6 +8424,8 @@ snapshots: dependencies: ms: 2.1.3 + deep-eql@5.0.2: {} + deep-is@0.1.4: {} deepmerge@4.3.1: {} @@ -7604,6 +8512,12 @@ snapshots: mysql2: 3.22.5(@types/node@26.0.0) optional: true + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + duplexer@0.1.2: {} eastasianwidth@0.2.0: {} @@ -7628,10 +8542,18 @@ snapshots: errx@0.1.0: {} + es-define-property@1.0.1: {} + es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + es-module-lexer@2.1.0: {} + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + esbuild@0.18.20: optionalDependencies: '@esbuild/android-arm': 0.18.20 @@ -7657,6 +8579,32 @@ snapshots: '@esbuild/win32-ia32': 0.18.20 '@esbuild/win32-x64': 0.18.20 + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + esbuild@0.25.12: optionalDependencies: '@esbuild/aix-ppc64': 0.25.12 @@ -7983,6 +8931,8 @@ snapshots: signal-exit: 4.1.0 strip-final-newline: 3.0.0 + expect-type@1.3.0: {} + exsolve@1.0.8: {} fast-deep-equal@3.1.3: {} @@ -8078,8 +9028,26 @@ snapshots: get-east-asian-width@1.6.0: {} + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + get-port-please@3.2.0: {} + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + get-stream@8.0.1: {} get-tsconfig@4.14.0: @@ -8128,6 +9096,8 @@ snapshots: slash: 5.1.0 unicorn-magic: 0.4.0 + gopd@1.2.0: {} + graceful-fs@4.2.11: {} gzip-size@7.0.0: @@ -8155,6 +9125,8 @@ snapshots: has-flag@4.0.0: {} + has-symbols@1.1.0: {} + hasown@2.0.4: dependencies: function-bind: 1.1.2 @@ -8262,6 +9234,8 @@ snapshots: global-directory: 4.0.1 is-path-inside: 4.0.0 + is-interactive@2.0.0: {} + is-module@1.0.0: {} is-number@7.0.0: {} @@ -8278,6 +9252,10 @@ snapshots: is-stream@3.0.0: {} + is-unicode-supported@1.3.0: {} + + is-unicode-supported@2.1.0: {} + is-wsl@3.1.1: dependencies: is-inside-container: 1.0.0 @@ -8388,8 +9366,15 @@ snapshots: lodash@4.18.1: {} + log-symbols@6.0.0: + dependencies: + chalk: 5.6.2 + is-unicode-supported: 1.3.0 + long@5.3.2: {} + loupe@3.2.1: {} + lru-cache@10.4.3: {} lru-cache@11.5.1: {} @@ -8421,6 +9406,8 @@ snapshots: '@babel/types': 7.29.7 source-map-js: 1.2.1 + math-intrinsics@1.1.0: {} + mdn-data@2.0.28: {} mdn-data@2.27.1: {} @@ -8444,6 +9431,8 @@ snapshots: mimic-fn@4.0.0: {} + mimic-function@5.0.1: {} + minimatch@10.2.5: dependencies: brace-expansion: 5.0.6 @@ -8481,6 +9470,8 @@ snapshots: muggle-string@0.4.1: {} + mute-stream@2.0.0: {} + mysql2@3.22.5(@types/node@22.19.21): dependencies: '@types/node': 22.19.21 @@ -8801,6 +9792,8 @@ snapshots: object-deep-merge@2.0.1: {} + object-inspect@1.13.4: {} + obug@2.1.3: {} ofetch@1.5.1: @@ -8823,6 +9816,10 @@ snapshots: dependencies: mimic-fn: 4.0.0 + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + open@11.0.0: dependencies: default-browser: 5.5.0 @@ -8843,6 +9840,18 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + ora@8.2.0: + dependencies: + chalk: 5.6.2 + cli-cursor: 5.0.0 + cli-spinners: 2.9.2 + is-interactive: 2.0.0 + is-unicode-supported: 2.1.0 + log-symbols: 6.0.0 + stdin-discarder: 0.2.2 + string-width: 7.2.0 + strip-ansi: 7.2.0 + oxc-minify@0.133.0: optionalDependencies: '@oxc-minify/binding-android-arm-eabi': 0.133.0 @@ -9001,6 +10010,8 @@ snapshots: pathe@2.0.3: {} + pathval@2.0.1: {} + perfect-debounce@2.1.0: {} picocolors@1.1.1: {} @@ -9187,6 +10198,10 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + posthog-node@5.38.1: + dependencies: + '@posthog/core': 1.35.2 + powershell-utils@0.1.0: {} prelude-ls@1.2.1: {} @@ -9205,6 +10220,10 @@ snapshots: punycode@2.3.1: {} + qs@6.15.2: + dependencies: + side-channel: 1.1.1 + quansync@0.2.11: {} queue-microtask@1.2.3: {} @@ -9280,6 +10299,11 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + retry@0.12.0: {} reusify@1.1.0: {} @@ -9395,6 +10419,36 @@ snapshots: shell-quote@1.8.4: {} + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + siginfo@2.0.0: {} + signal-exit@3.0.7: {} signal-exit@4.1.0: {} @@ -9447,12 +10501,18 @@ snapshots: stable-hash-x@0.2.0: {} + stackback@0.0.2: {} + standard-as-callback@2.1.0: {} statuses@2.0.2: {} + std-env@3.10.0: {} + std-env@4.1.0: {} + stdin-discarder@0.2.2: {} + streamx@2.28.0: dependencies: events-universal: 1.0.1 @@ -9506,6 +10566,12 @@ snapshots: dependencies: js-tokens: 9.0.1 + stripe@18.5.0(@types/node@22.19.21): + dependencies: + qs: 6.15.2 + optionalDependencies: + '@types/node': 22.19.21 + structured-clone-es@2.0.0: {} stylehacks@8.0.1(postcss@8.5.15): @@ -9575,8 +10641,12 @@ snapshots: tiny-invariant@1.3.3: {} + tinybench@2.9.0: {} + tinyclip@0.1.14: {} + tinyexec@0.3.2: {} + tinyexec@1.2.4: {} tinyglobby@0.2.17: @@ -9584,6 +10654,12 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinypool@1.1.1: {} + + tinyrainbow@1.2.0: {} + + tinyspy@3.0.2: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -9788,6 +10864,24 @@ snapshots: dependencies: vite: 7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + vite-node@2.1.9(@types/node@26.0.0)(terser@5.48.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21(@types/node@26.0.0)(terser@5.48.0) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vite-node@5.3.0(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0): dependencies: cac: 6.7.14 @@ -9849,6 +10943,16 @@ snapshots: vite: 7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) vue: 3.5.38(typescript@5.9.3) + vite@5.4.21(@types/node@26.0.0)(terser@5.48.0): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.15 + rollup: 4.62.0 + optionalDependencies: + '@types/node': 26.0.0 + fsevents: 2.3.3 + terser: 5.48.0 + vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0): dependencies: esbuild: 0.27.7 @@ -9865,6 +10969,41 @@ snapshots: tsx: 4.22.4 yaml: 2.9.0 + vitest@2.1.9(@types/node@26.0.0)(terser@5.48.0): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@26.0.0)(terser@5.48.0)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21(@types/node@26.0.0)(terser@5.48.0) + vite-node: 2.1.9(@types/node@26.0.0)(terser@5.48.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 26.0.0 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vscode-uri@3.1.0: {} vue-bundle-renderer@2.2.0: @@ -9942,8 +11081,19 @@ snapshots: dependencies: isexe: 4.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -9994,6 +11144,8 @@ snapshots: yocto-queue@1.2.2: {} + yoctocolors-cjs@2.1.3: {} + youch-core@0.3.3: dependencies: '@poppinss/exception': 1.2.3 @@ -10012,3 +11164,7 @@ snapshots: archiver-utils: 5.0.2 compress-commons: 6.0.2 readable-stream: 4.7.0 + + zod@3.25.76: {} + + zod@4.4.3: {} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..f4f2a83 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + // Unit tests for pure domain logic. DB-backed behavior is covered by manual + // E2E against a running MariaDB (see the change's tasks). + include: ['packages/**/*.test.ts'], + }, +}) From e256b10fc0383e1c3dcb0ccf0ebd4f984488041c Mon Sep 17 00:00:00 2001 From: kaitoyama Date: Sat, 20 Jun 2026 00:00:08 +0900 Subject: [PATCH 02/30] =?UTF-8?q?feat:=20payment=20listing=20(=E4=BC=9A?= =?UTF-8?q?=E8=A8=88=E3=81=AE=E5=85=A5=E5=87=BA=E9=87=91=E4=B8=80=E8=A6=A7?= =?UTF-8?q?)=20+=20auth=20middleware=20procedures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit add-payment-listing (OpenSpec): accountant-only, read-only payments listing backed by Stripe (no DB writes). - Stripe adapter: listInvoices / listCheckoutSessions (lazy client, cursor pagination, customer + line-item price expansion). - Stripe-independent payments domain: PaymentRow/PaymentPage DTOs, invoice/checkout-session normalizers (null-safe), buildDashboardUrl (test/live), limit clamp + nextCursor. - oRPC payments.listInvoices / payments.listCheckoutSessions. Authorization hardening (Codex review): introduce userProc / adminProc oRPC middleware so the auth check runs BEFORE input validation (no input shape leak to unauthenticated callers); migrate membership.* procedures to them. Checkout-session rows now carry an explicit product/Price reference (or null) instead of {}. Codex-reviewed, fixes applied, delta spec synced to openspec/specs/ and archived. Gates green: lint, typecheck, build, test (48). Live Stripe E2E pending test-mode keys. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../.openspec.yaml | 2 + .../2026-06-19-add-payment-listing/design.md | 79 ++++++ .../proposal.md | 30 +++ .../specs/payment-listing/spec.md | 66 +++++ .../2026-06-19-add-payment-listing/tasks.md | 26 ++ openspec/specs/payment-listing/spec.md | 82 ++++++ packages/api/src/index.ts | 3 + packages/api/src/orpc.ts | 20 ++ packages/api/src/payments/dashboard.ts | 28 ++ packages/api/src/payments/index.ts | 4 + packages/api/src/payments/normalize.ts | 90 +++++++ packages/api/src/payments/pagination.ts | 31 +++ packages/api/src/payments/payments.test.ts | 240 ++++++++++++++++++ packages/api/src/payments/types.ts | 34 +++ packages/api/src/router.ts | 65 ++++- packages/api/src/stripe/index.ts | 1 + packages/api/src/stripe/listing.ts | 60 +++++ 17 files changed, 856 insertions(+), 5 deletions(-) create mode 100644 openspec/changes/archive/2026-06-19-add-payment-listing/.openspec.yaml create mode 100644 openspec/changes/archive/2026-06-19-add-payment-listing/design.md create mode 100644 openspec/changes/archive/2026-06-19-add-payment-listing/proposal.md create mode 100644 openspec/changes/archive/2026-06-19-add-payment-listing/specs/payment-listing/spec.md create mode 100644 openspec/changes/archive/2026-06-19-add-payment-listing/tasks.md create mode 100644 openspec/specs/payment-listing/spec.md create mode 100644 packages/api/src/payments/dashboard.ts create mode 100644 packages/api/src/payments/index.ts create mode 100644 packages/api/src/payments/normalize.ts create mode 100644 packages/api/src/payments/pagination.ts create mode 100644 packages/api/src/payments/payments.test.ts create mode 100644 packages/api/src/payments/types.ts create mode 100644 packages/api/src/stripe/listing.ts diff --git a/openspec/changes/archive/2026-06-19-add-payment-listing/.openspec.yaml b/openspec/changes/archive/2026-06-19-add-payment-listing/.openspec.yaml new file mode 100644 index 0000000..ff1fbc8 --- /dev/null +++ b/openspec/changes/archive/2026-06-19-add-payment-listing/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-19 diff --git a/openspec/changes/archive/2026-06-19-add-payment-listing/design.md b/openspec/changes/archive/2026-06-19-add-payment-listing/design.md new file mode 100644 index 0000000..bf65b2e --- /dev/null +++ b/openspec/changes/archive/2026-06-19-add-payment-listing/design.md @@ -0,0 +1,79 @@ +## Context + +[[add-membership-collection]] で Stripe アダプタ(client/customers/invoices/webhook)と会計認可(`requireAdmin`)が整った。本 change は会計向けの入出金一覧(design.md §5.2)を、Stripe を source として読み取り専用で提供する。 + +## Goals / Non-Goals + +**Goals:** +- 請求書由来(Invoices)・決済ページ由来(Checkout Sessions)の 2 系統の一覧 API。 +- `status` フィルタ + カーソルページネーション。 +- Stripe 型に依存しない行 DTO + Dashboard URL 生成。 +- 会計のみ(`requireAdmin`)の読み取り。 + +**Non-Goals:** +- `/payments` UI ページ(後続)。CSV エクスポート、検索の高度化。 +- 払い戻し・Jomon、入出金の DB 複製。 + +## Decisions + +### D1. アダプタ拡張(`packages/api/src/stripe/`) + +- `listing.ts`(または `invoices.ts`/新規)に薄いラッパを追加: + - `listInvoices({ status?, limit, startingAfter? })` → Stripe `invoices.list`(`expand: ['data.customer']` で名前取得)。 + - `listCheckoutSessions({ status?, limit, startingAfter? })` → Stripe `checkout.sessions.list`。 +- ドメイン DTO へ正規化(Stripe 型を外に出さない)。 + +### D2. 行 DTO(Stripe 非依存) + +``` +interface PaymentRow { + id: string + amount: number // 最小単位 + currency: string + createdAt: string // ISO + customer: { id: string | null, name?: string | null } + paymentStatus: string // invoice.status / session.payment_status + paymentId: string | null // payment_intent 等 + product: { priceId?: string | null, description?: string | null } + dashboardUrl: string +} +interface PaymentPage { items: PaymentRow[], hasMore: boolean, nextCursor: string | null } +``` + +`nextCursor` は最後の行の `id`(Stripe の `starting_after` に渡す)。 + +### D3. フィルタ・ページネーション + +- `limit` は 1..100 にクランプ(既定 20)。 +- `status`: Invoices は `draft|open|paid|uncollectible|void`、Checkout Sessions は `open|complete|expired`。値は zod enum で受け、Stripe にそのまま渡す。 +- `hasMore` は Stripe レスポンスの `has_more` を採用。`nextCursor = hasMore ? items.at(-1).id : null`。 + +### D4. Dashboard URL + +- モード判定: `STRIPE_SECRET_KEY` が `sk_test_`/`rk_test_` で始まれば test。`buildDashboardUrl(mode, kind, id)`: + - invoice → `https://dashboard.stripe.com/{test/}invoices/{id}` + - checkout session → `https://dashboard.stripe.com/{test/}checkout/sessions/{id}`(または payment intent へ) +- `mode` は `BillingConfig` から導出(既存の `stripeSecretKey` を利用)。純粋関数として `billing/` ではなくアダプタ近傍 or `payments` ドメインに置く(Stripe 型非依存)。 + +### D5. oRPC + +- `payments.listInvoices` / `payments.listCheckoutSessions`(`requireAdmin`、読み取りなので `assertCsrf` は不要)。 +- input zod: `{ status?: enum, limit?: number, startingAfter?: string }`。output: `PaymentPage`。 + +## Risks / Trade-offs + +- **Stripe レート制限/件数** → Mitigation: ページサイズ上限、カーソル方式。必要なら将来キャッシュ。 +- **customer 展開のコスト** → Mitigation: `expand` は名前程度に限定。重ければ id のみに縮小可能。 +- **モード判定をキー prefix に依存** → Mitigation: 明示 env(`STRIPE_MODE`)での上書きも許容してよい(実装で任意)。 +- **test mode キー未投入で実データ確認不可** → Mitigation: 正規化・URL 生成・パラメータ組み立てをユニットテスト(Stripe モック)。 + +## Migration Plan + +1. アダプタに list 系を追加 → `payments` ドメイン(DTO 正規化・URL 生成)→ oRPC 手続き。 +2. ユニットテスト(DTO 正規化、Dashboard URL の test/live、limit クランプ、nextCursor 算出)。 +3. test mode キー投入後、実データで一覧・フィルタ・ページングを確認。 + +## Open Questions + +- Checkout Sessions の「金額」は `amount_total`、支払い状況は `payment_status` を採用(実装で確定)。現状 Checkout 経由の決済が無ければ空一覧で問題ない。 +- `/payments` UI を本 change に含めるか別 change か(本 change は API のみ。UI は次で)。 diff --git a/openspec/changes/archive/2026-06-19-add-payment-listing/proposal.md b/openspec/changes/archive/2026-06-19-add-payment-listing/proposal.md new file mode 100644 index 0000000..6656cbd --- /dev/null +++ b/openspec/changes/archive/2026-06-19-add-payment-listing/proposal.md @@ -0,0 +1,30 @@ +## Why + +第一弾スコープ②「入出金の一覧(会計用)」。会計が入金状況を確認できるよう、Stripe を source として請求書由来・決済ページ由来の入出金を一覧する。対応表は自前 DB が正だが、入出金の事実は Stripe が source なので、一覧は Stripe から取得する(DB に複製しない)。[[add-membership-collection]] の Stripe アダプタ・[[add-auth-foundation]] の会計認可(`requireAdmin`)の上に乗る。 + +## What Changes + +- **入出金一覧 API(会計のみ)**: 2 系統を提供。 + - `payments.listInvoices`: 請求書由来(Stripe Invoices)。 + - `payments.listCheckoutSessions`: 決済ページ由来(Stripe Checkout Sessions)。 +- **フィルタ+ページネーション**: `status` 等で絞り込み、Stripe のカーソル方式(`limit` + `starting_after`)でページング。`hasMore` と次カーソルを返す。 +- **行の項目**: `id` / 金額(通貨つき)/ 日時 / customer 参照(id、可能なら名前)/ 支払い状況 / 支払いの id(Dashboard URL を生成可)/ 商品(Price)参照。 +- **Stripe アダプタ拡張**: list 系(invoices / checkout sessions)の薄いラッパを追加。Dashboard URL はモード(test/live)+オブジェクト id から生成。 +- **認可**: すべて会計(管理者)のみ(`requireAdmin`)。読み取りのみ(DB 変更なし、CSRF 不要)。 + +明確に**スコープ外**(後続): UI ページ `/payments` の作成(本 change は API。UI は後続 or 併せて別途)、払い戻し・Jomon 連携、CSV エクスポート。 + +## Capabilities + +### New Capabilities +- `payment-listing`: 会計向けの入出金一覧。請求書由来・決済ページ由来の 2 系統、`status` フィルタ、カーソルページネーション、行項目の正規化(Stripe → ドメイン DTO)、Dashboard URL 生成、`requireAdmin` 認可。 + +### Modified Capabilities + + +## Impact + +- **API(`packages/api`)**: Stripe アダプタに `listInvoices` / `listCheckoutSessions` を追加。oRPC `payments.listInvoices` / `payments.listCheckoutSessions`(`requireAdmin`、zod 入力)。Stripe オブジェクト → ドメイン DTO への正規化と Dashboard URL 生成(モードは `STRIPE_SECRET_KEY` の prefix から判定、または env)。 +- **Web/Nitro(`apps/web`)**: 追加配線は不要(oRPC 経由)。UI ページは本 change 範囲外。 +- **DB**: 変更なし(入出金の source は Stripe)。 +- **テスト/検証**: DTO 正規化・Dashboard URL 生成・カーソル/フィルタのパラメータ組み立てはユニットテスト(Stripe はモック)。実データ一覧は test mode キー投入後に確認。 diff --git a/openspec/changes/archive/2026-06-19-add-payment-listing/specs/payment-listing/spec.md b/openspec/changes/archive/2026-06-19-add-payment-listing/specs/payment-listing/spec.md new file mode 100644 index 0000000..25fbaff --- /dev/null +++ b/openspec/changes/archive/2026-06-19-add-payment-listing/specs/payment-listing/spec.md @@ -0,0 +1,66 @@ +## ADDED Requirements + +### Requirement: 会計のみがアクセスできる + +入出金一覧は会計(管理者)のみがアクセス可能 SHALL。利用者・未ログインのアクセスは拒否する MUST。 + +#### Scenario: 会計は一覧を取得できる + +- **WHEN** 会計セッションで入出金一覧を要求する +- **THEN** 一覧が返る + +#### Scenario: 非会計は拒否される + +- **WHEN** 利用者または未ログインで入出金一覧を要求する +- **THEN** 認可エラーで拒否される + +### Requirement: 請求書由来と決済ページ由来の 2 系統 + +システムは、入出金一覧を「請求書由来(Stripe Invoices)」と「決済ページ由来(Stripe Checkout Sessions)」の 2 系統で提供 SHALL。各系統は独立したエンドポイントで取得できる MUST。一覧は Stripe を source とし、自前 DB に複製しない。 + +#### Scenario: 請求書由来を取得 + +- **WHEN** 会計が請求書由来の一覧を要求する +- **THEN** Stripe Invoices に基づく行の一覧が返る + +#### Scenario: 決済ページ由来を取得 + +- **WHEN** 会計が決済ページ由来の一覧を要求する +- **THEN** Stripe Checkout Sessions に基づく行の一覧が返る + +### Requirement: フィルタとカーソルページネーション + +システムは、`status` 等で絞り込み、カーソル方式(`limit` + `starting_after`)でページングできる SHALL。レスポンスには次ページの有無(`hasMore`)と次カーソルを含める MUST。`limit` には上限を設け、過大値はクランプする SHALL。 + +#### Scenario: status で絞り込む + +- **WHEN** `status` を指定して一覧を要求する +- **THEN** その状態に一致する行のみが返る + +#### Scenario: 次ページを取得する + +- **WHEN** 前ページの次カーソルを `starting_after` に指定して要求する +- **THEN** 続きの行が返り、さらに続きがあれば `hasMore` が真になる + +### Requirement: 行項目の正規化 + +システムは、各行を Stripe 型に依存しないドメイン DTO に正規化して返す SHALL。各行は少なくとも次を含む MUST:`id`、金額(最小単位+通貨)、日時、customer 参照(id、可能なら名前)、支払い状況、支払いの id、商品(Price)参照、および Stripe Dashboard で開ける URL。 + +#### Scenario: 行に必要項目が含まれる + +- **WHEN** 一覧の各行を受け取る +- **THEN** `id`・金額・通貨・日時・customer 参照・支払い状況・支払い id・商品参照・Dashboard URL が含まれる + +### Requirement: Dashboard URL の生成 + +システムは、支払い/請求のオブジェクト id から Stripe Dashboard の URL を生成 SHALL。test/live のモードに応じて URL(`/test/` の有無)を切り替える MUST。 + +#### Scenario: test モードの URL + +- **WHEN** test モードのオブジェクトから Dashboard URL を生成する +- **THEN** URL は test 用のパス(`/test/`)を含む + +#### Scenario: live モードの URL + +- **WHEN** live モードのオブジェクトから Dashboard URL を生成する +- **THEN** URL は live 用のパス(`/test/` を含まない)になる diff --git a/openspec/changes/archive/2026-06-19-add-payment-listing/tasks.md b/openspec/changes/archive/2026-06-19-add-payment-listing/tasks.md new file mode 100644 index 0000000..09c1f9f --- /dev/null +++ b/openspec/changes/archive/2026-06-19-add-payment-listing/tasks.md @@ -0,0 +1,26 @@ +## 1. Stripe アダプタ拡張 + +- [x] 1.1 `listInvoices({status?, limit, startingAfter?})` を Stripe `invoices.list`(`expand: ['data.customer']`)で実装 +- [x] 1.2 `listCheckoutSessions({status?, limit, startingAfter?})` を Stripe `checkout.sessions.list` で実装 +- [x] 1.3 list 系は遅延 Stripe クライアントを使い、キー未設定時は使用時のみ明確にエラー(既存方針踏襲) + +## 2. payments ドメイン(Stripe 非依存 DTO・URL) + +- [x] 2.1 `PaymentRow` / `PaymentPage` DTO 型を定義 +- [x] 2.2 Stripe Invoice → `PaymentRow` 正規化(id/金額/通貨/日時/customer/支払い状況/支払いid/Price/Dashboard URL) +- [x] 2.3 Stripe Checkout Session → `PaymentRow` 正規化(amount_total/payment_status/payment_intent 等) +- [x] 2.4 `buildDashboardUrl(mode, kind, id)`(test/live 切替)。mode は `STRIPE_SECRET_KEY` prefix から判定 +- [x] 2.5 `limit` クランプ(1..100, 既定 20)と `nextCursor`(`hasMore ? items.at(-1).id : null`)算出 + +## 3. oRPC 手続き(会計のみ) + +- [x] 3.1 `payments.listInvoices`(`requireAdmin`、zod 入力 `{status?, limit?, startingAfter?}`、`PaymentPage` 返却) +- [x] 3.2 `payments.listCheckoutSessions`(同上) +- [x] 3.3 status の zod enum(Invoices: draft/open/paid/uncollectible/void、Sessions: open/complete/expired) + +## 4. 検証 + +- [x] 4.1 ユニットテスト: Invoice/Session の DTO 正規化、`buildDashboardUrl`(test/live)、limit クランプ、nextCursor 算出 +- [x] 4.2 `pnpm lint` / `pnpm typecheck` / `pnpm build` / `pnpm test` をグリーンにする +- [ ] 4.3 (test mode キー投入後)実データで一覧・status フィルタ・カーソルページングを確認 — **BLOCKED**: Stripe test mode キー未投入のため実データ確認不可(ユニットテストで正規化・URL・クランプ・nextCursor を担保) +- [x] 4.4 Codex 指摘修正(認可順序 / Checkout 商品参照): 認可を oRPC ミドルウェア(`userProc` / `adminProc`)で入力検証前に実行し、未認証+不正入力でも UNAUTHORIZED/FORBIDDEN を返す(curl で検証)。Checkout Session に `expand: ['data.line_items.data.price']` を追加し、正規化で先頭 line item の `price.id` / 説明(description→price.nickname)を `product` にマップ、line items 不在時は明示的な `{ priceId: null, description: null }` を返す diff --git a/openspec/specs/payment-listing/spec.md b/openspec/specs/payment-listing/spec.md new file mode 100644 index 0000000..d49e705 --- /dev/null +++ b/openspec/specs/payment-listing/spec.md @@ -0,0 +1,82 @@ +# payment-listing + +## Purpose + +会計向けの入出金一覧。Stripe を source として、請求書由来(Invoices)・決済ページ由来(Checkout Sessions)の 2 系統を、`status` フィルタとカーソルページネーションで読み取り専用に提供する。行は Stripe 型に依存しないドメイン DTO に正規化し、Dashboard URL を添える。 + +## Requirements + +### Requirement: 会計のみがアクセスできる + +入出金一覧は会計(管理者)のみがアクセス可能 SHALL。利用者・未ログインのアクセスは拒否する MUST。認可チェックは入力検証より先に実行され、未認可の呼び出しに入力スキーマの形を漏らしては SHALL NOT。 + +#### Scenario: 会計は一覧を取得できる + +- **WHEN** 会計セッションで入出金一覧を要求する +- **THEN** 一覧が返る + +#### Scenario: 非会計は拒否される + +- **WHEN** 利用者または未ログインで入出金一覧を要求する +- **THEN** 認可エラーで拒否される + +#### Scenario: 未認可なら入力検証より前に拒否 + +- **WHEN** 未ログインの呼び出しが不正な入力で一覧を要求する +- **THEN** 入力検証エラー(形の漏洩)ではなく認可エラーが返る + +### Requirement: 請求書由来と決済ページ由来の 2 系統 + +システムは、入出金一覧を「請求書由来(Stripe Invoices)」と「決済ページ由来(Stripe Checkout Sessions)」の 2 系統で提供 SHALL。各系統は独立したエンドポイントで取得できる MUST。一覧は Stripe を source とし、自前 DB に複製しない。 + +#### Scenario: 請求書由来を取得 + +- **WHEN** 会計が請求書由来の一覧を要求する +- **THEN** Stripe Invoices に基づく行の一覧が返る + +#### Scenario: 決済ページ由来を取得 + +- **WHEN** 会計が決済ページ由来の一覧を要求する +- **THEN** Stripe Checkout Sessions に基づく行の一覧が返る + +### Requirement: フィルタとカーソルページネーション + +システムは、`status` 等で絞り込み、カーソル方式(`limit` + `starting_after`)でページングできる SHALL。レスポンスには次ページの有無(`hasMore`)と次カーソルを含める MUST。`limit` には上限を設け、過大値はクランプする SHALL。 + +#### Scenario: status で絞り込む + +- **WHEN** `status` を指定して一覧を要求する +- **THEN** その状態に一致する行のみが返る + +#### Scenario: 次ページを取得する + +- **WHEN** 前ページの次カーソルを `starting_after` に指定して要求する +- **THEN** 続きの行が返り、さらに続きがあれば `hasMore` が真になる + +### Requirement: 行項目の正規化 + +システムは、各行を Stripe 型に依存しないドメイン DTO に正規化して返す SHALL。各行は少なくとも次を含む MUST:`id`、金額(最小単位+通貨)、日時、customer 参照(id、可能なら名前)、支払い状況、支払いの id、商品(Price)参照、および Stripe Dashboard で開ける URL。値が欠落する場合は明示的な null を返し、欠落で例外を投げては SHALL NOT。 + +#### Scenario: 行に必要項目が含まれる + +- **WHEN** 一覧の各行を受け取る +- **THEN** `id`・金額・通貨・日時・customer 参照・支払い状況・支払い id・商品参照(無ければ null)・Dashboard URL が含まれる + +#### Scenario: 欠落フィールドは null で安全に返す + +- **WHEN** customer 削除済みや未展開などでフィールドが欠落する行を正規化する +- **THEN** 例外を投げず、該当フィールドは null として返る + +### Requirement: Dashboard URL の生成 + +システムは、支払い/請求のオブジェクト id から Stripe Dashboard の URL を生成 SHALL。test/live のモードに応じて URL(`/test/` の有無)を切り替える MUST。 + +#### Scenario: test モードの URL + +- **WHEN** test モードのオブジェクトから Dashboard URL を生成する +- **THEN** URL は test 用のパス(`/test/`)を含む + +#### Scenario: live モードの URL + +- **WHEN** live モードのオブジェクトから Dashboard URL を生成する +- **THEN** URL は live 用のパス(`/test/` を含まない)になる diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 327f114..d2857b2 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -9,6 +9,9 @@ export * from './auth' /** Membership-collection domain (Stripe-independent: term/pricing/authorize + config). */ export * from './billing' +/** Payments-listing domain (Stripe-independent: row DTOs / Dashboard URL / pagination). */ +export * from './payments' + /** Stripe adapter layer (all Stripe SDK usage is isolated here). */ export * from './stripe' diff --git a/packages/api/src/orpc.ts b/packages/api/src/orpc.ts index 30a6e5b..176a665 100644 --- a/packages/api/src/orpc.ts +++ b/packages/api/src/orpc.ts @@ -28,3 +28,23 @@ export interface Context extends AuthHelpers { /** Base procedure builder — start all procedures from here. */ export const pub = os.$context() + +/** + * Authenticated procedure builder. The auth check runs as oRPC middleware, so it + * executes BEFORE `.input()` validation — an unauthenticated caller sending + * malformed input gets UNAUTHORIZED, not a zod BAD_REQUEST that would leak the + * expected input shape. (session spec: §認可ヘルパ) + */ +export const userProc = pub.use(({ context, next }) => { + context.requireUser() + return next() +}) + +/** + * Admin (accountant) procedure builder. As with {@link userProc}, the + * `requireAdmin()` check runs as middleware ahead of input validation. + */ +export const adminProc = pub.use(({ context, next }) => { + context.requireAdmin() + return next() +}) diff --git a/packages/api/src/payments/dashboard.ts b/packages/api/src/payments/dashboard.ts new file mode 100644 index 0000000..f0906e2 --- /dev/null +++ b/packages/api/src/payments/dashboard.ts @@ -0,0 +1,28 @@ +/** Stripe Dashboard mode — drives the `/test/` path segment. */ +export type StripeMode = 'test' | 'live' + +/** The object kinds we can deep-link to in the Dashboard. */ +export type DashboardKind = 'invoice' | 'checkout-session' + +/** + * Derive the Dashboard mode from the configured secret key (design D4): test + * keys start with `sk_test_` / `rk_test_`; anything else is treated as live. + * Pure function — takes the key string, never the Stripe SDK. + */ +export function modeFromSecretKey(secretKey: string): StripeMode { + return secretKey.startsWith('sk_test_') || secretKey.startsWith('rk_test_') ? 'test' : 'live' +} + +/** + * Build the Stripe Dashboard URL for an object (payment-listing spec: + * §Dashboard URL の生成 / design D4): + * - invoice → `…/{test/}invoices/{id}` + * - checkout session → `…/{test/}checkout/sessions/{id}` + * test mode inserts the `/test/` segment; live omits it. + */ +export function buildDashboardUrl(mode: StripeMode, kind: DashboardKind, id: string): string { + const base = 'https://dashboard.stripe.com' + const modeSegment = mode === 'test' ? '/test' : '' + const path = kind === 'invoice' ? `/invoices/${id}` : `/checkout/sessions/${id}` + return `${base}${modeSegment}${path}` +} diff --git a/packages/api/src/payments/index.ts b/packages/api/src/payments/index.ts new file mode 100644 index 0000000..f610d72 --- /dev/null +++ b/packages/api/src/payments/index.ts @@ -0,0 +1,4 @@ +export * from './types' +export * from './dashboard' +export * from './pagination' +export * from './normalize' diff --git a/packages/api/src/payments/normalize.ts b/packages/api/src/payments/normalize.ts new file mode 100644 index 0000000..6b1c8c2 --- /dev/null +++ b/packages/api/src/payments/normalize.ts @@ -0,0 +1,90 @@ +import type Stripe from 'stripe' +import { buildDashboardUrl, type StripeMode } from './dashboard' +import type { PaymentRow } from './types' + +/** + * Normalizers: Stripe object → {@link PaymentRow}. They accept the Stripe object + * **types** (so callers stay type-checked) but only read plain fields and OUTPUT + * plain DTOs — they never call the Stripe SDK. That keeps `payments/` importable + * without pulling in any Stripe runtime, while the adapter (`stripe/listing.ts`) + * remains the only place that talks to Stripe. + */ + +/** Read the customer id + name from an expandable customer field. */ +function readCustomer( + customer: string | Stripe.Customer | Stripe.DeletedCustomer | null, + fallbackName?: string | null, +): { id: string | null, name?: string | null } { + if (customer === null) { + return { id: null, name: fallbackName ?? null } + } + if (typeof customer === 'string') { + return { id: customer, name: fallbackName ?? null } + } + // Expanded object: deleted customers carry no name. + const name = 'deleted' in customer && customer.deleted ? null : (customer.name ?? null) + return { id: customer.id, name: name ?? fallbackName ?? null } +} + +/** + * Map a Stripe Invoice to a {@link PaymentRow} (tasks 2.2). Amount uses + * `amount_due` (the invoiced total); the Price comes from the (single) line + * item's `pricing.price_details`. `paymentId` is the latest payment's intent id + * when present (Stripe v18 moved it under `payments`). + */ +export function invoiceToRow(invoice: Stripe.Invoice, mode: StripeMode): PaymentRow { + const line = invoice.lines?.data?.[0] + const priceId = line?.pricing?.price_details?.price ?? null + + // Latest InvoicePayment's payment_intent (when expanded to an object), else null. + const latestPayment = invoice.payments?.data?.[0]?.payment?.payment_intent ?? null + const paymentId = typeof latestPayment === 'string' ? latestPayment : (latestPayment?.id ?? null) + + return { + id: invoice.id ?? '', + amount: invoice.amount_due, + currency: invoice.currency, + createdAt: new Date(invoice.created * 1000).toISOString(), + customer: readCustomer(invoice.customer, invoice.customer_name), + paymentStatus: invoice.status ?? 'unknown', + paymentId, + product: { priceId, description: line?.description ?? null }, + dashboardUrl: buildDashboardUrl(mode, 'invoice', invoice.id ?? ''), + } +} + +/** + * Map a Stripe Checkout Session to a {@link PaymentRow} (tasks 2.3). Amount uses + * `amount_total`, status uses `payment_status`, and the payment id is the + * `payment_intent` (string or expanded id). + */ +export function checkoutSessionToRow( + session: Stripe.Checkout.Session, + mode: StripeMode, +): PaymentRow { + const intent = session.payment_intent + const paymentId = typeof intent === 'string' ? intent : (intent?.id ?? null) + + // Price comes from the first expanded line item (adapter requests + // `expand: ['data.line_items.data.price']`). When line items are absent / + // unexpanded we return explicit nulls so the DTO shape stays complete. + const lineItem = session.line_items?.data?.[0] + const product = lineItem + ? { + priceId: lineItem.price?.id ?? null, + description: lineItem.description ?? lineItem.price?.nickname ?? null, + } + : { priceId: null, description: null } + + return { + id: session.id, + amount: session.amount_total ?? 0, + currency: session.currency ?? 'unknown', + createdAt: new Date(session.created * 1000).toISOString(), + customer: readCustomer(session.customer, session.customer_details?.name), + paymentStatus: session.payment_status, + paymentId, + product, + dashboardUrl: buildDashboardUrl(mode, 'checkout-session', session.id), + } +} diff --git a/packages/api/src/payments/pagination.ts b/packages/api/src/payments/pagination.ts new file mode 100644 index 0000000..0411fe2 --- /dev/null +++ b/packages/api/src/payments/pagination.ts @@ -0,0 +1,31 @@ +/** Smallest page size accepted by Stripe list endpoints. */ +const MIN_LIMIT = 1 +/** Largest page size accepted by Stripe list endpoints. */ +const MAX_LIMIT = 100 +/** Default page size when the caller omits `limit`. */ +const DEFAULT_LIMIT = 20 + +/** + * Clamp a requested page size to Stripe's 1..100 range, defaulting to 20 when + * unset (design D3). Non-integers are floored so we never send a fractional + * `limit` to Stripe. + */ +export function clampLimit(limit?: number): number { + if (limit === undefined) { + return DEFAULT_LIMIT + } + const floored = Math.floor(limit) + return Math.min(MAX_LIMIT, Math.max(MIN_LIMIT, floored)) +} + +/** + * Compute the next cursor for cursor pagination (design D3): when Stripe reports + * more rows, the last row's id is the `starting_after` for the next page; + * otherwise there is no next page. + */ +export function nextCursor(items: { id: string }[], hasMore: boolean): string | null { + if (!hasMore) { + return null + } + return items.at(-1)?.id ?? null +} diff --git a/packages/api/src/payments/payments.test.ts b/packages/api/src/payments/payments.test.ts new file mode 100644 index 0000000..79e6faf --- /dev/null +++ b/packages/api/src/payments/payments.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, it } from 'vitest' +import type Stripe from 'stripe' +import { buildDashboardUrl, modeFromSecretKey } from './dashboard' +import { clampLimit, nextCursor } from './pagination' +import { checkoutSessionToRow, invoiceToRow } from './normalize' + +/** + * Representative plain objects shaped like the Stripe responses. We cast through + * `unknown` to the Stripe types so the normalizers stay type-checked without us + * having to spell out every (unused) Stripe field — the normalizers only ever + * read the handful of fields exercised here. + */ +function makeInvoice(overrides: Partial = {}): Stripe.Invoice { + return { + id: 'in_123', + amount_due: 4000, + currency: 'jpy', + created: 1_700_000_000, // 2023-11-14T22:13:20.000Z + status: 'open', + customer: { id: 'cus_1', name: 'Alice', object: 'customer' }, + customer_name: null, + lines: { + data: [ + { + description: '新規入部費・前期', + pricing: { price_details: { price: 'price_abc', product: 'prod_abc' } }, + }, + ], + }, + payments: { + data: [ + { payment: { payment_intent: 'pi_123' } }, + ], + }, + ...overrides, + } as unknown as Stripe.Invoice +} + +function makeSession( + overrides: Partial = {}, +): Stripe.Checkout.Session { + return { + id: 'cs_123', + amount_total: 2000, + currency: 'jpy', + created: 1_700_000_000, + payment_status: 'paid', + payment_intent: 'pi_456', + customer: 'cus_2', + customer_details: { name: 'Bob' }, + ...overrides, + } as unknown as Stripe.Checkout.Session +} + +describe('invoiceToRow', () => { + it('normalizes an invoice into a PaymentRow', () => { + const row = invoiceToRow(makeInvoice(), 'test') + expect(row).toEqual({ + id: 'in_123', + amount: 4000, + currency: 'jpy', + createdAt: '2023-11-14T22:13:20.000Z', + customer: { id: 'cus_1', name: 'Alice' }, + paymentStatus: 'open', + paymentId: 'pi_123', + product: { priceId: 'price_abc', description: '新規入部費・前期' }, + dashboardUrl: 'https://dashboard.stripe.com/test/invoices/in_123', + }) + }) + + it('falls back to customer_name and a string customer id', () => { + const row = invoiceToRow( + makeInvoice({ customer: 'cus_str', customer_name: 'Carol' }), + 'live', + ) + expect(row.customer).toEqual({ id: 'cus_str', name: 'Carol' }) + expect(row.dashboardUrl).toBe('https://dashboard.stripe.com/invoices/in_123') + }) + + it('tolerates a missing line / payments / status', () => { + const row = invoiceToRow( + makeInvoice({ status: null, lines: { data: [] } as never, payments: undefined }), + 'test', + ) + expect(row.product).toEqual({ priceId: null, description: null }) + expect(row.paymentId).toBeNull() + expect(row.paymentStatus).toBe('unknown') + }) + + it('drops the name for a deleted (expanded) customer', () => { + const row = invoiceToRow( + makeInvoice({ customer: { id: 'cus_del', deleted: true, object: 'customer' } as never }), + 'test', + ) + expect(row.customer).toEqual({ id: 'cus_del', name: null }) + }) +}) + +describe('checkoutSessionToRow', () => { + it('normalizes a session into a PaymentRow', () => { + const row = checkoutSessionToRow(makeSession(), 'test') + expect(row).toEqual({ + id: 'cs_123', + amount: 2000, + currency: 'jpy', + createdAt: '2023-11-14T22:13:20.000Z', + customer: { id: 'cus_2', name: 'Bob' }, + paymentStatus: 'paid', + paymentId: 'pi_456', + // No expanded line_items → explicit nulls (not {}), so the DTO is complete. + product: { priceId: null, description: null }, + dashboardUrl: 'https://dashboard.stripe.com/test/checkout/sessions/cs_123', + }) + }) + + it('maps the first expanded line item to product (price id + description)', () => { + const row = checkoutSessionToRow( + makeSession({ + line_items: { + data: [ + { + description: '部費(決済ページ)', + price: { id: 'price_cs', nickname: '部費' }, + }, + ], + } as never, + }), + 'test', + ) + expect(row.product).toEqual({ priceId: 'price_cs', description: '部費(決済ページ)' }) + }) + + it('falls back to the price nickname when the line item has no description', () => { + const row = checkoutSessionToRow( + makeSession({ + line_items: { + data: [ + { + description: null, + price: { id: 'price_cs', nickname: '継続特別' }, + }, + ], + } as never, + }), + 'test', + ) + expect(row.product).toEqual({ priceId: 'price_cs', description: '継続特別' }) + }) + + it('returns explicit null product fields when line items are absent', () => { + const row = checkoutSessionToRow(makeSession({ line_items: undefined }), 'test') + expect(row.product).toEqual({ priceId: null, description: null }) + }) + + it('handles a null amount / currency / customer and an expanded intent', () => { + const row = checkoutSessionToRow( + makeSession({ + amount_total: null, + currency: null, + customer: null, + customer_details: null, + payment_intent: { id: 'pi_exp' } as never, + }), + 'live', + ) + expect(row.amount).toBe(0) + expect(row.currency).toBe('unknown') + expect(row.customer).toEqual({ id: null, name: null }) + expect(row.paymentId).toBe('pi_exp') + expect(row.dashboardUrl).toBe('https://dashboard.stripe.com/checkout/sessions/cs_123') + }) +}) + +describe('buildDashboardUrl', () => { + it('includes /test/ for invoices in test mode', () => { + expect(buildDashboardUrl('test', 'invoice', 'in_1')) + .toBe('https://dashboard.stripe.com/test/invoices/in_1') + }) + + it('omits /test/ for invoices in live mode', () => { + expect(buildDashboardUrl('live', 'invoice', 'in_1')) + .toBe('https://dashboard.stripe.com/invoices/in_1') + }) + + it('includes /test/ for checkout sessions in test mode', () => { + expect(buildDashboardUrl('test', 'checkout-session', 'cs_1')) + .toBe('https://dashboard.stripe.com/test/checkout/sessions/cs_1') + }) + + it('omits /test/ for checkout sessions in live mode', () => { + expect(buildDashboardUrl('live', 'checkout-session', 'cs_1')) + .toBe('https://dashboard.stripe.com/checkout/sessions/cs_1') + }) +}) + +describe('modeFromSecretKey', () => { + it('treats sk_test_ / rk_test_ keys as test', () => { + expect(modeFromSecretKey('sk_test_abc')).toBe('test') + expect(modeFromSecretKey('rk_test_abc')).toBe('test') + }) + + it('treats live keys (and anything else) as live', () => { + expect(modeFromSecretKey('sk_live_abc')).toBe('live') + expect(modeFromSecretKey('')).toBe('live') + }) +}) + +describe('clampLimit', () => { + it('defaults to 20 when unset', () => { + expect(clampLimit()).toBe(20) + }) + + it('clamps below 1 up to 1', () => { + expect(clampLimit(0)).toBe(1) + expect(clampLimit(-5)).toBe(1) + }) + + it('clamps above 100 down to 100', () => { + expect(clampLimit(250)).toBe(100) + }) + + it('passes through and floors in-range values', () => { + expect(clampLimit(50)).toBe(50) + expect(clampLimit(20.9)).toBe(20) + }) +}) + +describe('nextCursor', () => { + it('returns the last id when hasMore is true', () => { + expect(nextCursor([{ id: 'a' }, { id: 'b' }], true)).toBe('b') + }) + + it('returns null when hasMore is false', () => { + expect(nextCursor([{ id: 'a' }, { id: 'b' }], false)).toBeNull() + }) + + it('returns null when there are no items even if hasMore is true', () => { + expect(nextCursor([], true)).toBeNull() + }) +}) diff --git a/packages/api/src/payments/types.ts b/packages/api/src/payments/types.ts new file mode 100644 index 0000000..1ab2d2d --- /dev/null +++ b/packages/api/src/payments/types.ts @@ -0,0 +1,34 @@ +/** + * Stripe-independent row DTO for the accountant payments listing + * (payment-listing spec: §行項目の正規化 / design D2). Every field here is a + * plain JSON value so the Stripe SDK never leaks past the adapter boundary. + */ +export interface PaymentRow { + /** The Stripe object id (invoice id or checkout session id). */ + id: string + /** Amount in the currency's smallest unit (e.g. JPY: yen). */ + amount: number + /** ISO 4217 currency code (lowercase, as Stripe returns it). */ + currency: string + /** Creation time as an ISO 8601 string. */ + createdAt: string + /** Customer reference: id, and name when it could be resolved. */ + customer: { id: string | null, name?: string | null } + /** Payment state — `invoice.status` or session `payment_status`. */ + paymentStatus: string + /** The payment object id (e.g. payment_intent), or null when none. */ + paymentId: string | null + /** Product (Price) reference, when present on the object. */ + product: { priceId?: string | null, description?: string | null } + /** Absolute Stripe Dashboard URL for the object (test/live aware). */ + dashboardUrl: string +} + +/** One cursor page of {@link PaymentRow}s (design D2). */ +export interface PaymentPage { + items: PaymentRow[] + /** Whether Stripe reports more rows beyond this page (`has_more`). */ + hasMore: boolean + /** Cursor for the next page (`starting_after`), or null when none. */ + nextCursor: string | null +} diff --git a/packages/api/src/router.ts b/packages/api/src/router.ts index 5a3aa06..c96fb14 100644 --- a/packages/api/src/router.ts +++ b/packages/api/src/router.ts @@ -1,6 +1,6 @@ import { z } from 'zod' import { ORPCError } from '@orpc/server' -import { pub } from './orpc' +import { adminProc, pub, userProc } from './orpc' import { isAllowedEmailDomain, deriveMailHash, @@ -9,7 +9,15 @@ import { getUserById, } from './auth' import { computeActivityYear, computeTerm, selectPriceId } from './billing' -import { getOrCreateCustomer, issueInvoice } from './stripe' +import { getOrCreateCustomer, issueInvoice, listCheckoutSessions, listInvoices } from './stripe' +import { + checkoutSessionToRow, + clampLimit, + invoiceToRow, + modeFromSecretKey, + nextCursor, + type PaymentPage, +} from './payments' /** * Application router. Procedures are grouped by capability. Browser-redirect / @@ -77,7 +85,7 @@ export const appRouter = { * others). `new` fees auto-price by the current term; `continuation` uses * the standard price. (membership-billing spec: §発行の認可 — 標準は本人) */ - issueInvoice: pub + issueInvoice: userProc .input(z.object({ email: z.string().email(), name: z.string().min(1), @@ -138,14 +146,13 @@ export const appRouter = { * otherwise creates the Customer from the supplied email (required if none). * (membership-billing spec: §発行の認可 — 特別は管理者) */ - issueSpecialInvoice: pub + issueSpecialInvoice: adminProc .input(z.object({ userId: z.string().min(1), name: z.string().min(1).optional(), email: z.string().email().optional(), })) .handler(async ({ input, context }) => { - context.requireAdmin() context.assertCsrf() const row = await getUserById(context.db, input.userId) @@ -197,4 +204,52 @@ export const appRouter = { return result }), }, + + payments: { + /** + * List 請求書由来 (Stripe Invoices) for the accountant. Read-only: requires + * admin, but no `assertCsrf` (no state change). Rows are normalized to + * Stripe-independent DTOs; `hasMore`/`nextCursor` drive cursor pagination. + * (payment-listing spec: §会計のみ / §2 系統 / §フィルタとカーソルページネーション) + */ + listInvoices: adminProc + .input(z.object({ + status: z.enum(['draft', 'open', 'paid', 'uncollectible', 'void']).optional(), + limit: z.number().int().optional(), + startingAfter: z.string().optional(), + })) + .handler(async ({ input, context }): Promise => { + const mode = modeFromSecretKey(context.billing.stripeSecretKey) + const page = await listInvoices(context.stripe, { + status: input.status, + limit: clampLimit(input.limit), + startingAfter: input.startingAfter, + }) + + const items = page.data.map(invoice => invoiceToRow(invoice, mode)) + return { items, hasMore: page.has_more, nextCursor: nextCursor(items, page.has_more) } + }), + + /** + * List 決済ページ由来 (Stripe Checkout Sessions) for the accountant. Same + * read-only admin authorization and cursor pagination as `listInvoices`. + */ + listCheckoutSessions: adminProc + .input(z.object({ + status: z.enum(['open', 'complete', 'expired']).optional(), + limit: z.number().int().optional(), + startingAfter: z.string().optional(), + })) + .handler(async ({ input, context }): Promise => { + const mode = modeFromSecretKey(context.billing.stripeSecretKey) + const page = await listCheckoutSessions(context.stripe, { + status: input.status, + limit: clampLimit(input.limit), + startingAfter: input.startingAfter, + }) + + const items = page.data.map(session => checkoutSessionToRow(session, mode)) + return { items, hasMore: page.has_more, nextCursor: nextCursor(items, page.has_more) } + }), + }, } diff --git a/packages/api/src/stripe/index.ts b/packages/api/src/stripe/index.ts index c89cec2..a90a77a 100644 --- a/packages/api/src/stripe/index.ts +++ b/packages/api/src/stripe/index.ts @@ -1,4 +1,5 @@ export * from './client' export * from './customers' export * from './invoices' +export * from './listing' export * from './webhook' diff --git a/packages/api/src/stripe/listing.ts b/packages/api/src/stripe/listing.ts new file mode 100644 index 0000000..fa960bc --- /dev/null +++ b/packages/api/src/stripe/listing.ts @@ -0,0 +1,60 @@ +import type Stripe from 'stripe' +import type { StripeClient } from './client' + +/** Shared cursor-pagination inputs for the list adapters. */ +export interface ListInput { + /** Optional Stripe status filter, passed through verbatim. */ + status?: Status + /** Page size (already clamped by the domain before reaching the adapter). */ + limit: number + /** Stripe cursor (`starting_after`) — the previous page's last object id. */ + startingAfter?: string +} + +/** Status values accepted by `invoices.list`. */ +export type InvoiceStatus = Stripe.InvoiceListParams.Status +/** Status values accepted by `checkout.sessions.list`. */ +export type CheckoutSessionStatus = Stripe.Checkout.SessionListParams.Status + +/** + * Thin list wrappers over the Stripe SDK. They return the **raw** Stripe pages + * so all Stripe types stay inside the adapter boundary; the `payments` domain + * normalizes them into Stripe-independent DTOs. + * + * Reads only — no DB writes. They go through the lazy {@link StripeClient}, so a + * missing `STRIPE_SECRET_KEY` only fails here, on actual use (existing policy). + */ + +/** + * List Invoices (請求書由来). `expand: ['data.customer']` so the normalizer can + * read the customer name without an extra round-trip per row. + */ +export function listInvoices( + stripe: StripeClient, + input: ListInput, +): Promise> { + return stripe.sdk.invoices.list({ + status: input.status, + limit: input.limit, + starting_after: input.startingAfter, + expand: ['data.customer'], + }) +} + +/** + * List Checkout Sessions (決済ページ由来). `expand: ['data.customer']` mirrors + * the invoice path so the normalizer can resolve a customer name when present; + * `expand: ['data.line_items.data.price']` surfaces the first line item's Price + * so the normalizer can populate the row's `product` reference. + */ +export function listCheckoutSessions( + stripe: StripeClient, + input: ListInput, +): Promise> { + return stripe.sdk.checkout.sessions.list({ + status: input.status, + limit: input.limit, + starting_after: input.startingAfter, + expand: ['data.customer', 'data.line_items.data.price'], + }) +} From f7d796cb5b1ca39b984b3ad69f8e59cb82330936 Mon Sep 17 00:00:00 2001 From: kaitoyama Date: Sat, 20 Jun 2026 00:33:37 +0900 Subject: [PATCH 03/30] =?UTF-8?q?feat:=20Stripe=20Connect=20onboarding=20f?= =?UTF-8?q?or=20refunds=20(=E4=BC=9A=E8=A8=88=20JIT=20onboarding)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit add-connect-onboarding (OpenSpec): connected-account JIT onboarding, the prerequisite for payouts. Jomon-independent; actual payouts land in a later change. - Stripe Connect adapter: race-safe getOrCreateConnectedAccount (Express; fresh re-read + conditional compare-and-set claim; orphan account cleanup on lost race), createAccountOnboardingLink (hosted Account Link), Connect-secret signature verify. - Stripe-independent onboarding logic: isPayoutsReady (payouts_enabled + no currently_due), nextOnboardingStatus (none/requested/done, terminal & idempotent). - account.updated webhook: Connect-secret raw-body verify + event-id idempotency + flag-based requested->done (never "event = done"). - oRPC payouts.createOnboardingLink / onboardingStatus (admin-only). - DB: users.stripe_connected_account_id (UNIQUE) + payout_onboarding_status. Migrations 0002, 0003. Codex-reviewed (race + uniqueness findings fixed), delta specs synced to openspec/specs/ and archived. Gates green: lint, typecheck, build, test (62). Live Connect E2E pending Connect-enabled test keys. Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 8 + apps/web/nuxt.config.ts | 2 + .../routes/webhook/account-updated.post.ts | 78 +++++ apps/web/server/utils/billing-config.ts | 1 + .../.openspec.yaml | 2 + .../design.md | 79 +++++ .../proposal.md | 29 ++ .../specs/connect-onboarding/spec.md | 76 +++++ .../specs/identity/spec.md | 20 ++ .../tasks.md | 37 ++ openspec/specs/connect-onboarding/spec.md | 87 +++++ openspec/specs/identity/spec.md | 7 +- packages/api/src/auth/identity.ts | 73 +++- packages/api/src/billing/billing.test.ts | 1 + packages/api/src/billing/config.ts | 6 + packages/api/src/index.ts | 3 + packages/api/src/payouts/index.ts | 1 + packages/api/src/payouts/onboarding.test.ts | 68 ++++ packages/api/src/payouts/onboarding.ts | 59 ++++ packages/api/src/router.ts | 74 +++- packages/api/src/stripe/connect.test.ts | 19 ++ packages/api/src/stripe/connect.ts | 193 +++++++++++ packages/api/src/stripe/index.ts | 1 + packages/db/drizzle/0002_gifted_pyro.sql | 2 + packages/db/drizzle/0003_happy_cobalt_man.sql | 1 + packages/db/drizzle/meta/0002_snapshot.json | 310 +++++++++++++++++ packages/db/drizzle/meta/0003_snapshot.json | 316 ++++++++++++++++++ packages/db/drizzle/meta/_journal.json | 14 + packages/db/src/schema.ts | 15 + 29 files changed, 1577 insertions(+), 5 deletions(-) create mode 100644 apps/web/server/routes/webhook/account-updated.post.ts create mode 100644 openspec/changes/archive/2026-06-20-add-connect-onboarding/.openspec.yaml create mode 100644 openspec/changes/archive/2026-06-20-add-connect-onboarding/design.md create mode 100644 openspec/changes/archive/2026-06-20-add-connect-onboarding/proposal.md create mode 100644 openspec/changes/archive/2026-06-20-add-connect-onboarding/specs/connect-onboarding/spec.md create mode 100644 openspec/changes/archive/2026-06-20-add-connect-onboarding/specs/identity/spec.md create mode 100644 openspec/changes/archive/2026-06-20-add-connect-onboarding/tasks.md create mode 100644 openspec/specs/connect-onboarding/spec.md create mode 100644 packages/api/src/payouts/index.ts create mode 100644 packages/api/src/payouts/onboarding.test.ts create mode 100644 packages/api/src/payouts/onboarding.ts create mode 100644 packages/api/src/stripe/connect.test.ts create mode 100644 packages/api/src/stripe/connect.ts create mode 100644 packages/db/drizzle/0002_gifted_pyro.sql create mode 100644 packages/db/drizzle/0003_happy_cobalt_man.sql create mode 100644 packages/db/drizzle/meta/0002_snapshot.json create mode 100644 packages/db/drizzle/meta/0003_snapshot.json diff --git a/.env.example b/.env.example index e73f1e1..4a3f077 100644 --- a/.env.example +++ b/.env.example @@ -54,3 +54,11 @@ PRICE_KEIZOKU_SPECIAL="" # Days until an issued (send_invoice) invoice is due. INVOICE_DAYS_UNTIL_DUE="7" + +# --- Refund / Connect onboarding (OpenSpec: add-connect-onboarding) --- + +# Signing secret for the Connect account.updated webhook endpoint (whsec_...). +# Connect events may use a separate endpoint from the invoice webhook; set the +# same value here if they share one. Leave empty for non-payout dev (the Stripe +# adapter is lazy, so non-payout requests work without it). +STRIPE_CONNECT_WEBHOOK_SECRET="" diff --git a/apps/web/nuxt.config.ts b/apps/web/nuxt.config.ts index d24d7a8..3f4da57 100644 --- a/apps/web/nuxt.config.ts +++ b/apps/web/nuxt.config.ts @@ -28,6 +28,8 @@ export default defineNuxtConfig({ // Membership collection / Stripe stripeSecretKey: process.env.STRIPE_SECRET_KEY ?? '', stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET ?? '', + // Connect (account.updated) webhook signing secret — see add-connect-onboarding. + stripeConnectWebhookSecret: process.env.STRIPE_CONNECT_WEBHOOK_SECRET ?? '', priceShinkiZenki: process.env.PRICE_SHINKI_ZENKI ?? '', priceShinkiKouki: process.env.PRICE_SHINKI_KOUKI ?? '', priceKeizokuStandard: process.env.PRICE_KEIZOKU_STANDARD ?? '', diff --git a/apps/web/server/routes/webhook/account-updated.post.ts b/apps/web/server/routes/webhook/account-updated.post.ts new file mode 100644 index 0000000..9419155 --- /dev/null +++ b/apps/web/server/routes/webhook/account-updated.post.ts @@ -0,0 +1,78 @@ +import { + constructAccountEvent, + createStripeClient, + getUserByConnectedAccountId, + hasProcessedStripeEvent, + isPayoutsReady, + nextOnboardingStatus, + recordStripeEventOnce, + setPayoutOnboardingStatus, +} from '@checkin/api' + +/** + * POST /webhook/account-updated — Stripe Connect `account.updated` receiver. + * + * No CSRF/session here: Stripe authenticates via the request signature, not a + * browser cookie (design.md D5). We verify the signature over the RAW body with + * the **Connect** webhook secret, dedupe by event id, locate the payee by their + * connected account id, and flip `requested → done` only when the account is + * actually payouts-ready (`payouts_enabled` + no requirements due). Stripe sends + * no single "completed" event, so we judge by flags, not by event arrival. + * + * Idempotency mirrors invoice-paid: check `hasProcessedStripeEvent` → process → + * `recordStripeEventOnce`. A failure before recording leaves the event + * unrecorded so Stripe retries (at-least-once). The state transition itself is + * idempotent (`done` is terminal), so a rare re-run is harmless. + */ +export default defineEventHandler(async (event) => { + const billing = resolveBillingConfig() + const stripe = createStripeClient(billing.stripeSecretKey) + + // Raw, unparsed body is required for the signature HMAC to match. + const rawBody = await readRawBody(event, false) + const signature = getHeader(event, 'stripe-signature') + + let verified: ReturnType + try { + if (!rawBody) { + throw new Error('missing request body') + } + verified = constructAccountEvent(stripe, rawBody, signature, billing.connectWebhookSecret) + } + catch { + // Invalid or missing signature → reject without processing. + throw createError({ statusCode: 400, statusMessage: 'invalid Stripe signature' }) + } + + // Ignore everything except account.updated (acknowledge so Stripe stops retrying). + if (verified.type !== 'account.updated') { + return { ok: true, ignored: verified.type } + } + + // Idempotency (happy path): skip if this event was already processed before. + const db = useDatabase() + if (await hasProcessedStripeEvent(db, verified.id)) { + return { ok: true, duplicate: true } + } + + // Locate the payee by their connected account id. Unknown account → ignore (200). + if (!verified.accountId) { + return { ok: true, ignored: 'missing account' } + } + const row = await getUserByConnectedAccountId(db, verified.accountId) + if (!row) { + return { ok: true, ignored: 'unknown account' } + } + + // Flag-based readiness: only advance to `done` (terminal) when payouts-ready. + const ready = isPayoutsReady(verified.account) + const next = nextOnboardingStatus(row.payoutOnboardingStatus, ready) + if (next !== row.payoutOnboardingStatus) { + await setPayoutOnboardingStatus(db, row.id, next) + } + + // Record only AFTER the side effect so a failure above leaves it for retry. + await recordStripeEventOnce(db, verified) + + return { ok: true } +}) diff --git a/apps/web/server/utils/billing-config.ts b/apps/web/server/utils/billing-config.ts index dc5014a..74b6023 100644 --- a/apps/web/server/utils/billing-config.ts +++ b/apps/web/server/utils/billing-config.ts @@ -28,6 +28,7 @@ export function resolveBillingConfig(): BillingConfig { }, stripeSecretKey: String(rc.stripeSecretKey ?? ''), stripeWebhookSecret: String(rc.stripeWebhookSecret ?? ''), + connectWebhookSecret: String(rc.stripeConnectWebhookSecret ?? ''), invoiceDaysUntilDue: num(rc.invoiceDaysUntilDue, 7), } return cached diff --git a/openspec/changes/archive/2026-06-20-add-connect-onboarding/.openspec.yaml b/openspec/changes/archive/2026-06-20-add-connect-onboarding/.openspec.yaml new file mode 100644 index 0000000..ff1fbc8 --- /dev/null +++ b/openspec/changes/archive/2026-06-20-add-connect-onboarding/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-19 diff --git a/openspec/changes/archive/2026-06-20-add-connect-onboarding/design.md b/openspec/changes/archive/2026-06-20-add-connect-onboarding/design.md new file mode 100644 index 0000000..f2cfcbe --- /dev/null +++ b/openspec/changes/archive/2026-06-20-add-connect-onboarding/design.md @@ -0,0 +1,79 @@ +## Context + +[[add-membership-collection]] で Stripe アダプタ(lazy client)・`stripe_events` 冪等台帳・会計認可(`adminProc`)が整った。本 change は払い戻しの前提となる Stripe Connect の connected account onboarding を Jomon 非依存で整備する(design.md §4/§5.3/§7)。実送金・Jomon 連携は後続 [[jomon-integration]]。 + +design.md §5.3 の要点に従う: +- onboarding は just-in-time。ホスト型 onboarding リンクを発行(API onboarding ではなく hosted を推奨)。 +- 完了検知は `account.updated` Webhook。単発「完了」イベントは無いので `payouts_enabled` 等のフラグ+未提出要件で判定し、event id で冪等に状態遷移。 + +## Goals / Non-Goals + +**Goals:** +- connected account の get-or-create(Express)と `stripe_connected_account_id` 永続化。 +- ホスト型 onboarding リンク(Account Link)発行と `payout_onboarding_status` 遷移。 +- `account.updated` Webhook によるフラグ判定(`payouts_enabled` + `requirements.currently_due`)と冪等な `requested→done`。 +- 会計のみの発行・状態確認 API。 + +**Non-Goals:** +- Jomon 取込・振込依頼・結果書き戻し、実 transfer/payout、Payouts テーブル(後続 add-payout-execution)。 +- `/payouts` UI、催促/再送、資金繰り。 + +## Decisions + +### D1. データモデル(`packages/db`) + +- `users` に追加:`stripe_connected_account_id varchar(255)` nullable、`payout_onboarding_status` enum(`none`,`requested`,`done`) NOT NULL default `none`。 +- Webhook 冪等は既存 `stripe_events`(`event_id` unique)を再利用(新テーブル不要)。 +- `pnpm db:generate` で `0002_*` を生成・コミット(列追加の ALTER。rename 誤検出に注意。既存ベースライン 0000/0001 は保持)。 + +### D2. Stripe Connect アダプタ(`packages/api/src/stripe/connect.ts`) + +- `getOrCreateConnectedAccount(client, db, { userId, stripeConnectedAccountId, mailHash })`:`accounts.create({ type: 'express', metadata: { mail_hash } })` → `users.stripe_connected_account_id` 保存。既存があれば再利用。 +- `createAccountOnboardingLink(client, { accountId, refreshUrl, returnUrl })`:`accountLinks.create({ account, type: 'account_onboarding', refresh_url, return_url })` → URL。 +- `retrieveAccount(client, accountId)`:webhook 検証後の再取得が必要な場合用(基本はイベントの object を使用)。 +- Stripe 型はアダプタ内に閉じ込める。 + +### D3. onboarding 状態(純粋ロジック, `packages/api/src/payouts/onboarding.ts`) + +- `isPayoutsReady(account)`:`account.payouts_enabled === true && (account.requirements?.currently_due ?? []).length === 0`(Stripe 型に依存しない狭い構造入力で受ける)。 +- `nextOnboardingStatus(current, ready)`:`done` は終端。`ready` なら `done`。それ以外は `current==='none' ? 'none' : 'requested'`(リンク発行側で `requested` にする)。冪等。 + +### D4. oRPC(会計のみ) + +- `payouts.createOnboardingLink`(`adminProc` + `assertCsrf`): input `{ userId }`。connected account を get-or-create → Account Link 発行 → `payout_onboarding_status` を(`done` でなければ)`requested` に。返り値 `{ url }`。refresh/return URL は `APP_ORIGIN + '/payouts/onboarding/{refresh,return}'`(UI は後続)。 +- `payouts.onboardingStatus`(`adminProc`): input `{ userId }` → `{ status, hasConnectedAccount }`。 + +### D5. account.updated Webhook(Nitro) + +- `apps/web/server/routes/webhook/account-updated.post.ts`。**raw body** → `stripe.webhooks.constructEvent(raw, sig, STRIPE_CONNECT_WEBHOOK_SECRET)`。 +- 冪等: 既存パターン(`hasProcessedStripeEvent` → 処理 → `recordStripeEventOnce`)。account.updated は多数飛ぶが event id 単位で一度だけ。 +- 対象特定: `event.account`(または `event.data.object.id`)の connected account id で `users` を検索。 +- 判定: `isPayoutsReady(object)` 真かつ現状態 `done` でなければ `done` に更新。偽なら変更なし。該当ユーザーが無ければ無視(200)。 +- 署名は Connect 用シークレットで検証(主 Webhook と別エンドポイントの可能性に備え専用 env)。CSRF/セッションは適用しない。 +- done 遷移時に会計へ通知してもよい(任意、`Notifier`)。 + +### D6. 設定 + +- `BillingConfig`(または新規 `PayoutConfig`)に `connectWebhookSecret`(`STRIPE_CONNECT_WEBHOOK_SECRET`)を追加。`.env.example` / runtimeConfig に追記。`APP_ORIGIN` は既存。 +- Connect account type は `express`(hosted onboarding 推奨、Stripe 管理ダッシュボード)。 + +## Risks / Trade-offs + +- **`account.updated` は完了単発ではない** → Mitigation: フラグ(`payouts_enabled`+要件)判定+event id 冪等。「来たら done」にしない。 +- **Connect Webhook 署名シークレットが主と別** → Mitigation: 専用 env を用意(同一なら同値を設定)。 +- **onboarding リンクの受取人への到達**(メール平文を持たない)→ 本 change では会計にリンクを返して転送する設計。自動メール送付は email を持つ後続フロー(Jomon の payee 情報等)で対応。 +- **Connect 未有効な test アカウント**では E2E 不可 → Mitigation: 判定・遷移・get-or-create をユニットテスト。Connect 有効化後に sandbox E2E。 + +## Migration Plan + +1. `users` 列追加 → `pnpm db:generate`(0002)→ migrate。 +2. Connect アダプタ → onboarding 純粋ロジック → oRPC(発行/状態)→ account.updated Webhook → 設定。 +3. ユニットテスト(`isPayoutsReady`、`nextOnboardingStatus`、get-or-create のモック)。 +4. Connect 有効 test mode で onboarding リンク→完了→`account.updated`→`done` の E2E。 +5. ロールバック: マイグレーション revert + 発行 API/Webhook 無効化(後続未着手なら局所)。 + +## Open Questions + +- connected account の type は Express で確定(hosted onboarding)。将来 Standard/Custom が要れば再検討。 +- done 遷移時の会計通知を出すか(任意。まずは状態更新のみでも可)。 +- refresh/return URL のページ実体は後続 UI change。 diff --git a/openspec/changes/archive/2026-06-20-add-connect-onboarding/proposal.md b/openspec/changes/archive/2026-06-20-add-connect-onboarding/proposal.md new file mode 100644 index 0000000..560be27 --- /dev/null +++ b/openspec/changes/archive/2026-06-20-add-connect-onboarding/proposal.md @@ -0,0 +1,29 @@ +## Why + +第一弾スコープ③「払い戻し」の前半。払い戻し(transfer/payout)には受取人の本人確認+口座登録(Stripe Connect の connected account onboarding)が必須。これを Jomon 非依存に先行整備する。[[add-auth-foundation]] の本人識別・会計認可、[[add-membership-collection]] の Stripe アダプタ/`stripe_events` 冪等基盤の上に乗る。Jomon 連携・実送金は後続 [[jomon-integration]](add-payout-execution)で行う。 + +## What Changes + +- **connected account の JIT onboarding**: 受取人(`users` 行)に対し Stripe Connect の connected account を get-or-create し、Stripe **ホスト型 onboarding リンク**(Account Link)を発行する。発行で状態を `requested` にする。 +- **onboarding 状態機械**: `users.payout_onboarding_status` を `none` → `requested` → `done` で管理。 + - **BREAKING(identity 拡張)**: `users` に `stripe_connected_account_id`(nullable)と `payout_onboarding_status`(既定 `none`)を追加(メール平文は引き続き保存しない。非 PII の Stripe 参照は既存方針で許容)。 +- **`account.updated` Webhook**: Stripe は「完了」単発イベントを送らないため、`account.updated` を受信→署名検証→event id で冪等→対象 connected account を特定→`payouts_enabled` + 未提出要件(`requirements.currently_due`)の有無で「払い出し可能か」を判定→可なら `requested` を `done` に遷移。「来たら done」ではなく**フラグで判定**する。 +- **発行 API(会計のみ)**: `payouts.createOnboardingLink({ userId })`(会計が対象者の onboarding リンクを発行・取得して受取人へ案内)、`payouts.onboardingStatus({ userId })`(状態確認)。 + +明確に**スコープ外**(後続 add-payout-execution): Jomon 取込・振込依頼・結果書き戻し、実際の transfer/payout 実行、Payouts テーブル、催促/再送、資金繰り。`/payouts` UI ページ。 + +## Capabilities + +### New Capabilities +- `connect-onboarding`: Stripe Connect connected account の JIT onboarding。account の get-or-create、ホスト型 onboarding リンク発行、`account.updated` Webhook によるフラグ判定(`payouts_enabled` + 要件)と冪等な状態遷移(`none`/`requested`/`done`)、会計のみの発行・状態確認 API。 + +### Modified Capabilities +- `identity`: `users` に `stripe_connected_account_id`(nullable)と `payout_onboarding_status`(`none`/`requested`/`done`)を追加(既存「メール平文を永続化しない」要件の非 PII 参照許容に整合)。 + +## Impact + +- **DB(`packages/db`)**: `users` に `stripe_connected_account_id`・`payout_onboarding_status` を追加。Webhook 冪等は既存 `stripe_events` を再利用。マイグレーション生成・コミット。 +- **API(`packages/api`)**: Stripe アダプタに Connect(account get-or-create / Account Link / 払い出し可否判定)を追加。onboarding 状態遷移の純粋ヘルパ。oRPC `payouts.createOnboardingLink` / `payouts.onboardingStatus`(`adminProc`)。`BillingConfig` に Connect 用 Webhook 署名シークレットを追加。 +- **Web/Nitro(`apps/web`)**: `POST /webhook/account-updated`(raw body + Connect 署名検証の Nitro ルート)。Account Link の refresh/return URL は `APP_ORIGIN` 配下(UI ページは後続)。 +- **設定**: `STRIPE_CONNECT_WEBHOOK_SECRET`(Connect イベント用。主 Webhook と別エンドポイントの場合に備える)を env / runtimeConfig に追加。Connect の account type は Express。 +- **テスト/検証**: 払い出し可否判定・状態遷移・get-or-create はユニットテスト(Stripe モック)。実 onboarding は Connect 有効な test mode で後日 E2E。 diff --git a/openspec/changes/archive/2026-06-20-add-connect-onboarding/specs/connect-onboarding/spec.md b/openspec/changes/archive/2026-06-20-add-connect-onboarding/specs/connect-onboarding/spec.md new file mode 100644 index 0000000..d52001a --- /dev/null +++ b/openspec/changes/archive/2026-06-20-add-connect-onboarding/specs/connect-onboarding/spec.md @@ -0,0 +1,76 @@ +## ADDED Requirements + +### Requirement: connected account の get-or-create + +システムは、受取人(`users` 行)に対し Stripe Connect の connected account を解決 SHALL:`users.stripe_connected_account_id` があれば再利用し、無ければ作成して保存する MUST。作成する account は Express とし、`metadata` に `mail_hash` を入れてよいが、対応付けの参照キーは `stripe_connected_account_id` とする。 + +#### Scenario: 既存の connected account を再利用 + +- **WHEN** `users.stripe_connected_account_id` が設定済みの受取人に onboarding を要求する +- **THEN** 新規作成せず、保存済みの connected account を使う + +#### Scenario: 無ければ作成して保存 + +- **WHEN** connected account 未連携の受取人に onboarding を要求する +- **THEN** connected account を作成し、`users.stripe_connected_account_id` に保存する + +### Requirement: ホスト型 onboarding リンクの発行 + +システムは、connected account に対し Stripe **ホスト型** onboarding リンク(Account Link)を発行 SHALL。発行時、当該受取人の `payout_onboarding_status` を `requested` に遷移する MUST(既に `done` の場合は `done` を維持)。refresh / return URL は自サイト(`APP_ORIGIN` 配下)とする。 + +#### Scenario: リンク発行で requested になる + +- **WHEN** 会計が受取人の onboarding リンク発行を要求する +- **THEN** ホスト型 onboarding リンク(URL)が返り、受取人の状態が `requested` になる + +### Requirement: onboarding の状態機械(none / requested / done) + +システムは、`payout_onboarding_status` を `none`(初期)→ `requested`(リンク発行)→ `done`(払い出し可能を検知)で管理 SHALL。`done` は終端であり、後退しては SHALL NOT。状態遷移は冪等で、同じ入力で繰り返し評価しても結果が変わらない MUST。 + +#### Scenario: 初期状態は none + +- **WHEN** まだ onboarding を開始していない受取人を参照する +- **THEN** 状態は `none` + +#### Scenario: done は維持される + +- **WHEN** 既に `done` の受取人に対して再度評価・リンク発行が起きる +- **THEN** 状態は `done` のまま後退しない + +### Requirement: account.updated による払い出し可否のフラグ判定 + +システムは、`account.updated` Webhook を受信したとき、署名検証(Connect 用署名シークレット)→ event id による冪等 → イベントの connected account を特定 → `payouts_enabled` と未提出要件(`requirements.currently_due` が空)から「払い出し可能か」を判定 SHALL。Stripe は単発の「完了」イベントを送らず `account.updated` を何度も送るため、「来たら done」ではなく**フラグで判定** MUST。払い出し可能なら `requested` を `done` に遷移する。署名が無効・欠落なら拒否(4xx)する SHALL。 + +#### Scenario: 払い出し可能で done に遷移 + +- **WHEN** `payouts_enabled` が真かつ未提出要件が無い `account.updated` を受信する +- **THEN** 対象受取人の状態が `done` に遷移する + +#### Scenario: 未完了のイベントでは done にしない + +- **WHEN** `payouts_enabled` が偽、または未提出要件が残る `account.updated` を受信する +- **THEN** 状態は `done` に遷移しない(`requested` を維持) + +#### Scenario: 同一イベントの再送は二重処理しない + +- **WHEN** 同じ event id の `account.updated` が再送される +- **THEN** 既処理として扱い、副作用を二重に起こさない + +#### Scenario: 無効署名は拒否 + +- **WHEN** 署名が欠落または不正な Webhook が届く +- **THEN** 処理せず拒否(4xx)される + +### Requirement: 発行・状態確認は会計のみ + +onboarding リンク発行と状態確認は会計(管理者)のみがアクセス可能 SHALL。認可チェックは入力検証より先に実行される MUST。 + +#### Scenario: 会計はリンク発行・状態確認できる + +- **WHEN** 会計セッションで onboarding リンク発行または状態確認を要求する +- **THEN** 操作が許可される + +#### Scenario: 非会計は拒否 + +- **WHEN** 利用者または未ログインで onboarding リンク発行を要求する +- **THEN** 認可エラーで拒否される diff --git a/openspec/changes/archive/2026-06-20-add-connect-onboarding/specs/identity/spec.md b/openspec/changes/archive/2026-06-20-add-connect-onboarding/specs/identity/spec.md new file mode 100644 index 0000000..0084a48 --- /dev/null +++ b/openspec/changes/archive/2026-06-20-add-connect-onboarding/specs/identity/spec.md @@ -0,0 +1,20 @@ +## MODIFIED Requirements + +### Requirement: メール平文を永続化しない + +システムは、isct メールの平文を DB に保存しては SHALL NOT。本人識別に必要なのは `mail_hash` のみであり、メール平文は確認メールの送信や Stripe Customer 作成など処理中の一時的な利用に限る。メール平文の保持が必要な場面(請求先など)は Stripe 側に委ねる。一方で、本人行(`users`)が **非 PII の Stripe 参照**(例: `stripe_customer_id`、`stripe_connected_account_id`)や、それに付随する**非 PII の状態**(例: `payout_onboarding_status`)を保持することは許容される MUST(これらはメール平文ではない)。 + +#### Scenario: 確認後に平文が残らない + +- **WHEN** isct メール確認が完了し本人行が作成される +- **THEN** `users` 行にはメール平文を保持する列が存在せず、識別は `mail_hash` で行われる + +#### Scenario: Stripe 参照は保持してよい + +- **WHEN** 集金フローで Customer が作成され `stripe_customer_id` が保存される +- **THEN** `users` 行は `stripe_customer_id`(非 PII の参照)を保持できるが、メール平文の列は持たない + +#### Scenario: 払い戻しの Connect 参照・状態も保持してよい + +- **WHEN** 払い戻しの onboarding で connected account が作成され `stripe_connected_account_id` と `payout_onboarding_status` が保存される +- **THEN** `users` 行はこれら非 PII の参照・状態を保持できるが、メール平文の列は持たない diff --git a/openspec/changes/archive/2026-06-20-add-connect-onboarding/tasks.md b/openspec/changes/archive/2026-06-20-add-connect-onboarding/tasks.md new file mode 100644 index 0000000..aee18a0 --- /dev/null +++ b/openspec/changes/archive/2026-06-20-add-connect-onboarding/tasks.md @@ -0,0 +1,37 @@ +## 1. データモデル + +- [x] 1.1 `users` に `stripe_connected_account_id varchar(255)` nullable を追加 +- [x] 1.2 `users` に `payout_onboarding_status` enum(`none`,`requested`,`done`) NOT NULL default `none` を追加 +- [x] 1.3 `pnpm db:generate` で `0002_*` 生成・コミット(既存 0000/0001 baseline 保持、列追加 ALTER、rename 誤検出に注意) + +## 2. Stripe Connect アダプタ(packages/api/src/stripe/connect.ts) + +- [x] 2.1 `getOrCreateConnectedAccount`(Express account 作成 / 再利用 / `stripe_connected_account_id` 保存、`metadata.mail_hash`) +- [x] 2.2 `createAccountOnboardingLink`(Account Link `type:'account_onboarding'`、refresh/return URL) +- [x] 2.3 lazy Stripe client を使用。stripe/index.ts から export。Stripe 型はアダプタ内に閉じ込める + +## 3. onboarding 状態ロジック(Stripe 非依存, packages/api/src/payouts/onboarding.ts) + +- [x] 3.1 `isPayoutsReady(account)`(`payouts_enabled` + `requirements.currently_due` 空、狭い構造入力) +- [x] 3.2 `nextOnboardingStatus(current, ready)`(`done` 終端・冪等) +- [x] 3.3 `BillingConfig`(または PayoutConfig)に `connectWebhookSecret` を追加し解決経路を用意 + +## 4. oRPC(会計のみ) + +- [x] 4.1 `payouts.createOnboardingLink`(`adminProc`+`assertCsrf`、`{userId}`): account get-or-create → Account Link → status を(done でなければ)requested に。`{url}` 返却 +- [x] 4.2 `payouts.onboardingStatus`(`adminProc`、`{userId}`): `{status, hasConnectedAccount}` + +## 5. account.updated Webhook(Nitro) + +- [x] 5.1 `apps/web/server/routes/webhook/account-updated.post.ts`: raw body+Connect 署名検証 +- [x] 5.2 冪等(`hasProcessedStripeEvent`→処理→`recordStripeEventOnce`)。対象 connected account で `users` 特定 +- [x] 5.3 `isPayoutsReady` 真かつ非 done なら `done` に遷移。偽/該当無しは状態変更なし(200)。無効署名は 4xx + +## 6. 設定・検証 + +- [x] 6.1 `.env.example` に `STRIPE_CONNECT_WEBHOOK_SECRET` 追記、`nuxt.config` runtimeConfig に対応キー追加 +- [x] 6.2 ユニットテスト: `isPayoutsReady`(可/不可)、`nextOnboardingStatus`(none/requested/done・冪等)、get-or-create(モック) +- [x] 6.3 `pnpm lint` / `pnpm typecheck` / `pnpm build` / `pnpm test` をグリーンにする +- [ ] 6.4 (Connect 有効 test mode 後)onboarding リンク→完了→`account.updated`→`done` の E2E + - BLOCKED: Connect/Stripe のテストキー(Connect 有効な test mode)が無いため実行不可。判定・遷移・get-or-create はユニットで担保済み。キー入手後に sandbox E2E を実施する。 +- [x] 6.5 Codex review fixes: get-or-create を race-safe 化(DB 再読込→条件付き compare-and-set `claimConnectedAccountId`→敗者は孤児 account を best-effort 削除して勝者 id を再利用)、`users.stripe_connected_account_id` に UNIQUE 制約(migration `0003_happy_cobalt_man.sql`)、条件付き書込の duplicate-key を防御的に再読込で吸収 diff --git a/openspec/specs/connect-onboarding/spec.md b/openspec/specs/connect-onboarding/spec.md new file mode 100644 index 0000000..042fddb --- /dev/null +++ b/openspec/specs/connect-onboarding/spec.md @@ -0,0 +1,87 @@ +# connect-onboarding + +## Purpose + +払い戻し(transfer/payout)の前提となる Stripe Connect connected account の just-in-time onboarding。受取人ごとに connected account を get-or-create し、ホスト型 onboarding リンクを発行し、`account.updated` Webhook のフラグ判定で払い出し可否を見極めて状態(`none`/`requested`/`done`)を冪等に進める。実送金・Jomon 連携は別 capability。 + +## Requirements + +### Requirement: connected account の get-or-create + +システムは、受取人(`users` 行)に対し Stripe Connect の connected account を解決 SHALL:`users.stripe_connected_account_id` があれば再利用し、無ければ作成して保存する MUST。作成する account は Express とし、`metadata` に `mail_hash` を入れてよいが、対応付けの参照キーは `stripe_connected_account_id` とする。保存は競合時にも重複・上書きを起こさない(条件付き compare-and-set で、敗者は自分が作った余分な account を後始末する)MUST。`stripe_connected_account_id` は `users` で unique である MUST。 + +#### Scenario: 既存の connected account を再利用 + +- **WHEN** `users.stripe_connected_account_id` が設定済みの受取人に onboarding を要求する +- **THEN** 新規作成せず、保存済みの connected account を使う + +#### Scenario: 無ければ作成して保存 + +- **WHEN** connected account 未連携の受取人に onboarding を要求する +- **THEN** connected account を作成し、`users.stripe_connected_account_id` に保存する + +#### Scenario: 競合しても重複を残さない + +- **WHEN** 同一受取人に対して onboarding 発行が同時並行で起きる +- **THEN** 永続化される connected account は 1 つだけで、敗者側が作成した余分な account は後始末される + +### Requirement: ホスト型 onboarding リンクの発行 + +システムは、connected account に対し Stripe **ホスト型** onboarding リンク(Account Link)を発行 SHALL。発行時、当該受取人の `payout_onboarding_status` を `requested` に遷移する MUST(既に `done` の場合は `done` を維持)。refresh / return URL は自サイト(`APP_ORIGIN` 配下)とする。 + +#### Scenario: リンク発行で requested になる + +- **WHEN** 会計が受取人の onboarding リンク発行を要求する +- **THEN** ホスト型 onboarding リンク(URL)が返り、受取人の状態が `requested` になる + +### Requirement: onboarding の状態機械(none / requested / done) + +システムは、`payout_onboarding_status` を `none`(初期)→ `requested`(リンク発行)→ `done`(払い出し可能を検知)で管理 SHALL。`done` は終端であり、後退しては SHALL NOT。状態遷移は冪等で、同じ入力で繰り返し評価しても結果が変わらない MUST。 + +#### Scenario: 初期状態は none + +- **WHEN** まだ onboarding を開始していない受取人を参照する +- **THEN** 状態は `none` + +#### Scenario: done は維持される + +- **WHEN** 既に `done` の受取人に対して再度評価・リンク発行が起きる +- **THEN** 状態は `done` のまま後退しない + +### Requirement: account.updated による払い出し可否のフラグ判定 + +システムは、`account.updated` Webhook を受信したとき、署名検証(Connect 用署名シークレット)→ event id による冪等 → イベントの connected account を特定 → `payouts_enabled` と未提出要件(`requirements.currently_due` が空)から「払い出し可能か」を判定 SHALL。Stripe は単発の「完了」イベントを送らず `account.updated` を何度も送るため、「来たら done」ではなく**フラグで判定** MUST。払い出し可能なら `requested` を `done` に遷移する。署名が無効・欠落なら拒否(4xx)する SHALL。該当 account の受取人が存在しなければ無視(200)する。 + +#### Scenario: 払い出し可能で done に遷移 + +- **WHEN** `payouts_enabled` が真かつ未提出要件が無い `account.updated` を受信する +- **THEN** 対象受取人の状態が `done` に遷移する + +#### Scenario: 未完了のイベントでは done にしない + +- **WHEN** `payouts_enabled` が偽、または未提出要件が残る `account.updated` を受信する +- **THEN** 状態は `done` に遷移しない(`requested` を維持) + +#### Scenario: 同一イベントの再送は二重処理しない + +- **WHEN** 同じ event id の `account.updated` が再送される +- **THEN** 既処理として扱い、副作用を二重に起こさない + +#### Scenario: 無効署名は拒否 + +- **WHEN** 署名が欠落または不正な Webhook が届く +- **THEN** 処理せず拒否(4xx)される + +### Requirement: 発行・状態確認は会計のみ + +onboarding リンク発行と状態確認は会計(管理者)のみがアクセス可能 SHALL。認可チェックは入力検証より先に実行される MUST。状態変更を伴う発行は CSRF 検証を行う MUST。 + +#### Scenario: 会計はリンク発行・状態確認できる + +- **WHEN** 会計セッションで onboarding リンク発行または状態確認を要求する +- **THEN** 操作が許可される + +#### Scenario: 非会計は拒否 + +- **WHEN** 利用者または未ログインで onboarding リンク発行を要求する +- **THEN** 認可エラーで拒否される diff --git a/openspec/specs/identity/spec.md b/openspec/specs/identity/spec.md index c952f4b..fa67b9b 100644 --- a/openspec/specs/identity/spec.md +++ b/openspec/specs/identity/spec.md @@ -36,7 +36,7 @@ ### Requirement: メール平文を永続化しない -システムは、isct メールの平文を DB に保存しては SHALL NOT。本人識別に必要なのは `mail_hash` のみであり、メール平文は確認メールの送信や Stripe Customer 作成など処理中の一時的な利用に限る。メール平文の保持が必要な場面(請求先など)は Stripe 側に委ねる。一方で、本人行(`users`)が **非 PII の Stripe 参照**(例: `stripe_customer_id`)を保持することは許容される MUST(これらはメール平文ではない)。 +システムは、isct メールの平文を DB に保存しては SHALL NOT。本人識別に必要なのは `mail_hash` のみであり、メール平文は確認メールの送信や Stripe Customer 作成など処理中の一時的な利用に限る。メール平文の保持が必要な場面(請求先など)は Stripe 側に委ねる。一方で、本人行(`users`)が **非 PII の Stripe 参照**(例: `stripe_customer_id`、`stripe_connected_account_id`)や、それに付随する**非 PII の状態**(例: `payout_onboarding_status`)を保持することは許容される MUST(これらはメール平文ではない)。 #### Scenario: 確認後に平文が残らない @@ -48,6 +48,11 @@ - **WHEN** 集金フローで Customer が作成され `stripe_customer_id` が保存される - **THEN** `users` 行は `stripe_customer_id`(非 PII の参照)を保持できるが、メール平文の列は持たない +#### Scenario: 払い戻しの Connect 参照・状態も保持してよい + +- **WHEN** 払い戻しの onboarding で connected account が作成され `stripe_connected_account_id` と `payout_onboarding_status` が保存される +- **THEN** `users` 行はこれら非 PII の参照・状態を保持できるが、メール平文の列は持たない + ### Requirement: 本人行の get-or-create システムは、確認済みの `mail_hash` に対して本人行を取得し、無ければ作成する操作を提供 SHALL。作成は冪等であり、競合時にも `mail_hash` の unique 制約により重複行を作らない MUST。 diff --git a/packages/api/src/auth/identity.ts b/packages/api/src/auth/identity.ts index 94ddc17..c1309ce 100644 --- a/packages/api/src/auth/identity.ts +++ b/packages/api/src/auth/identity.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'node:crypto' -import { eq } from 'drizzle-orm' +import { and, eq, isNull } from 'drizzle-orm' import { schema, type Database } from '@checkin/db' export interface UserRow { @@ -7,20 +7,27 @@ export interface UserRow { mailHash: string } -/** A person row including its (nullable) Stripe Customer link — for billing. */ +/** + * A person row including its (nullable) Stripe Customer link and Connect payout + * fields — for billing and payouts. (identity spec: §Stripe 参照・状態は保持してよい) + */ export interface BillingUserRow { id: string mailHash: string stripeCustomerId: string | null + stripeConnectedAccountId: string | null + payoutOnboardingStatus: 'none' | 'requested' | 'done' } -/** Fetch a person row by id, including its Stripe Customer link, or null. */ +/** Fetch a person row by id, including its Stripe links/state, or null. */ export async function getUserById(db: Database, id: string): Promise { const [row] = await db .select({ id: schema.users.id, mailHash: schema.users.mailHash, stripeCustomerId: schema.users.stripeCustomerId, + stripeConnectedAccountId: schema.users.stripeConnectedAccountId, + payoutOnboardingStatus: schema.users.payoutOnboardingStatus, }) .from(schema.users) .where(eq(schema.users.id, id)) @@ -28,6 +35,66 @@ export async function getUserById(db: Database, id: string): Promise { + const [row] = await db + .select({ + id: schema.users.id, + mailHash: schema.users.mailHash, + stripeCustomerId: schema.users.stripeCustomerId, + stripeConnectedAccountId: schema.users.stripeConnectedAccountId, + payoutOnboardingStatus: schema.users.payoutOnboardingStatus, + }) + .from(schema.users) + .where(eq(schema.users.stripeConnectedAccountId, accountId)) + .limit(1) + return row ?? null +} + +/** + * Race-safe compare-and-set of a person's connected-account id: only links it + * when the column is still NULL. Returns whether *this* call won the claim — a + * concurrent caller that set it first leaves us with `false` (no overwrite of an + * already-linked account). The `unique` constraint on the column backs this up + * as a last line of defence. (connect-onboarding spec: §connected account の get-or-create) + */ +export async function claimConnectedAccountId( + db: Database, + userId: string, + accountId: string, +): Promise { + // UPDATE ... WHERE id = ? AND stripe_connected_account_id IS NULL + const result = await db + .update(schema.users) + .set({ stripeConnectedAccountId: accountId }) + .where(and( + eq(schema.users.id, userId), + isNull(schema.users.stripeConnectedAccountId), + )) + + // mysql2 returns affectedRows; 1 ⇒ we set it, 0 ⇒ someone else already did. + const affected = (result as unknown as { affectedRows?: number })?.affectedRows ?? 0 + const claimed = Array.isArray(result) + ? ((result[0] as { affectedRows?: number })?.affectedRows ?? 0) + : affected + return claimed > 0 +} + +/** Update a person's payout onboarding status (none / requested / done). */ +export async function setPayoutOnboardingStatus( + db: Database, + userId: string, + status: 'none' | 'requested' | 'done', +): Promise { + await db + .update(schema.users) + .set({ payoutOnboardingStatus: status }) + .where(eq(schema.users.id, userId)) +} + /** * Get the person row for a `mail_hash`, creating it if absent. Idempotent and * race-safe: the `INSERT ... ON DUPLICATE KEY UPDATE` no-op plus the unique diff --git a/packages/api/src/billing/billing.test.ts b/packages/api/src/billing/billing.test.ts index 33174f3..759e11e 100644 --- a/packages/api/src/billing/billing.test.ts +++ b/packages/api/src/billing/billing.test.ts @@ -13,6 +13,7 @@ const config: BillingConfig = { }, stripeSecretKey: 'sk_test_x', stripeWebhookSecret: 'whsec_x', + connectWebhookSecret: 'whsec_connect_x', invoiceDaysUntilDue: 7, } diff --git a/packages/api/src/billing/config.ts b/packages/api/src/billing/config.ts index a6e1bc6..b37399a 100644 --- a/packages/api/src/billing/config.ts +++ b/packages/api/src/billing/config.ts @@ -20,6 +20,12 @@ export interface BillingConfig { stripeSecretKey: string /** Stripe webhook signing secret for `invoice.paid` verification. */ stripeWebhookSecret: string + /** + * Stripe webhook signing secret for Connect `account.updated` verification. + * May differ from {@link stripeWebhookSecret} when Connect events use a + * separate endpoint (set the same value if they share one). (design D6) + */ + connectWebhookSecret: string /** Days until an issued invoice is due (`send_invoice` collection). */ invoiceDaysUntilDue: number } diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index d2857b2..595b670 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -12,6 +12,9 @@ export * from './billing' /** Payments-listing domain (Stripe-independent: row DTOs / Dashboard URL / pagination). */ export * from './payments' +/** Payout onboarding domain (Stripe-independent: readiness + state machine). */ +export * from './payouts' + /** Stripe adapter layer (all Stripe SDK usage is isolated here). */ export * from './stripe' diff --git a/packages/api/src/payouts/index.ts b/packages/api/src/payouts/index.ts new file mode 100644 index 0000000..cb00e1b --- /dev/null +++ b/packages/api/src/payouts/index.ts @@ -0,0 +1 @@ +export * from './onboarding' diff --git a/packages/api/src/payouts/onboarding.test.ts b/packages/api/src/payouts/onboarding.test.ts new file mode 100644 index 0000000..6bb694c --- /dev/null +++ b/packages/api/src/payouts/onboarding.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' +import { isPayoutsReady, nextOnboardingStatus, type PayoutsReadyAccount } from './onboarding' + +describe('isPayoutsReady', () => { + it('is true when payouts are enabled and no requirements are due', () => { + expect(isPayoutsReady({ payouts_enabled: true, requirements: { currently_due: [] } })) + .toBe(true) + }) + + it('is false when payouts are disabled (even with no requirements due)', () => { + expect(isPayoutsReady({ payouts_enabled: false, requirements: { currently_due: [] } })) + .toBe(false) + }) + + it('is false when requirements are still due (even with payouts enabled)', () => { + expect(isPayoutsReady({ + payouts_enabled: true, + requirements: { currently_due: ['external_account'] }, + })).toBe(false) + }) + + it('treats missing requirements as no requirements due', () => { + expect(isPayoutsReady({ payouts_enabled: true })).toBe(true) + expect(isPayoutsReady({ payouts_enabled: true, requirements: null })).toBe(true) + expect(isPayoutsReady({ payouts_enabled: true, requirements: { currently_due: null } })) + .toBe(true) + }) + + it('is false when not enabled and requirements are due', () => { + const account: PayoutsReadyAccount = { + payouts_enabled: false, + requirements: { currently_due: ['external_account'] }, + } + expect(isPayoutsReady(account)).toBe(false) + }) +}) + +describe('nextOnboardingStatus', () => { + it('keeps none as none when not ready', () => { + expect(nextOnboardingStatus('none', false)).toBe('none') + }) + + it('keeps requested as requested when not ready', () => { + expect(nextOnboardingStatus('requested', false)).toBe('requested') + }) + + it('advances requested to done when ready', () => { + expect(nextOnboardingStatus('requested', true)).toBe('done') + }) + + it('advances none to done when ready', () => { + expect(nextOnboardingStatus('none', true)).toBe('done') + }) + + it('keeps done as done regardless of readiness (terminal, no regress)', () => { + expect(nextOnboardingStatus('done', false)).toBe('done') + expect(nextOnboardingStatus('done', true)).toBe('done') + }) + + it('is idempotent — re-applying the same transition yields the same result', () => { + const onceReady = nextOnboardingStatus('requested', true) + expect(nextOnboardingStatus(onceReady, true)).toBe('done') + expect(nextOnboardingStatus(onceReady, false)).toBe('done') + + const onceNone = nextOnboardingStatus('none', false) + expect(nextOnboardingStatus(onceNone, false)).toBe('none') + }) +}) diff --git a/packages/api/src/payouts/onboarding.ts b/packages/api/src/payouts/onboarding.ts new file mode 100644 index 0000000..83aca9a --- /dev/null +++ b/packages/api/src/payouts/onboarding.ts @@ -0,0 +1,59 @@ +/** + * Payout onboarding state — pure, Stripe-independent logic. + * + * This module must NOT call the Stripe SDK (a type-only import is fine). It + * receives a narrow structural view of a connected account and decides payout + * readiness / the next onboarding status, so the rules are trivially testable + * and the Stripe boundary stays in `stripe/connect.ts`. (design D3) + */ + +/** The three states of the onboarding state machine. */ +export type PayoutOnboardingStatus = 'none' | 'requested' | 'done' + +/** + * The narrow shape `isPayoutsReady` reads from a connected account. The adapter + * (or a webhook event) maps the Stripe `Account` down to this so the domain + * never sees a Stripe type. + */ +export interface PayoutsReadyAccount { + payouts_enabled: boolean + requirements?: { currently_due?: string[] | null } | null +} + +/** + * Whether the connected account can receive payouts: payouts are enabled AND + * there are no outstanding (currently due) requirements. Stripe sends no single + * "completed" event, so readiness is decided by these flags, not by event arrival. + * (connect-onboarding spec: §account.updated による払い出し可否のフラグ判定) + */ +export function isPayoutsReady(account: PayoutsReadyAccount): boolean { + return account.payouts_enabled === true + && (account.requirements?.currently_due ?? []).length === 0 +} + +/** + * Next onboarding status given the current status and computed readiness. + * + * Rules (connect-onboarding spec: §onboarding の状態機械): + * - `done` is terminal — never regress. + * - `ready` ⇒ `done`. + * - otherwise keep `none` as `none`, and `requested` (or any started state) + * as `requested`. + * + * Idempotent: re-evaluating with the same inputs yields the same result. + */ +export function nextOnboardingStatus( + current: PayoutOnboardingStatus, + ready: boolean, +): PayoutOnboardingStatus { + // `done` is terminal and must not regress. + if (current === 'done') { + return 'done' + } + if (ready) { + return 'done' + } + // Not ready: preserve the (non-terminal) current state. `none` stays `none` + // until a link is issued; anything already started stays `requested`. + return current === 'none' ? 'none' : 'requested' +} diff --git a/packages/api/src/router.ts b/packages/api/src/router.ts index c96fb14..6b45c6d 100644 --- a/packages/api/src/router.ts +++ b/packages/api/src/router.ts @@ -7,9 +7,18 @@ import { sanitizeRedirect, createEmailVerification, getUserById, + setPayoutOnboardingStatus, } from './auth' import { computeActivityYear, computeTerm, selectPriceId } from './billing' -import { getOrCreateCustomer, issueInvoice, listCheckoutSessions, listInvoices } from './stripe' +import { + createAccountOnboardingLink, + getOrCreateConnectedAccount, + getOrCreateCustomer, + issueInvoice, + listCheckoutSessions, + listInvoices, +} from './stripe' +import { nextOnboardingStatus } from './payouts' import { checkoutSessionToRow, clampLimit, @@ -252,4 +261,67 @@ export const appRouter = { return { items, hasMore: page.has_more, nextCursor: nextCursor(items, page.has_more) } }), }, + + payouts: { + /** + * Issue a Stripe **hosted** onboarding link for a payee's Connect connected + * account so refunds can later pay out to them. Admin (accountant) only: + * the accountant gets the URL and forwards it to the payee (we don't hold + * plaintext email). Get-or-create the connected account, create the Account + * Link, then mark the payee `requested` (unless already `done`). + * (connect-onboarding spec: §ホスト型 onboarding リンクの発行 / §発行は会計のみ) + */ + createOnboardingLink: adminProc + .input(z.object({ userId: z.string().min(1) })) + .handler(async ({ input, context }) => { + context.assertCsrf() + + const row = await getUserById(context.db, input.userId) + if (!row) { + throw new ORPCError('NOT_FOUND', { message: 'target user not found' }) + } + + const accountId = await getOrCreateConnectedAccount(context.stripe, context.db, { + userId: row.id, + stripeConnectedAccountId: row.stripeConnectedAccountId, + mailHash: row.mailHash, + }) + + // refresh/return land on app-origin pages (the UI is a later change). + const url = await createAccountOnboardingLink(context.stripe, { + accountId, + refreshUrl: `${context.config.appOrigin}/payouts/onboarding/refresh`, + returnUrl: `${context.config.appOrigin}/payouts/onboarding/return`, + }) + + // Issuing a link advances toward `requested`; `done` is terminal (no + // regress). `nextOnboardingStatus(_, false)` keeps `done`/`requested` and + // only `none` would stay `none` — so promote a non-`done` payee to + // `requested` (skips a redundant write when already `requested`). + const stayed = nextOnboardingStatus(row.payoutOnboardingStatus, false) + if (stayed !== 'done' && row.payoutOnboardingStatus !== 'requested') { + await setPayoutOnboardingStatus(context.db, row.id, 'requested') + } + + return { url } + }), + + /** + * Report a payee's onboarding status (and whether a connected account is + * linked yet) for the accountant. Read-only: admin, no `assertCsrf`. + * (connect-onboarding spec: §状態確認は会計のみ) + */ + onboardingStatus: adminProc + .input(z.object({ userId: z.string().min(1) })) + .handler(async ({ input, context }) => { + const row = await getUserById(context.db, input.userId) + if (!row) { + throw new ORPCError('NOT_FOUND', { message: 'target user not found' }) + } + return { + status: row.payoutOnboardingStatus, + hasConnectedAccount: row.stripeConnectedAccountId !== null, + } + }), + }, } diff --git a/packages/api/src/stripe/connect.test.ts b/packages/api/src/stripe/connect.test.ts new file mode 100644 index 0000000..4a5ba43 --- /dev/null +++ b/packages/api/src/stripe/connect.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import { isDuplicateKeyError } from './connect' + +describe('isDuplicateKeyError', () => { + it('matches mysql2 duplicate-entry by code', () => { + expect(isDuplicateKeyError({ code: 'ER_DUP_ENTRY' })).toBe(true) + }) + + it('matches mysql2 duplicate-entry by errno (1062)', () => { + expect(isDuplicateKeyError({ errno: 1062 })).toBe(true) + }) + + it('is false for unrelated errors and non-error values', () => { + expect(isDuplicateKeyError(new Error('boom'))).toBe(false) + expect(isDuplicateKeyError({ code: 'ER_NO_SUCH_TABLE', errno: 1146 })).toBe(false) + expect(isDuplicateKeyError(null)).toBe(false) + expect(isDuplicateKeyError(undefined)).toBe(false) + }) +}) diff --git a/packages/api/src/stripe/connect.ts b/packages/api/src/stripe/connect.ts new file mode 100644 index 0000000..225c316 --- /dev/null +++ b/packages/api/src/stripe/connect.ts @@ -0,0 +1,193 @@ +import type Stripe from 'stripe' +import type { Database } from '@checkin/db' +import { claimConnectedAccountId, getUserById } from '../auth/identity' +import type { StripeClient } from './client' + +/** Inputs to resolve (and possibly create) the Connect connected account for a payee. */ +export interface ResolveConnectedAccountInput { + /** The person row id (`users.id`) whose `stripe_connected_account_id` we read/persist. */ + userId: string + /** + * The caller's snapshot of `users.stripe_connected_account_id` (or null). Kept + * for callers' convenience/logging only — it is NOT trusted: we always re-read + * the column fresh from the DB before deciding to create, to stay race-safe. + */ + stripeConnectedAccountId: string | null + /** Non-PII label for log/dashboard readability; never used as a lookup key. */ + mailHash: string +} + +/** + * Get-or-create the Stripe Connect connected account for a payee and persist its id. + * + * Resolution order (connect-onboarding spec: §connected account の get-or-create): + * 1. Re-read `users.stripe_connected_account_id` FRESH — present → reuse it. + * 2. Else create an Express account (`metadata.mail_hash`) and CONDITIONALLY + * link it (`... WHERE id = ? AND stripe_connected_account_id IS NULL`). + * + * Race safety: two concurrent calls for the same payee may both pass step 1 and + * each create a Stripe account. The conditional link in step 2 lets exactly one + * win; the loser re-reads the winning id and best-effort deletes its orphaned + * account so we never overwrite a stored id or leak an unused Stripe account. + * + * The connected account id is the sole reference key — `mail_hash` is metadata + * only. No plaintext email is involved. (D2) + */ +export async function getOrCreateConnectedAccount( + stripe: StripeClient, + db: Database, + input: ResolveConnectedAccountInput, +): Promise { + // 1. Reuse the linked connected account if one is already persisted. Re-read + // fresh (don't trust the caller's snapshot) so a concurrent link is seen. + const existing = await readConnectedAccountId(db, input.userId) + if (existing) { + return existing + } + + // 2. Create an Express account (Stripe-managed hosted onboarding). + const created = await stripe.sdk.accounts.create({ + type: 'express', + metadata: { mail_hash: input.mailHash }, + }) + + // Conditional compare-and-set: only link if the column is still NULL. The + // `unique` constraint on the column backs this up — should the IS NULL guard + // ever be lost to a race we treat the duplicate-key error as "lost the claim". + let won: boolean + try { + won = await claimConnectedAccountId(db, input.userId, created.id) + } + catch (err) { + if (!isDuplicateKeyError(err)) { + throw err + } + won = false + } + if (won) { + return created.id + } + + // We lost the race: another request linked an account first. Re-read the + // winning id, then best-effort delete our just-created orphan (deletion is + // allowed for unused platform-created Express accounts) and reuse the winner. + const winning = await readConnectedAccountId(db, input.userId) + await deleteOrphanAccount(stripe, created.id) + if (!winning) { + // Shouldn't happen (the claim lost ⇒ a row was linked), but never invent an + // id: surface it rather than returning the orphan we just deleted. + throw new Error('connected account claim lost but no linked account found') + } + return winning +} + +/** Re-read the persisted connected-account id for a person, or null. */ +async function readConnectedAccountId(db: Database, userId: string): Promise { + const row = await getUserById(db, userId) + return row?.stripeConnectedAccountId ?? null +} + +/** + * Best-effort delete of a connected account we created but failed to link (lost + * the race). Swallows errors: a leftover unused account is harmless and must not + * fail the caller's onboarding. + */ +async function deleteOrphanAccount(stripe: StripeClient, accountId: string): Promise { + try { + await stripe.sdk.accounts.del(accountId) + } + catch { + // Deletion is a cleanup nicety, not a correctness requirement — ignore. + } +} + +/** Whether an error is a MySQL/MariaDB duplicate-key (unique violation). */ +export function isDuplicateKeyError(err: unknown): boolean { + const code = (err as { code?: string })?.code + const errno = (err as { errno?: number })?.errno + return code === 'ER_DUP_ENTRY' || errno === 1062 +} + +/** Inputs for issuing a hosted Account Link for connected-account onboarding. */ +export interface CreateAccountOnboardingLinkInput { + /** Target connected account id (resolved via get-or-create). */ + accountId: string + /** URL Stripe sends the payee to if the link expires / needs refreshing. */ + refreshUrl: string + /** URL Stripe returns the payee to after the hosted onboarding flow. */ + returnUrl: string +} + +/** + * Create a Stripe **hosted** onboarding link (Account Link) for a connected + * account. The returned URL is the Stripe-managed onboarding page; refresh / + * return URLs are our own (`APP_ORIGIN` 配下). + * (connect-onboarding spec: §ホスト型 onboarding リンクの発行) + */ +export async function createAccountOnboardingLink( + stripe: StripeClient, + input: CreateAccountOnboardingLinkInput, +): Promise { + const link = await stripe.sdk.accountLinks.create({ + account: input.accountId, + type: 'account_onboarding', + refresh_url: input.refreshUrl, + return_url: input.returnUrl, + }) + return link.url +} + +/** + * A verified `account.updated` event reduced to what the webhook handler needs. + * The account object is narrowed to the payouts-relevant flags so the `payouts` + * domain never depends on the Stripe SDK (it receives this structural shape). + */ +export interface VerifiedAccountEvent { + id: string + type: string + /** The connected account id this event is about (`event.account` ?? object id). */ + accountId: string | null + /** The connected account object carried by the event. */ + account: { + payouts_enabled: boolean + requirements?: { currently_due?: string[] | null } | null + } +} + +/** + * Verify a Connect webhook signature over the raw body and return the decoded + * `account.updated` event, narrowed to the payouts-relevant shape. Throws when + * the signature is missing/invalid — callers map that to a 4xx. The raw + * (unparsed) body is required for the HMAC to match. + * + * Kept here (not in webhook.ts) so the full Stripe `Account` type stays inside + * the adapter boundary while the handler/`payouts` domain see only structural data. + */ +export function constructAccountEvent( + stripe: StripeClient, + rawBody: string | Buffer, + signature: string | undefined, + webhookSecret: string, +): VerifiedAccountEvent { + if (!signature) { + throw new Error('missing Stripe-Signature header') + } + if (!webhookSecret) { + throw new Error('STRIPE_CONNECT_WEBHOOK_SECRET is not set; refusing to verify webhook') + } + const event: Stripe.Event = stripe.sdk.webhooks.constructEvent(rawBody, signature, webhookSecret) + const object = event.data.object as Stripe.Account + return { + id: event.id, + type: event.type, + // `event.account` is the connected account for Connect events; fall back to + // the object id for direct-account deliveries. + accountId: event.account ?? object.id ?? null, + account: { + payouts_enabled: object.payouts_enabled ?? false, + requirements: object.requirements + ? { currently_due: object.requirements.currently_due } + : null, + }, + } +} diff --git a/packages/api/src/stripe/index.ts b/packages/api/src/stripe/index.ts index a90a77a..dc8a734 100644 --- a/packages/api/src/stripe/index.ts +++ b/packages/api/src/stripe/index.ts @@ -1,4 +1,5 @@ export * from './client' +export * from './connect' export * from './customers' export * from './invoices' export * from './listing' diff --git a/packages/db/drizzle/0002_gifted_pyro.sql b/packages/db/drizzle/0002_gifted_pyro.sql new file mode 100644 index 0000000..350c31e --- /dev/null +++ b/packages/db/drizzle/0002_gifted_pyro.sql @@ -0,0 +1,2 @@ +ALTER TABLE `users` ADD `stripe_connected_account_id` varchar(255);--> statement-breakpoint +ALTER TABLE `users` ADD `payout_onboarding_status` enum('none','requested','done') DEFAULT 'none' NOT NULL; \ No newline at end of file diff --git a/packages/db/drizzle/0003_happy_cobalt_man.sql b/packages/db/drizzle/0003_happy_cobalt_man.sql new file mode 100644 index 0000000..c7937d5 --- /dev/null +++ b/packages/db/drizzle/0003_happy_cobalt_man.sql @@ -0,0 +1 @@ +ALTER TABLE `users` ADD CONSTRAINT `users_stripe_connected_account_id_unique` UNIQUE(`stripe_connected_account_id`); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0002_snapshot.json b/packages/db/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..45ea9d3 --- /dev/null +++ b/packages/db/drizzle/meta/0002_snapshot.json @@ -0,0 +1,310 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "beea9b68-94a0-45e6-bd40-648c02a0d0b3", + "prevId": "cc721e21-f36d-4104-9dd4-742376c7ae6c", + "tables": { + "email_verifications": { + "name": "email_verifications", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mail_hash": { + "name": "mail_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect": { + "name": "redirect", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "email_verifications_id": { + "name": "email_verifications_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "email_verifications_token_hash_unique": { + "name": "email_verifications_token_hash_unique", + "columns": [ + "token_hash" + ] + } + }, + "checkConstraint": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id_hash": { + "name": "id_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_type": { + "name": "actor_type", + "type": "enum('user','admin')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "traq_id": { + "name": "traq_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sessions_id": { + "name": "sessions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "sessions_id_hash_unique": { + "name": "sessions_id_hash_unique", + "columns": [ + "id_hash" + ] + } + }, + "checkConstraint": {} + }, + "stripe_events": { + "name": "stripe_events", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "received_at": { + "name": "received_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "stripe_events_id": { + "name": "stripe_events_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "stripe_events_event_id_unique": { + "name": "stripe_events_event_id_unique", + "columns": [ + "event_id" + ] + } + }, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mail_hash": { + "name": "mail_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripe_connected_account_id": { + "name": "stripe_connected_account_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payout_onboarding_status": { + "name": "payout_onboarding_status", + "type": "enum('none','requested','done')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'none'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "users_mail_hash_unique": { + "name": "users_mail_hash_unique", + "columns": [ + "mail_hash" + ] + } + }, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/0003_snapshot.json b/packages/db/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..31987b0 --- /dev/null +++ b/packages/db/drizzle/meta/0003_snapshot.json @@ -0,0 +1,316 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "7e4eab66-6c1b-4005-8ffd-8fc690c1101c", + "prevId": "beea9b68-94a0-45e6-bd40-648c02a0d0b3", + "tables": { + "email_verifications": { + "name": "email_verifications", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mail_hash": { + "name": "mail_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect": { + "name": "redirect", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "email_verifications_id": { + "name": "email_verifications_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "email_verifications_token_hash_unique": { + "name": "email_verifications_token_hash_unique", + "columns": [ + "token_hash" + ] + } + }, + "checkConstraint": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id_hash": { + "name": "id_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_type": { + "name": "actor_type", + "type": "enum('user','admin')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "traq_id": { + "name": "traq_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sessions_id": { + "name": "sessions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "sessions_id_hash_unique": { + "name": "sessions_id_hash_unique", + "columns": [ + "id_hash" + ] + } + }, + "checkConstraint": {} + }, + "stripe_events": { + "name": "stripe_events", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "received_at": { + "name": "received_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "stripe_events_id": { + "name": "stripe_events_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "stripe_events_event_id_unique": { + "name": "stripe_events_event_id_unique", + "columns": [ + "event_id" + ] + } + }, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mail_hash": { + "name": "mail_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripe_connected_account_id": { + "name": "stripe_connected_account_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payout_onboarding_status": { + "name": "payout_onboarding_status", + "type": "enum('none','requested','done')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'none'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "users_mail_hash_unique": { + "name": "users_mail_hash_unique", + "columns": [ + "mail_hash" + ] + }, + "users_stripe_connected_account_id_unique": { + "name": "users_stripe_connected_account_id_unique", + "columns": [ + "stripe_connected_account_id" + ] + } + }, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 2d955ac..914ab72 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -15,6 +15,20 @@ "when": 1781877502412, "tag": "0001_naive_stryfe", "breakpoints": true + }, + { + "idx": 2, + "version": "5", + "when": 1781882206587, + "tag": "0002_gifted_pyro", + "breakpoints": true + }, + { + "idx": 3, + "version": "5", + "when": 1781882910210, + "tag": "0003_happy_cobalt_man", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 17f5cdf..cca70ee 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -17,6 +17,21 @@ export const users = mysqlTable('users', { // so subsequent invoices reuse the same Customer. Plaintext email is still // never stored. (identity spec: §Stripe 参照は保持してよい) stripeCustomerId: varchar('stripe_customer_id', { length: 255 }), + // Stripe Connect connected-account reference (non-PII). Added by + // add-connect-onboarding: refunds need a payee account, so the onboarding flow + // does a get-or-create against Stripe Connect and persists the account id here. + // Plaintext email is still never stored. (identity spec: §Connect 参照・状態も保持してよい) + // Unique: a connected account belongs to exactly one person — the constraint + // is the last line of defence against a get-or-create race linking two rows / + // creating duplicate Stripe accounts. MariaDB allows multiple NULLs under a + // unique index, so nullable + unique is fine for the "not yet linked" case. + stripeConnectedAccountId: varchar('stripe_connected_account_id', { length: 255 }).unique(), + // Payout onboarding state machine: none → requested (link issued) → done + // (payouts_enabled + no requirements due). `done` is terminal. Non-PII status. + // (connect-onboarding spec: §onboarding の状態機械) + payoutOnboardingStatus: mysqlEnum('payout_onboarding_status', ['none', 'requested', 'done']) + .notNull() + .default('none'), createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow().onUpdateNow(), }) From 720e643524f5b33b60d49d6bdfeec07723d230db Mon Sep 17 00:00:00 2001 From: kaitoyama Date: Sat, 20 Jun 2026 01:05:41 +0900 Subject: [PATCH 04/30] =?UTF-8?q?feat:=20Jomon=20payout=20execution=20(?= =?UTF-8?q?=E6=89=95=E3=81=84=E6=88=BB=E3=81=97=E3=81=AE=E5=AE=9F=E8=A1=8C?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit add-payout-execution (OpenSpec): pull approved transfer requests from Jomon, pay them out via Stripe Connect transfers, write results back. Responsibility split (design §5.3): approval is Jomon's, Checkin only executes. Pull-based; Jomon integrated stub-first with v1/v2 adapters. - Jomon adapter: JomonClient interface, StubJomonClient (dev/tests), v1/v2 HTTP clients (Bearer service token) with STRICT zod response validation that fails loudly rather than mis-mapping a payee. - payouts table + state machine (pending / onboarding_waiting / processing / paid / failed), jomon_ref UNIQUE, jomon_written_back_at. - Execution: ingest (idempotent upsert) -> resolve payee by mail_hash (userId immutable once set) -> onboarding gate -> atomic claim-to-processing -> Stripe transfer (idempotency key payout:) -> paid/failed -> Jomon write-back (retryable without re-transfer). Per-item error isolation; failed rows retried only by admin single execute, never auto-retried by the batch. - Stripe transfers adapter (createTransfer). oRPC payouts.processApproved / list / execute (admin-only). Migrations 0004, 0005. Codex-reviewed (1 critical + 2 high + 3 medium money-movement findings fixed: atomic execution claim, retryable write-back, strict Jomon validation, batch isolation, userId immutability, explicit failed-retry policy). Delta spec synced to openspec/specs/ and archived. Gates green: lint, typecheck, build, test (94). Live Jomon/Stripe E2E pending the Jomon Bearer-token endpoint and Connect-enabled test keys. Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 14 + apps/web/nuxt.config.ts | 5 + apps/web/server/utils/auth.ts | 29 +- apps/web/server/utils/jomon-config.ts | 29 ++ .../.openspec.yaml | 2 + .../2026-06-20-add-payout-execution/design.md | 88 ++++ .../proposal.md | 35 ++ .../specs/payout-execution/spec.md | 99 ++++ .../2026-06-20-add-payout-execution/tasks.md | 38 ++ openspec/specs/payout-execution/spec.md | 137 ++++++ packages/api/src/auth/identity.ts | 25 + packages/api/src/index.ts | 3 + packages/api/src/jomon/config.ts | 22 + packages/api/src/jomon/factory.ts | 22 + packages/api/src/jomon/http.ts | 164 +++++++ packages/api/src/jomon/index.ts | 5 + packages/api/src/jomon/jomon.test.ts | 117 +++++ packages/api/src/jomon/stub.ts | 37 ++ packages/api/src/jomon/types.ts | 53 +++ packages/api/src/orpc.ts | 6 + packages/api/src/payouts/execute.test.ts | 412 +++++++++++++++++ packages/api/src/payouts/execute.ts | 407 +++++++++++++++++ packages/api/src/payouts/index.ts | 3 + packages/api/src/payouts/status.test.ts | 56 +++ packages/api/src/payouts/status.ts | 62 +++ packages/api/src/payouts/store.ts | 169 +++++++ packages/api/src/router.ts | 67 ++- packages/api/src/stripe/index.ts | 1 + packages/api/src/stripe/transfers.ts | 40 ++ packages/db/drizzle/0004_mature_sentinels.sql | 15 + .../db/drizzle/0005_blushing_blindfold.sql | 2 + packages/db/drizzle/meta/0004_snapshot.json | 421 +++++++++++++++++ packages/db/drizzle/meta/0005_snapshot.json | 428 ++++++++++++++++++ packages/db/drizzle/meta/_journal.json | 14 + packages/db/src/schema.ts | 45 +- 35 files changed, 3064 insertions(+), 8 deletions(-) create mode 100644 apps/web/server/utils/jomon-config.ts create mode 100644 openspec/changes/archive/2026-06-20-add-payout-execution/.openspec.yaml create mode 100644 openspec/changes/archive/2026-06-20-add-payout-execution/design.md create mode 100644 openspec/changes/archive/2026-06-20-add-payout-execution/proposal.md create mode 100644 openspec/changes/archive/2026-06-20-add-payout-execution/specs/payout-execution/spec.md create mode 100644 openspec/changes/archive/2026-06-20-add-payout-execution/tasks.md create mode 100644 openspec/specs/payout-execution/spec.md create mode 100644 packages/api/src/jomon/config.ts create mode 100644 packages/api/src/jomon/factory.ts create mode 100644 packages/api/src/jomon/http.ts create mode 100644 packages/api/src/jomon/index.ts create mode 100644 packages/api/src/jomon/jomon.test.ts create mode 100644 packages/api/src/jomon/stub.ts create mode 100644 packages/api/src/jomon/types.ts create mode 100644 packages/api/src/payouts/execute.test.ts create mode 100644 packages/api/src/payouts/execute.ts create mode 100644 packages/api/src/payouts/status.test.ts create mode 100644 packages/api/src/payouts/status.ts create mode 100644 packages/api/src/payouts/store.ts create mode 100644 packages/api/src/stripe/transfers.ts create mode 100644 packages/db/drizzle/0004_mature_sentinels.sql create mode 100644 packages/db/drizzle/0005_blushing_blindfold.sql create mode 100644 packages/db/drizzle/meta/0004_snapshot.json create mode 100644 packages/db/drizzle/meta/0005_snapshot.json diff --git a/.env.example b/.env.example index 4a3f077..423423f 100644 --- a/.env.example +++ b/.env.example @@ -62,3 +62,17 @@ INVOICE_DAYS_UNTIL_DUE="7" # same value here if they share one. Leave empty for non-payout dev (the Stripe # adapter is lazy, so non-payout requests work without it). STRIPE_CONNECT_WEBHOOK_SECRET="" + +# --- Payout execution / Jomon integration (OpenSpec: add-payout-execution) --- + +# Jomon API base URL and Bearer service token (Checkin -> Jomon, one-way). Only +# needed for the live drivers (v1/v2); leave empty for stub (the default). +JOMON_API_BASE_URL="" +JOMON_API_TOKEN="" + +# Jomon driver: "stub" (in-memory, dev/tests), "v1" or "v2" (live HTTP). The +# stub is functionally complete and key-free, so payout dev works without Jomon. +JOMON_API_VERSION="stub" + +# Default payout currency (smallest unit; jpy has no minor unit so whole yen). +PAYOUT_CURRENCY="jpy" diff --git a/apps/web/nuxt.config.ts b/apps/web/nuxt.config.ts index 3f4da57..40c68d8 100644 --- a/apps/web/nuxt.config.ts +++ b/apps/web/nuxt.config.ts @@ -35,6 +35,11 @@ export default defineNuxtConfig({ priceKeizokuStandard: process.env.PRICE_KEIZOKU_STANDARD ?? '', priceKeizokuSpecial: process.env.PRICE_KEIZOKU_SPECIAL ?? '', invoiceDaysUntilDue: process.env.INVOICE_DAYS_UNTIL_DUE ?? '7', + // Payout execution / Jomon integration (OpenSpec: add-payout-execution). + jomonApiBaseUrl: process.env.JOMON_API_BASE_URL ?? '', + jomonApiToken: process.env.JOMON_API_TOKEN ?? '', + jomonApiVersion: process.env.JOMON_API_VERSION ?? 'stub', + payoutCurrency: process.env.PAYOUT_CURRENCY ?? 'jpy', }, compatibilityDate: '2025-01-01', typescript: { diff --git a/apps/web/server/utils/auth.ts b/apps/web/server/utils/auth.ts index be4b0c6..606013e 100644 --- a/apps/web/server/utils/auth.ts +++ b/apps/web/server/utils/auth.ts @@ -2,6 +2,7 @@ import type { H3Event } from 'h3' import { type Context, createAuthHelpers, + createJomonClient, createMailer, createNotifier, createStripeClient, @@ -73,20 +74,36 @@ export function isCsrfValid(event: H3Event): boolean { } /** - * Build the per-request oRPC Context: db handle, resolved auth + billing config, - * mailer + accountant notifier, a lazy Stripe adapter (no SDK is instantiated - * unless a billing procedure actually uses it, so non-billing requests work even - * without a Stripe key), the session restored from the cookie, and the - * CSRF-aware authorization helpers. + * Build the per-request oRPC Context: db handle, resolved auth + billing + Jomon + * config, mailer + accountant notifier, a lazy Stripe adapter (no SDK is + * instantiated unless a billing procedure actually uses it, so non-billing + * requests work even without a Stripe key), the Jomon client (`stub` by default, + * key-free), the session restored from the cookie, and the CSRF-aware + * authorization helpers. */ export async function buildRequestContext(event: H3Event): Promise { const db = useDatabase() const config = resolveAuthConfig() const billing = resolveBillingConfig() + const jomonConfig = resolveJomonConfig() const mailer = createMailer(config.mailer) const notifier = createNotifier() const stripe = createStripeClient(billing.stripeSecretKey) + // Lazy: the `stub` driver is key-free; live drivers only validate creds when a + // payout procedure actually reaches out to Jomon. + const jomon = createJomonClient(jomonConfig) const session = await resolveSession(db, getCookie(event, SESSION_COOKIE)) const helpers = createAuthHelpers(session, isCsrfValid(event)) - return { db, config, billing, mailer, notifier, stripe, session, ...helpers } + return { + db, + config, + billing, + jomonConfig, + mailer, + notifier, + stripe, + jomon, + session, + ...helpers, + } } diff --git a/apps/web/server/utils/jomon-config.ts b/apps/web/server/utils/jomon-config.ts new file mode 100644 index 0000000..144405b --- /dev/null +++ b/apps/web/server/utils/jomon-config.ts @@ -0,0 +1,29 @@ +import type { JomonConfig } from '@checkin/api' + +let cached: JomonConfig | undefined + +/** Coerce an env value to a known Jomon driver, defaulting to `stub`. */ +function version(value: unknown): JomonConfig['version'] { + return value === 'v1' || value === 'v2' ? value : 'stub' +} + +/** + * Resolve the typed JomonConfig from Nuxt runtimeConfig (env-backed). Cached per + * server instance. The domain in @checkin/api only sees this typed shape; live + * driver creds (base URL / token) may be empty in `stub` mode (the default), so + * non-payout dev works without Jomon configured. + */ +export function resolveJomonConfig(): JomonConfig { + if (cached) { + return cached + } + const rc = useRuntimeConfig() + + cached = { + version: version(rc.jomonApiVersion), + baseUrl: String(rc.jomonApiBaseUrl ?? ''), + token: String(rc.jomonApiToken ?? ''), + payoutCurrency: String(rc.payoutCurrency ?? 'jpy'), + } + return cached +} diff --git a/openspec/changes/archive/2026-06-20-add-payout-execution/.openspec.yaml b/openspec/changes/archive/2026-06-20-add-payout-execution/.openspec.yaml new file mode 100644 index 0000000..ff1fbc8 --- /dev/null +++ b/openspec/changes/archive/2026-06-20-add-payout-execution/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-19 diff --git a/openspec/changes/archive/2026-06-20-add-payout-execution/design.md b/openspec/changes/archive/2026-06-20-add-payout-execution/design.md new file mode 100644 index 0000000..1846f81 --- /dev/null +++ b/openspec/changes/archive/2026-06-20-add-payout-execution/design.md @@ -0,0 +1,88 @@ +## Context + +[[add-connect-onboarding]] で connected account の get-or-create・onboarding・`payout_onboarding_status` が整った。本 change は Jomon の承認済み振込依頼を取り込み、Stripe で送金し、結果を Jomon に書き戻す(design.md §5.3)。責任分界は確定: 承認・申請は Jomon、Checkin は実行のみ。連携は pull。Jomon は v1/v2 両対応+stub 先行([[jomon-integration]])。 + +## Goals / Non-Goals + +**Goals:** +- `JomonClient` アダプタ(取得・書き戻し、`stub`/`v1`/`v2`、Bearer トークン)。 +- `payouts` テーブルと状態機械(pending/onboarding_waiting/paid/failed)、`jomon_ref` 冪等。 +- 本人特定(mail_hash)→ onboarding 判定 → Stripe transfer(冪等)→ Jomon 書き戻し。 +- 会計のみの操作。 + +**Non-Goals:** +- Jomon 側トークン受け口の実装、資金繰り、`/payouts` UI、汎用 transfers 一覧、account.updated からの自動再開(将来)。 + +## Decisions + +### D1. Jomon アダプタ(`packages/api/src/jomon/`) + +- `JomonClient` インターフェース: + - `listApprovedTransferRequests(): Promise` + - `writeBackResult(jomonRef, result: { status: 'paid'|'failed', stripeTransferId?, message? }): Promise` +- `JomonTransferRequest`(ドメイン DTO, Jomon 型非依存): `{ jomonRef, payeeEmail, amount, currency }`。 + - **未確定**: 受取人識別子。当面 `payeeEmail`(isct メール)を採用し `deriveMailHash` で対応表に解決する想定。実フィールド名/形は実接続時に v1/v2 アダプタで確定(design.md §9、要 Jomon 調整)。 +- 実装: `StubJomonClient`(メモリ/固定データ、dev・テスト用、機能完結)、`JomonV1Client` / `JomonV2Client`(`JOMON_API_BASE_URL` + `Authorization: Bearer JOMON_API_TOKEN`、v1/v2 のパス差を吸収。フィールドマッピングは TODO コメントで明示し、実接続時に確定)。 +- `createJomonClient(config)`: `JOMON_API_VERSION`(`stub`|`v1`|`v2`)で選択。 + +### D2. データモデル(`packages/db`) + +`payouts`: +- `id` varchar(36) PK +- `jomonRef` varchar(255) **unique** +- `userId` varchar(36) FK→users(nullable: 未特定時) +- `amount` int、`currency` varchar(8) +- `status` enum(`pending`,`onboarding_waiting`,`paid`,`failed`) NOT NULL default `pending` +- `stripeTransferId` varchar(255) nullable +- `createdAt`/`updatedAt` +- 取込は `jomon_ref` で冪等 upsert(`INSERT ... ON DUPLICATE KEY UPDATE` no-op パターン、既存実装に倣う)。 +- `pnpm db:generate`(`0004_*`、baseline 0000–0003 保持)。 + +### D3. Stripe 送金アダプタ(`packages/api/src/stripe/transfers.ts`) + +- `createTransfer(client, { destinationAccountId, amount, currency, idempotencyKey, metadata })` → `transfers.create({ amount, currency, destination }, { idempotencyKey })`。idempotencyKey は `payout:${jomonRef}`。返却は `{ transferId }`。Stripe 型はアダプタ内に閉じ込める。 + +### D4. 実行オーケストレーション(`packages/api/src/payouts/execute.ts`、Stripe/Jomon 非依存のドメイン+薄い配線) + +- 純粋な状態遷移ヘルパ: `nextPayoutStatus(current, { onboardingDone, transferOk })` 等(テスト可能)。 +- オーケストレーション(DB+アダプタを引数で受ける): + 1. `client.listApprovedTransferRequests()` → 各 req を `jomon_ref` で upsert(status 既定 pending)。 + 2. 本人特定: `deriveMailHash(payeeEmail)` → `getUserByMailHash`。無ければ `payouts.userId=null` のまま「要対応」、送金しない。 + 3. `payout_onboarding_status !== 'done'` → `getOrCreateConnectedAccount` + `createAccountOnboardingLink`、status=`onboarding_waiting`(リンクは結果に含める/会計が受取人へ案内)。 + 4. `done` → `createTransfer`(冪等キー `payout:${jomonRef}`)→ 成功 `paid`+`stripeTransferId` / 失敗 `failed`。 + 5. 確定後 `client.writeBackResult(...)`。 +- 冪等: `paid` は再実行しない(早期 return)。`onboarding_waiting` は再実行で `done` を再評価。 + +### D5. oRPC(会計のみ, `adminProc`) + +- `payouts.processApproved`(`adminProc`+`assertCsrf`): 取込→各 req を 1 ステップ進める→結果サマリ返却(実行件数・待機件数・要対応件数)。 +- `payouts.list`(`adminProc`): `payouts` 一覧(フィルタ status、ページング簡易)。 +- `payouts.execute`(`adminProc`+`assertCsrf`、`{ jomonRef }`): 単一 payout を進める(待機分の手動再開・再試行用)。 +- Context に `jomon: JomonClient` を追加(`buildRequestContext` で `createJomonClient` を遅延生成)。 + +### D6. 設定 + +`JOMON_API_BASE_URL` / `JOMON_API_TOKEN` / `JOMON_API_VERSION`(`stub`|`v1`|`v2`, 既定 `stub`) を `JomonConfig`(packages/api)+ runtimeConfig + `.env.example` に追加。送金通貨の既定(`PAYOUT_CURRENCY`, 既定 `jpy`)も env に。 + +## Risks / Trade-offs + +- **受取人識別子→mail_hash の対応が未確定** → Mitigation: アダプタ契約で `payeeEmail` を採用しドメインは mail_hash に解決。実フィールドは実接続時に確定(要 Jomon 調整)。特定不可は送金せず要対応化。 +- **二重送金** → Mitigation: `jomon_ref` unique + Stripe 冪等キー + `paid` 早期 return。 +- **送金成功後の書き戻し失敗** → Mitigation: payout は `paid`+`stripe_transfer_id` を source として保持。書き戻しは再試行可能(次回 processApproved で paid かつ未書き戻しを再送)。実装で「書き戻し済み」フラグ or 冪等な書き戻しを検討。 +- **stub と実 API の乖離** → Mitigation: v1/v2 アダプタはフィールドマッピングを TODO 明示。実接続時に schema 確定。 +- **Jomon 側トークン受け口未整備** → 本 change は stub で完結、実接続は後続調整。 + +## Migration Plan + +1. `payouts` テーブル追加 → `pnpm db:generate`(0004)→ migrate。 +2. Jomon アダプタ(interface+stub+v1/v2 scaffold)→ Stripe transfers アダプタ → 実行オーケストレーション → oRPC → 設定。 +3. ユニットテスト(本人特定、状態遷移、冪等 upsert、stub クライアントでのフロー)。 +4. Jomon トークン受け口+Connect 有効キー入手後、sandbox で取込→onboarding→送金→書き戻しの E2E。 +5. ロールバック: マイグレーション revert + payouts 手続き無効化。 + +## Open Questions + +- Jomon 振込依頼の受取人識別子(isct メール?traQ?内部 id?)→ 実接続時に確定(design.md §9)。 +- v1/v2 のエンドポイント・スキーマ差(取得・書き戻しのパス/フィールド)→ 実接続時に v1/v2 アダプタで確定。 +- 送金は transfer(platform→connected)で確定。connected からの bank payout は Stripe の payout schedule に委ねる(必要なら明示 payout を将来追加)。 +- 書き戻し冪等(フラグ列を payouts に足すか)→ 実装で判断(まずは paid 再送を許容しても Jomon 側冪等なら可)。 diff --git a/openspec/changes/archive/2026-06-20-add-payout-execution/proposal.md b/openspec/changes/archive/2026-06-20-add-payout-execution/proposal.md new file mode 100644 index 0000000..421f977 --- /dev/null +++ b/openspec/changes/archive/2026-06-20-add-payout-execution/proposal.md @@ -0,0 +1,35 @@ +## Why + +第一弾スコープ③「払い戻し」の後半。Jomon で**承認済み**の振込依頼を取り込み、[[add-connect-onboarding]] で整えた connected account を使って Stripe で送金(transfer/payout)し、結果を Jomon に書き戻す。責任分界は確定(design.md §5.3): **承認・申請は Jomon、Checkin は実行のみ**。即時性は不要なので pull 方式。 + +Jomon の現行 API バージョンが未確定(v1 の可能性)なため、**v1/v2 両対応**の `JomonClient` アダプタを定義し、まず**スタブ実装**で Checkin 側の機械を完成させ、実接続は Jomon 側の Bearer トークン受け口が整ってから差し替える([[jomon-integration]])。 + +## What Changes + +- **Jomon アダプタ(pull, v1/v2 両対応+stub)**: `JomonClient` インターフェース(承認済み振込依頼の取得・結果の書き戻し)。実装は `stub`(dev/テスト)・`v1`・`v2` を env で選択。Checkin→Jomon は **Bearer サービストークン**(env、片方向)。 +- **Payouts テーブル(新規)**: `jomon_ref`(unique)/ `user_id` / `amount` / `currency` / `status`(`pending`/`onboarding_waiting`/`paid`/`failed`)/ `stripe_transfer_id` / timestamps。取込は `jomon_ref` で冪等。 +- **払い戻しの実行フロー(会計トリガ)**: + 1. 承認済み依頼を取込 → 本人特定(対応表=`mail_hash`)→ Payouts を upsert(`jomon_ref` 冪等)。 + 2. 受取人の `payout_onboarding_status` が `done` でなければ connected account を get-or-create + onboarding リンクを発行し、Payouts を `onboarding_waiting` に。 + 3. `done` なら Stripe で transfer 実行(`jomon_ref` を冪等キー)→ 成功で `paid`+`stripe_transfer_id`、失敗で `failed`。 + 4. 結果(送金済み/失敗)を Jomon に書き戻し。 +- **待機分の再開**: onboarding 完了後に `onboarding_waiting` を実行へ進める(会計の再実行で拾う。account.updated からの自動再開は将来拡張)。 +- **認可**: 取込・実行・状態確認は会計(管理者)のみ(`adminProc`)。 + +明確に**スコープ外**: Jomon 側のトークン受け口実装(Jomon リポジトリ側)、資金繰り(残高補填)、`/payouts` UI、catch-all の汎用 transfers 一覧、account.updated からの自動再開(将来)。 + +## Capabilities + +### New Capabilities +- `payout-execution`: 承認済み振込依頼の取込(Jomon pull, v1/v2+stub)、本人特定、Payouts の冪等管理と状態機械(pending/onboarding_waiting/paid/failed)、onboarding 未完なら onboarding へ誘導、Stripe transfer 実行(冪等)、Jomon への結果書き戻し、会計のみの操作。 + +### Modified Capabilities + + +## Impact + +- **DB(`packages/db`)**: `payouts` テーブル追加。マイグレーション生成・コミット。 +- **API(`packages/api`)**: `jomon/`(`JomonClient` インターフェース+`stub`/`v1`/`v2` 実装、`JomonConfig`)、`payouts/` 実行ドメイン(本人特定・状態遷移・実行オーケストレーション)、Stripe アダプタに `createTransfer`(connected account への送金、冪等キー)。oRPC `payouts.processApproved` / `payouts.list` / `payouts.execute`(会計のみ)。Context に `jomon` クライアントを追加。 +- **設定**: `JOMON_API_BASE_URL` / `JOMON_API_TOKEN` / `JOMON_API_VERSION`(`stub`|`v1`|`v2`) を env / runtimeConfig に追加。 +- **テスト/検証**: 本人特定・状態遷移・冪等・stub クライアントはユニットテスト。実 Jomon 接続と実 transfer は、トークン受け口・Connect 有効キー入手後に E2E。 +- **未確定(要 Jomon 調整)**: Jomon の振込依頼が返す**受取人識別子 → `mail_hash` の対応**(isct メール等)。本 change ではアダプタ契約として定義し、実フィールドは実接続時に確定(design.md §9)。 diff --git a/openspec/changes/archive/2026-06-20-add-payout-execution/specs/payout-execution/spec.md b/openspec/changes/archive/2026-06-20-add-payout-execution/specs/payout-execution/spec.md new file mode 100644 index 0000000..318b644 --- /dev/null +++ b/openspec/changes/archive/2026-06-20-add-payout-execution/specs/payout-execution/spec.md @@ -0,0 +1,99 @@ +## ADDED Requirements + +### Requirement: Jomon アダプタ(pull・v1/v2・stub) + +システムは、承認済み振込依頼の取得と結果の書き戻しを `JomonClient` アダプタ越しに行う SHALL。実装は環境設定で `stub` / `v1` / `v2` を選択でき、ドメインは具体実装に依存しては SHALL NOT。Checkin→Jomon の認証は Bearer サービストークン(env、片方向)とする MUST。即時性は不要のため pull 方式とする。 + +#### Scenario: ドライバを切り替えられる + +- **WHEN** `JOMON_API_VERSION` を `stub` / `v1` / `v2` のいずれかに設定する +- **THEN** 対応する `JomonClient` 実装が使われ、ドメインコードは変更不要 + +#### Scenario: 取得と書き戻しの両操作を持つ + +- **WHEN** 払い戻しフローが Jomon と連携する +- **THEN** 「承認済み振込依頼の取得」と「結果(送金済み/失敗)の書き戻し」が `JomonClient` 経由で行える + +### Requirement: 取込は jomon_ref で冪等 + +システムは、取り込んだ振込依頼を `payouts` に記録し、`jomon_ref` を unique キーとして冪等に upsert SHALL。同一依頼を複数回取り込んでも重複行や二重送金を起こしては SHALL NOT。 + +#### Scenario: 同一依頼の再取込は重複しない + +- **WHEN** 同じ `jomon_ref` の依頼を 2 回取り込む +- **THEN** `payouts` 行は 1 つのままで、状態は引き継がれる + +### Requirement: 本人特定(対応表 mail_hash) + +システムは、各振込依頼の受取人を対応表(`mail_hash`)で特定 SHALL。特定できない依頼は送金へ進めず、役員対応が必要な状態として記録・可視化する MUST(誤送金を避ける)。 + +#### Scenario: 特定できたら処理を進める + +- **WHEN** 振込依頼の受取人が `mail_hash` で `users` 行に解決できる +- **THEN** その依頼は onboarding 判定/送金へ進む + +#### Scenario: 特定できなければ送金しない + +- **WHEN** 受取人を `mail_hash` で特定できない +- **THEN** 送金は行わず、要対応として記録される + +### Requirement: onboarding 未完なら onboarding へ誘導 + +システムは、受取人の `payout_onboarding_status` が `done` でない場合、connected account を get-or-create し onboarding リンクを発行したうえで、当該 payout を `onboarding_waiting` とする SHALL。`done` になるまで送金しては SHALL NOT。 + +#### Scenario: 未完は待機にする + +- **WHEN** 受取人の onboarding が未完(`done` でない)で払い戻しが必要 +- **THEN** onboarding リンクが発行され、payout は `onboarding_waiting` になる(送金はしない) + +#### Scenario: 完了後に再開できる + +- **WHEN** 受取人の onboarding が `done` になった後で再実行する +- **THEN** `onboarding_waiting` の payout が送金へ進む + +### Requirement: Stripe での送金実行(冪等) + +システムは、`done` の受取人の connected account に対し Stripe で送金(transfer)を実行 SHALL。`jomon_ref` を Stripe 冪等キーに用い、再試行で二重送金を起こしては SHALL NOT。成功時は payout を `paid` にし `stripe_transfer_id` を保存、失敗時は `failed` にする MUST。 + +#### Scenario: 送金成功 + +- **WHEN** `done` の受取人へ送金を実行し成功する +- **THEN** payout は `paid` になり `stripe_transfer_id` が保存される + +#### Scenario: 送金失敗 + +- **WHEN** 送金実行が失敗する +- **THEN** payout は `failed` になり、二重送金は起きない + +#### Scenario: 再実行は二重送金しない + +- **WHEN** 既に `paid` の payout に対して再実行が起きる +- **THEN** 冪等キーにより新たな送金は発生しない + +### Requirement: 結果を Jomon に書き戻す + +システムは、送金結果(送金済み / 失敗)を `JomonClient` を通じて Jomon に書き戻す SHALL。書き戻しは結果確定後に行う MUST。 + +#### Scenario: 送金済みを書き戻す + +- **WHEN** payout が `paid` になる +- **THEN** Jomon に「送金済み」が書き戻される + +#### Scenario: 失敗を書き戻す + +- **WHEN** payout が `failed` になる +- **THEN** Jomon に「失敗」が書き戻される + +### Requirement: 払い戻し操作は会計のみ + +取込・実行・一覧/状態確認は会計(管理者)のみがアクセス可能 SHALL。認可チェックは入力検証より先に実行され、状態変更を伴う操作は CSRF 検証を行う MUST。 + +#### Scenario: 会計は実行できる + +- **WHEN** 会計セッションで取込・実行・一覧を要求する +- **THEN** 操作が許可される + +#### Scenario: 非会計は拒否 + +- **WHEN** 利用者または未ログインで払い戻し操作を要求する +- **THEN** 認可エラーで拒否される diff --git a/openspec/changes/archive/2026-06-20-add-payout-execution/tasks.md b/openspec/changes/archive/2026-06-20-add-payout-execution/tasks.md new file mode 100644 index 0000000..8fd143a --- /dev/null +++ b/openspec/changes/archive/2026-06-20-add-payout-execution/tasks.md @@ -0,0 +1,38 @@ +## 1. データモデル + +- [x] 1.1 `payouts` テーブル追加(`id` PK, `jomon_ref` unique, `user_id` FK nullable, `amount` int, `currency`, `status` enum(pending/onboarding_waiting/paid/failed) default pending, `stripe_transfer_id` nullable, timestamps) +- [x] 1.2 `pnpm db:generate`(`0004_*`、baseline 0000–0003 保持)→ `pnpm db:migrate` + +## 2. Jomon アダプタ(packages/api/src/jomon、v1/v2+stub) + +- [x] 2.1 `JomonClient` インターフェース(`listApprovedTransferRequests` / `writeBackResult`)+ `JomonTransferRequest` DTO(Jomon 型非依存、`payeeEmail` を本人キーに) +- [x] 2.2 `StubJomonClient`(メモリ/固定データ、機能完結、dev・テスト用) +- [x] 2.3 `JomonV1Client` / `JomonV2Client`(`JOMON_API_BASE_URL`+`Authorization: Bearer`、フィールドマッピングは TODO 明示) +- [x] 2.4 `JomonConfig` + `createJomonClient(config)`(`JOMON_API_VERSION` stub/v1/v2)。Context に `jomon` を遅延生成で追加 + +## 3. Stripe 送金アダプタ + +- [x] 3.1 `createTransfer(client, {destinationAccountId, amount, currency, idempotencyKey, metadata})`(`transfers.create` + 冪等キー `payout:${jomonRef}`、Stripe 型はアダプタ内) + +## 4. 実行ドメイン(packages/api/src/payouts) + +- [x] 4.1 `payouts` の冪等 upsert(`jomon_ref`)と読み取り/更新ヘルパ +- [x] 4.2 純粋ロジック: `nextPayoutStatus`(onboarding未完→onboarding_waiting、done→送金結果で paid/failed、paid 終端) +- [x] 4.3 オーケストレーション: 取込→本人特定(mail_hash)→onboarding 判定(未完は get-or-create+リンク+waiting)→done は transfer→ 結果で paid/failed → Jomon 書き戻し。`paid` は早期 return(再送金しない)。特定不可は送金せず要対応 + +## 5. oRPC(会計のみ) + +- [x] 5.1 `payouts.processApproved`(`adminProc`+`assertCsrf`): 取込→各依頼を1ステップ進める→サマリ返却 +- [x] 5.2 `payouts.list`(`adminProc`、status フィルタ) +- [x] 5.3 `payouts.execute`(`adminProc`+`assertCsrf`、`{jomonRef}`): 単一 payout の再開/再試行 + +## 6. 設定・検証 + +- [x] 6.1 `.env.example` に `JOMON_API_BASE_URL`/`JOMON_API_TOKEN`/`JOMON_API_VERSION`/`PAYOUT_CURRENCY` 追記、`nuxt.config` runtimeConfig に対応キー追加 +- [x] 6.2 ユニットテスト: 本人特定(解決/不可)、`nextPayoutStatus`(各遷移・冪等)、`jomon_ref` 冪等 upsert、`StubJomonClient` でのフロー(取込→waiting/done→paid→書き戻し) +- [x] 6.3 `pnpm lint` / `pnpm typecheck` / `pnpm build` / `pnpm test` をグリーンにする +- [x] 6.5 Codex ハードニング修正: 送金前の原子的 execution claim(`processing`)、Jomon 書き戻しの再試行可能化(`jomon_written_back_at`、再送金なし)、Jomon レスポンスの厳格 zod 検証(フェイルセーフ)、`processApproved` のアイテム単位エラー分離、`userId` の不変性、`failed` の明示的リトライ方針(`execute` のみ再試行)。マイグレーション `0005_blushing_blindfold`(enum 値追加+列追加、baseline 0000–0004 保持) +- [ ] 6.4 (Jomon トークン受け口+Connect 有効キー後)sandbox で 取込→onboarding→送金→書き戻し の E2E + diff --git a/openspec/specs/payout-execution/spec.md b/openspec/specs/payout-execution/spec.md new file mode 100644 index 0000000..61557bc --- /dev/null +++ b/openspec/specs/payout-execution/spec.md @@ -0,0 +1,137 @@ +# payout-execution + +## Purpose + +Jomon で承認済みの振込依頼を取り込み、[[connect-onboarding]] で整えた connected account を使って Stripe で送金し、結果を Jomon に書き戻す。責任分界(design.md §5.3): 承認・申請は Jomon、Checkin は実行のみ。連携は pull。Jomon は v1/v2 両対応+stub 先行。お金が外に出るため、冪等・原子的クレーム・厳格バリデーションで二重送金と誤送金を防ぐ。 + +## Requirements + +### Requirement: Jomon アダプタ(pull・v1/v2・stub) + +システムは、承認済み振込依頼の取得と結果の書き戻しを `JomonClient` アダプタ越しに行う SHALL。実装は環境設定で `stub` / `v1` / `v2` を選択でき、ドメインは具体実装に依存しては SHALL NOT。Checkin→Jomon の認証は Bearer サービストークン(env、片方向)とする MUST。連携は pull とする。 + +#### Scenario: ドライバを切り替えられる + +- **WHEN** `JOMON_API_VERSION` を `stub` / `v1` / `v2` のいずれかに設定する +- **THEN** 対応する `JomonClient` 実装が使われ、ドメインコードは変更不要 + +#### Scenario: 取得と書き戻しの両操作を持つ + +- **WHEN** 払い戻しフローが Jomon と連携する +- **THEN** 「承認済み振込依頼の取得」と「結果(送金済み/失敗)の書き戻し」が `JomonClient` 経由で行える + +### Requirement: Jomon レスポンスの厳格バリデーション(fail-safe) + +システムは、Jomon から取得した振込依頼を厳格に検証 SHALL:受取人識別子が非空、`jomon_ref` が非空、`amount` が正の整数、通貨が存在すること。欠落・不正・想定外の形は既定値で埋めず**エラーとして拒否** MUST(誤った人への送金や 0 円送金を黙って作らない)。 + +#### Scenario: 不正な形は拒否 + +- **WHEN** 受取人識別子・`jomon_ref`・`amount` のいずれかが欠落/不正な依頼が返る +- **THEN** 既定値で補わずエラーとして扱い、その依頼は送金へ進めない + +### Requirement: 取込は jomon_ref で冪等 + +システムは、取り込んだ振込依頼を `payouts` に記録し、`jomon_ref` を unique キーとして冪等に upsert SHALL。同一依頼を複数回取り込んでも重複行や二重送金を起こしては SHALL NOT。 + +#### Scenario: 同一依頼の再取込は重複しない + +- **WHEN** 同じ `jomon_ref` の依頼を 2 回取り込む +- **THEN** `payouts` 行は 1 つのままで、状態は引き継がれる + +### Requirement: 本人特定(対応表 mail_hash)と userId 不変 + +システムは、各振込依頼の受取人を対応表(`mail_hash`)で特定 SHALL。特定できない依頼は送金へ進めず要対応として記録する MUST。一度 `user_id` が確定した payout は、後続の取込で別人に解決されても**再リンクしては SHALL NOT**(要対応として扱う。誤送金防止)。 + +#### Scenario: 特定できたら処理を進める + +- **WHEN** 振込依頼の受取人が `mail_hash` で `users` 行に解決でき、まだ user 未設定 +- **THEN** `user_id` を設定し、onboarding 判定/送金へ進む + +#### Scenario: 特定できなければ送金しない + +- **WHEN** 受取人を `mail_hash` で特定できない +- **THEN** 送金は行わず、要対応として記録される + +#### Scenario: 確定済み userId は変えない + +- **WHEN** 既に `user_id` のある payout が、後続取込で別人に解決される +- **THEN** 再リンクせず要対応として扱い、送金しない + +### Requirement: onboarding 未完なら onboarding へ誘導 + +システムは、受取人の `payout_onboarding_status` が `done` でない場合、connected account を get-or-create し onboarding リンクを発行したうえで、当該 payout を `onboarding_waiting` とする SHALL。`done` になるまで送金しては SHALL NOT。 + +#### Scenario: 未完は待機にする + +- **WHEN** 受取人の onboarding が未完(`done` でない)で払い戻しが必要 +- **THEN** onboarding リンクが発行され、payout は `onboarding_waiting` になる(送金はしない) + +#### Scenario: 完了後に再開できる + +- **WHEN** 受取人の onboarding が `done` になった後で再実行する +- **THEN** `onboarding_waiting` の payout が送金へ進む + +### Requirement: 送金前の原子的クレーム(二重送金防止) + +システムは、Stripe 送金の前に payout を原子的にクレーム SHALL:`status` を `processing` へ条件付き更新(`status IN ('pending','onboarding_waiting','failed')` のときのみ成功)し、クレームに失敗した(他の実行が先行した)場合は再読込して短絡し、**送金しない** MUST。これにより同一 `jomon_ref` への同時実行で二重に送金されては SHALL NOT。Stripe 冪等キー(`payout:${jomonRef}`)を併用する。 + +#### Scenario: 同時実行は一方だけが送金する + +- **WHEN** 同一 `jomon_ref` に対し処理が同時並行で走る +- **THEN** クレームに成功した 1 つだけが送金し、他方は短絡して送金しない + +### Requirement: Stripe での送金実行と結果確定 + +システムは、`done` の受取人の connected account に対し Stripe で送金(transfer)を実行 SHALL。成功時は payout を `paid` にし `stripe_transfer_id` を保存、失敗時は `failed` にする MUST。確定済みの `paid` を上書きして送金し直しては SHALL NOT。 + +#### Scenario: 送金成功 + +- **WHEN** クレーム済みの payout へ送金を実行し成功する +- **THEN** payout は `paid` になり `stripe_transfer_id` が保存される + +#### Scenario: 送金失敗 + +- **WHEN** 送金実行が失敗する +- **THEN** payout は `failed` になり、二重送金は起きない + +### Requirement: 結果を Jomon に書き戻す(再試行可能・再送金しない) + +システムは、送金結果(送金済み / 失敗)を `JomonClient` を通じて Jomon に書き戻す SHALL。書き戻し成否を記録(例: `jomon_written_back_at`)し、送金成功済みだが書き戻し未了の payout は、再実行時に**書き戻しのみ再試行**して送金は再実行しては SHALL NOT。 + +#### Scenario: 送金済みを書き戻す + +- **WHEN** payout が `paid` になる +- **THEN** Jomon に「送金済み」が書き戻され、書き戻し済みが記録される + +#### Scenario: 書き戻し失敗は再試行され、再送金はしない + +- **WHEN** 送金は成功したが Jomon 書き戻しが失敗し、後で再実行する +- **THEN** 送金は再実行されず、書き戻しのみ再試行される + +### Requirement: バッチの個別分離と failed の再試行ポリシー + +システムは、複数依頼の一括処理で 1 件の失敗が他の処理を止めないよう、各依頼を個別に分離(try/catch)し、エラーを集計して継続する SHALL。一括処理(取込)は `failed` の payout を**自動再試行しない** MUST。`failed` の再試行は会計による単一実行でのみ行う SHALL。 + +#### Scenario: 1 件の失敗で全体が止まらない + +- **WHEN** 一括処理中に 1 件がエラーになる +- **THEN** そのエラーは集計され、残りの依頼は処理が継続する + +#### Scenario: 一括は failed を自動再試行しない + +- **WHEN** 一括処理が `failed` の payout を含む +- **THEN** その `failed` は自動再送金されず、スキップ集計される(再試行は会計の単一実行のみ) + +### Requirement: 払い戻し操作は会計のみ + +取込・実行・一覧/状態確認は会計(管理者)のみがアクセス可能 SHALL。認可チェックは入力検証より先に実行され、状態変更を伴う操作は CSRF 検証を行う MUST。 + +#### Scenario: 会計は実行できる + +- **WHEN** 会計セッションで取込・実行・一覧を要求する +- **THEN** 操作が許可される + +#### Scenario: 非会計は拒否 + +- **WHEN** 利用者または未ログインで払い戻し操作を要求する +- **THEN** 認可エラーで拒否される diff --git a/packages/api/src/auth/identity.ts b/packages/api/src/auth/identity.ts index c1309ce..d33b035 100644 --- a/packages/api/src/auth/identity.ts +++ b/packages/api/src/auth/identity.ts @@ -35,6 +35,31 @@ export async function getUserById(db: Database, id: string): Promise { + const [row] = await db + .select({ + id: schema.users.id, + mailHash: schema.users.mailHash, + stripeCustomerId: schema.users.stripeCustomerId, + stripeConnectedAccountId: schema.users.stripeConnectedAccountId, + payoutOnboardingStatus: schema.users.payoutOnboardingStatus, + }) + .from(schema.users) + .where(eq(schema.users.mailHash, mailHash)) + .limit(1) + return row ?? null +} + /** Fetch a person row by its Connect connected-account id, or null. */ export async function getUserByConnectedAccountId( db: Database, diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 595b670..c92eb5c 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -18,6 +18,9 @@ export * from './payouts' /** Stripe adapter layer (all Stripe SDK usage is isolated here). */ export * from './stripe' +/** Jomon integration (pull adapter: interface + stub / v1 / v2 + config + factory). */ +export * from './jomon' + /** Accountant notification abstraction (used by the payment webhook). */ export * from './notify' diff --git a/packages/api/src/jomon/config.ts b/packages/api/src/jomon/config.ts new file mode 100644 index 0000000..4c7c516 --- /dev/null +++ b/packages/api/src/jomon/config.ts @@ -0,0 +1,22 @@ +/** + * Jomon configuration. Like the auth/billing config, values originate from + * environment variables and are resolved by the host (Nitro reads runtimeConfig). + * The domain depends only on this typed shape — never on `process.env` or the + * framework. (design D6) + */ +export interface JomonConfig { + /** Driver to use: in-memory `stub` (dev/tests) or live `v1` / `v2` HTTP. */ + version: 'stub' | 'v1' | 'v2' + /** Jomon API base URL (live drivers only). */ + baseUrl: string + /** Bearer service token for Checkin→Jomon calls (live drivers only, one-way). */ + token: string + /** Default payout currency (`PAYOUT_CURRENCY`, e.g. 'jpy') for requests that omit one. */ + payoutCurrency: string + /** + * Seed data for {@link StubJomonClient}: approved requests the stub serves + * from memory in dev/tests. Empty by default. (config-driven, no env parsing + * of structured data — set programmatically in tests.) + */ + stubApproved?: import('./types').JomonTransferRequest[] +} diff --git a/packages/api/src/jomon/factory.ts b/packages/api/src/jomon/factory.ts new file mode 100644 index 0000000..51ef36c --- /dev/null +++ b/packages/api/src/jomon/factory.ts @@ -0,0 +1,22 @@ +import type { JomonConfig } from './config' +import type { JomonClient } from './types' +import { StubJomonClient } from './stub' +import { JomonV1Client, JomonV2Client } from './http' + +/** + * Build the configured Jomon client. `JOMON_API_VERSION` selects the driver + * (`stub` | `v1` | `v2`, default `stub`); the domain is unaffected by the + * choice. Constructing the `stub` driver is key-free, so the Nitro host can + * attach one to every request Context; live drivers validate their env at + * construction. (design D1 / D5) + */ +export function createJomonClient(config: JomonConfig): JomonClient { + switch (config.version) { + case 'v1': + return new JomonV1Client(config.baseUrl, config.token) + case 'v2': + return new JomonV2Client(config.baseUrl, config.token) + default: + return new StubJomonClient(config.stubApproved ?? []) + } +} diff --git a/packages/api/src/jomon/http.ts b/packages/api/src/jomon/http.ts new file mode 100644 index 0000000..b01fd9c --- /dev/null +++ b/packages/api/src/jomon/http.ts @@ -0,0 +1,164 @@ +import { z } from 'zod' +import type { JomonClient, JomonTransferRequest, JomonWriteBackResult } from './types' + +/** + * Strict zod schema for ONE raw Jomon transfer request (fail-safe parsing). + * + * We REQUIRE every field that a payout depends on and THROW on anything missing, + * empty, zero, negative, or the wrong shape — rather than silently defaulting. + * A wrong field mapping (e.g. the payee identifier under an unexpected key) must + * fail loudly here so it can never quietly pay the wrong person or a zero amount. + * + * TODO: confirm exact v1/v2 field names against live Jomon (design §9). The + * accepted aliases below are best-effort guesses; the payee identifier in + * particular (isct email vs traQ id vs internal id) is unconfirmed. When the + * live schema is known, pin these to the real keys (drop the aliases). + */ +const rawTransferRequestSchema = z + .object({ + // jomonRef: the opaque source/idempotency key. Required, non-empty. + jomonRef: z.string().trim().min(1).optional(), + id: z.union([z.string(), z.number()]).optional(), + // payee identifier: required, non-empty. + payeeEmail: z.string().trim().min(1).optional(), + email: z.string().trim().min(1).optional(), + // amount: required, positive integer (smallest currency unit). + amount: z.number().int().positive(), + // currency: required, non-empty ISO code. + currency: z.string().trim().min(1), + }) + .transform((obj, ctx): JomonTransferRequest => { + const jomonRef = obj.jomonRef ?? (obj.id !== undefined ? String(obj.id).trim() : '') + if (!jomonRef) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'missing jomonRef/id' }) + return z.NEVER + } + const payeeEmail = obj.payeeEmail ?? obj.email ?? '' + if (!payeeEmail) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'missing payee identifier (payeeEmail/email)' }) + return z.NEVER + } + return { jomonRef, payeeEmail, amount: obj.amount, currency: obj.currency } + }) + +/** A list response: a bare array or `{ data: [...] }`, of raw transfer requests. */ +const listResponseSchema = z.union([ + z.array(z.unknown()), + z.object({ data: z.array(z.unknown()) }).transform(o => o.data), +]) + +/** + * Shared HTTP plumbing for the live Jomon drivers (`v1` / `v2`). + * + * Auth is a one-way Bearer service token (`JOMON_API_TOKEN`); the base URL is + * `JOMON_API_BASE_URL`. The exact Jomon request/response schemas are NOT yet + * confirmed, so this class implements the transport and JSON plumbing and maps + * to/from {@link JomonTransferRequest} with STRICT zod validation that throws on + * any missing/invalid/unknown-shape field (never silently defaults). + * + * The v1/v2 difference is currently only the endpoint paths; field mapping is + * shared until the live schemas diverge. (design §9 — verify against live Jomon, + * requires Jomon coordination.) + */ +export abstract class JomonHttpClient implements JomonClient { + constructor( + protected readonly baseUrl: string, + protected readonly token: string, + ) { + if (!baseUrl) { + throw new Error('JOMON_API_BASE_URL is not set; refusing to call Jomon') + } + if (!token) { + throw new Error('JOMON_API_TOKEN is not set; refusing to call Jomon') + } + } + + /** Path of the "list approved transfer requests" endpoint (version-specific). */ + protected abstract listPath(): string + + /** Path of the "write back result" endpoint for a given ref (version-specific). */ + protected abstract writeBackPath(jomonRef: string): string + + async listApprovedTransferRequests(): Promise { + const res = await this.request('GET', this.listPath()) + const body = (await res.json()) as unknown + // TODO: verify field names/paths against live Jomon v1/v2 (design §9). + // Strict, fail-safe parsing: an unexpected envelope shape throws here rather + // than silently yielding an empty list (which would skip real payouts). + const parsedList = listResponseSchema.safeParse(body) + if (!parsedList.success) { + throw new Error( + `Jomon ${this.listPath()} returned an unexpected response shape: ${parsedList.error.message}`, + ) + } + return parsedList.data.map(item => this.toTransferRequest(item)) + } + + async writeBackResult(jomonRef: string, result: JomonWriteBackResult): Promise { + // TODO: verify the write-back path, method, and payload shape against live + // Jomon v1/v2 (design §9). Best-effort: POST the settled result as JSON. + await this.request('POST', this.writeBackPath(jomonRef), { + status: result.status, + stripe_transfer_id: result.stripeTransferId, + message: result.message, + }) + } + + /** + * Map a raw Jomon item to the Jomon-type-free DTO via STRICT zod parsing. + * + * THROWS on any missing/invalid field (empty payee, missing ref, non-positive + * amount, missing currency, wrong shape) so a wrong-field mapping fails loudly + * instead of silently paying the wrong person or a zero amount. + * + * TODO: confirm exact v1/v2 field names against live Jomon (design §9). + */ + protected toTransferRequest(raw: unknown): JomonTransferRequest { + const parsed = rawTransferRequestSchema.safeParse(raw) + if (!parsed.success) { + throw new Error(`Jomon transfer request failed validation: ${parsed.error.message}`) + } + return parsed.data + } + + /** Issue an authenticated JSON request against the Jomon API. */ + private async request(method: string, path: string, body?: unknown): Promise { + const url = `${this.baseUrl.replace(/\/$/, '')}${path}` + const res = await fetch(url, { + method, + headers: { + authorization: `Bearer ${this.token}`, + ...(body !== undefined ? { 'content-type': 'application/json' } : {}), + }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }) + if (!res.ok) { + throw new Error(`Jomon ${method} ${path} failed: ${res.status} ${await res.text()}`) + } + return res + } +} + +/** Live Jomon v1 driver. Endpoint paths are best-effort (design §9). */ +export class JomonV1Client extends JomonHttpClient { + // TODO: verify v1 endpoint paths against live Jomon (design §9). + protected listPath(): string { + return '/api/v1/transfer-requests?status=approved' + } + + protected writeBackPath(jomonRef: string): string { + return `/api/v1/transfer-requests/${encodeURIComponent(jomonRef)}/result` + } +} + +/** Live Jomon v2 driver. Endpoint paths are best-effort (design §9). */ +export class JomonV2Client extends JomonHttpClient { + // TODO: verify v2 endpoint paths against live Jomon (design §9). + protected listPath(): string { + return '/api/v2/transfer-requests?status=approved' + } + + protected writeBackPath(jomonRef: string): string { + return `/api/v2/transfer-requests/${encodeURIComponent(jomonRef)}/result` + } +} diff --git a/packages/api/src/jomon/index.ts b/packages/api/src/jomon/index.ts new file mode 100644 index 0000000..b17c36f --- /dev/null +++ b/packages/api/src/jomon/index.ts @@ -0,0 +1,5 @@ +export * from './types' +export * from './config' +export * from './stub' +export * from './http' +export * from './factory' diff --git a/packages/api/src/jomon/jomon.test.ts b/packages/api/src/jomon/jomon.test.ts new file mode 100644 index 0000000..ca44946 --- /dev/null +++ b/packages/api/src/jomon/jomon.test.ts @@ -0,0 +1,117 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createJomonClient } from './factory' +import { JomonV1Client } from './http' +import { StubJomonClient } from './stub' +import type { JomonTransferRequest } from './types' + +const seed: JomonTransferRequest[] = [ + { jomonRef: 'jmn_1', payeeEmail: 'alice@m.isct.ac.jp', amount: 4000, currency: 'jpy' }, + { jomonRef: 'jmn_2', payeeEmail: 'bob@m.isct.ac.jp', amount: 2000, currency: 'jpy' }, +] + +describe('createJomonClient', () => { + it('defaults to the in-memory stub driver', () => { + const client = createJomonClient({ version: 'stub', baseUrl: '', token: '', payoutCurrency: 'jpy' }) + expect(client).toBeInstanceOf(StubJomonClient) + }) + + it('seeds the stub from config', async () => { + const client = createJomonClient({ + version: 'stub', + baseUrl: '', + token: '', + payoutCurrency: 'jpy', + stubApproved: seed, + }) + const approved = await client.listApprovedTransferRequests() + expect(approved.map(r => r.jomonRef)).toEqual(['jmn_1', 'jmn_2']) + }) +}) + +describe('StubJomonClient', () => { + it('returns a copy so callers cannot mutate the internal list', async () => { + const client = new StubJomonClient(seed) + const first = await client.listApprovedTransferRequests() + first.pop() + const second = await client.listApprovedTransferRequests() + expect(second).toHaveLength(2) + }) + + it('records write-backs and removes the settled request from approved', async () => { + const client = new StubJomonClient(seed) + await client.writeBackResult('jmn_1', { status: 'paid', stripeTransferId: 'tr_123' }) + + expect(client.writeBacks).toEqual([ + { jomonRef: 'jmn_1', result: { status: 'paid', stripeTransferId: 'tr_123' } }, + ]) + const remaining = await client.listApprovedTransferRequests() + expect(remaining.map(r => r.jomonRef)).toEqual(['jmn_2']) + }) + + it('records a failure write-back', async () => { + const client = new StubJomonClient(seed) + await client.writeBackResult('jmn_2', { status: 'failed', message: 'boom' }) + expect(client.writeBacks).toEqual([ + { jomonRef: 'jmn_2', result: { status: 'failed', message: 'boom' } }, + ]) + }) +}) + +describe('JomonHttpClient strict response validation (fail-safe)', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + /** Stub global fetch with one JSON `ok` response body. */ + function mockFetch(body: unknown): void { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } }), + ) + } + + const client = new JomonV1Client('https://jomon.test', 'token') + + it('parses a valid array response into DTOs', async () => { + mockFetch([ + { jomonRef: 'jmn_a', payeeEmail: 'a@m.isct.ac.jp', amount: 4000, currency: 'jpy' }, + // `id`/`email` aliases also accepted (best-effort until live field names confirmed). + { id: 7, email: 'b@m.isct.ac.jp', amount: 2000, currency: 'jpy' }, + ]) + const out = await client.listApprovedTransferRequests() + expect(out).toEqual([ + { jomonRef: 'jmn_a', payeeEmail: 'a@m.isct.ac.jp', amount: 4000, currency: 'jpy' }, + { jomonRef: '7', payeeEmail: 'b@m.isct.ac.jp', amount: 2000, currency: 'jpy' }, + ]) + }) + + it('parses a `{ data: [...] }` envelope', async () => { + mockFetch({ data: [{ jomonRef: 'jmn_c', payeeEmail: 'c@m.isct.ac.jp', amount: 100, currency: 'jpy' }] }) + const out = await client.listApprovedTransferRequests() + expect(out.map(r => r.jomonRef)).toEqual(['jmn_c']) + }) + + it('THROWS on a missing payee identifier (never silently defaults)', async () => { + mockFetch([{ jomonRef: 'jmn_d', amount: 4000, currency: 'jpy' }]) + await expect(client.listApprovedTransferRequests()).rejects.toThrow(/validation/i) + }) + + it('THROWS on a zero / non-positive amount', async () => { + mockFetch([{ jomonRef: 'jmn_e', payeeEmail: 'e@m.isct.ac.jp', amount: 0, currency: 'jpy' }]) + await expect(client.listApprovedTransferRequests()).rejects.toThrow(/validation/i) + }) + + it('THROWS on a missing jomonRef / id', async () => { + mockFetch([{ payeeEmail: 'f@m.isct.ac.jp', amount: 4000, currency: 'jpy' }]) + await expect(client.listApprovedTransferRequests()).rejects.toThrow(/validation/i) + }) + + it('THROWS on a missing currency', async () => { + mockFetch([{ jomonRef: 'jmn_g', payeeEmail: 'g@m.isct.ac.jp', amount: 4000 }]) + await expect(client.listApprovedTransferRequests()).rejects.toThrow(/validation/i) + }) + + it('THROWS on an unknown top-level response shape', async () => { + mockFetch({ unexpected: true }) + await expect(client.listApprovedTransferRequests()).rejects.toThrow(/unexpected response shape/i) + }) +}) diff --git a/packages/api/src/jomon/stub.ts b/packages/api/src/jomon/stub.ts new file mode 100644 index 0000000..31c5090 --- /dev/null +++ b/packages/api/src/jomon/stub.ts @@ -0,0 +1,37 @@ +import type { JomonClient, JomonTransferRequest, JomonWriteBackResult } from './types' + +/** A recorded write-back, kept in memory for dev visibility and tests. */ +export interface StubWriteBack { + jomonRef: string + result: JomonWriteBackResult +} + +/** + * In-memory Jomon driver for dev and tests (functionally complete, no network). + * + * Approved requests are served from a seeded in-memory list; `writeBackResult` + * appends to an in-memory log (and removes the request from the approved list, + * mirroring Jomon "settling" it so a re-pull doesn't return it again). This lets + * the whole payout machine run end-to-end without live Jomon. (design D1) + */ +export class StubJomonClient implements JomonClient { + private approved: JomonTransferRequest[] + /** Append-only log of write-backs, exposed for assertions/dev inspection. */ + readonly writeBacks: StubWriteBack[] = [] + + constructor(seed: JomonTransferRequest[] = []) { + // Copy so callers' arrays aren't mutated under them. + this.approved = [...seed] + } + + async listApprovedTransferRequests(): Promise { + // Return a copy — callers must not mutate our internal list. + return [...this.approved] + } + + async writeBackResult(jomonRef: string, result: JomonWriteBackResult): Promise { + this.writeBacks.push({ jomonRef, result }) + // Once written back, drop it from "approved" so a subsequent pull is clean. + this.approved = this.approved.filter(r => r.jomonRef !== jomonRef) + } +} diff --git a/packages/api/src/jomon/types.ts b/packages/api/src/jomon/types.ts new file mode 100644 index 0000000..9a0195c --- /dev/null +++ b/packages/api/src/jomon/types.ts @@ -0,0 +1,53 @@ +/** + * Jomon integration contract (OpenSpec change: add-payout-execution, design D1). + * + * Jomon owns approval/application of transfer requests; Checkin pulls the + * **approved** ones, pays them out via Stripe Connect, and writes the result + * back. Immediacy is not required, so the integration is pull-based. + * + * The domain depends only on these Jomon-type-free shapes; concrete drivers + * (`stub` / `v1` / `v2`) live behind {@link JomonClient} and are selected by + * configuration. (payout-execution spec: §Jomon アダプタ) + */ + +/** + * A single approved transfer request, normalized to a Jomon-type-free DTO. + * + * The payee is identified by `payeeEmail` (isct email), which the domain + * resolves to a `users` row via `deriveMailHash`. The real recipient field is + * unconfirmed against live Jomon and is mapped per driver (design §9, requires + * Jomon coordination). + */ +export interface JomonTransferRequest { + /** Opaque Jomon request id — the unique source/idempotency key. */ + jomonRef: string + /** Payee's isct email; resolved to `mail_hash` for person identification. */ + payeeEmail: string + /** Amount in the currency's smallest unit (jpy = whole yen). */ + amount: number + /** ISO currency code, e.g. 'jpy'. */ + currency: string +} + +/** Result written back to Jomon after a payout settles. */ +export interface JomonWriteBackResult { + /** Settled outcome of the payout. */ + status: 'paid' | 'failed' + /** Stripe Transfer id, present on success. */ + stripeTransferId?: string + /** Optional human-readable note (e.g. the failure reason). */ + message?: string +} + +/** + * Jomon client abstraction. The domain calls only these two operations: + * - fetch approved transfer requests, + * - write a settled payout result back. + * Checkin→Jomon auth is a one-way Bearer service token (env). (design D1) + */ +export interface JomonClient { + /** Fetch the currently approved transfer requests awaiting payout. */ + listApprovedTransferRequests(): Promise + /** Write a settled payout result back to Jomon for `jomonRef`. */ + writeBackResult(jomonRef: string, result: JomonWriteBackResult): Promise +} diff --git a/packages/api/src/orpc.ts b/packages/api/src/orpc.ts index 176a665..02c56c9 100644 --- a/packages/api/src/orpc.ts +++ b/packages/api/src/orpc.ts @@ -6,6 +6,8 @@ import type { SessionActor } from './auth/session' import type { AuthHelpers } from './auth/context' import type { BillingConfig } from './billing/config' import type { StripeClient } from './stripe/client' +import type { JomonClient } from './jomon/types' +import type { JomonConfig } from './jomon/config' import type { Notifier } from './notify/notifier' /** @@ -22,6 +24,10 @@ export interface Context extends AuthHelpers { notifier: Notifier /** Lazy Stripe adapter — only instantiates the SDK when a procedure uses it. */ stripe: StripeClient + /** Jomon client (pull adapter) — `stub` by default; live drivers are env-gated. */ + jomon: JomonClient + /** Resolved Jomon config (carries the default payout currency). */ + jomonConfig: JomonConfig /** The authenticated actor, or null when unauthenticated. */ session: SessionActor | null } diff --git a/packages/api/src/payouts/execute.test.ts b/packages/api/src/payouts/execute.test.ts new file mode 100644 index 0000000..604400c --- /dev/null +++ b/packages/api/src/payouts/execute.test.ts @@ -0,0 +1,412 @@ +import { randomUUID } from 'node:crypto' +import { eq } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { createDatabase, schema, type Database } from '@checkin/db' +import { deriveMailHash } from '../auth/crypto' +import { setPayoutOnboardingStatus } from '../auth/identity' +import type { StripeClient } from '../stripe/client' +import { StubJomonClient } from '../jomon/stub' +import type { JomonTransferRequest } from '../jomon/types' +import { executePayout, processApprovedPayouts, type PayoutExecuteConfig } from './execute' +import { getPayoutByJomonRef, upsertPayoutByJomonRef } from './store' + +/** + * DB-backed tests for the payout store + orchestration. They run against the + * MariaDB pointed at by DATABASE_URL (see the change's tasks: MariaDB is up). + * If the database is unreachable they self-skip so `pnpm test` stays green + * without infrastructure. Stripe/Jomon are faked; no live calls are made. + */ + +const config: PayoutExecuteConfig = { + mailHashSecret: 'test-secret', + appOrigin: 'http://localhost:3000', + defaultCurrency: 'jpy', +} + +/** Minimal Stripe fake: only the surface the orchestration touches. */ +function fakeStripe(opts: { transferOk?: boolean } = {}): StripeClient { + const sdk = { + accounts: { + create: async () => ({ id: `acct_${randomUUID().slice(0, 8)}` }), + del: async () => ({}), + }, + accountLinks: { + create: async () => ({ url: 'https://connect.stripe.test/onboard' }), + }, + transfers: { + create: async () => { + if (opts.transferOk === false) { + throw new Error('stripe transfer declined') + } + return { id: `tr_${randomUUID().slice(0, 8)}` } + }, + }, + } + return { sdk } as unknown as StripeClient +} + +let db: Database +let available = false + +beforeAll(async () => { + try { + db = createDatabase(process.env.DATABASE_URL ?? 'mysql://checkin:password@localhost:3306/checkin') + await db.select({ id: schema.users.id }).from(schema.users).limit(1) + available = true + } + catch { + available = false + } +}) + +/** Unique suffix so repeated runs don't collide on jomon_ref / mail_hash. */ +const tag = randomUUID().slice(0, 8) +const refs: string[] = [] +const userIds: string[] = [] + +afterAll(async () => { + if (!available) { + return + } + for (const ref of refs) { + await db.delete(schema.payouts).where(eq(schema.payouts.jomonRef, ref)) + } + for (const id of userIds) { + await db.delete(schema.users).where(eq(schema.users.id, id)) + } +}) + +/** Insert a payee user with a known mail_hash; returns its email + id. */ +async function makePayee(localPart: string, onboarding: 'none' | 'done'): Promise<{ email: string, id: string }> { + const email = `${localPart}-${tag}@m.isct.ac.jp` + const id = randomUUID() + const mailHash = deriveMailHash(email, config.mailHashSecret) + await db.insert(schema.users).values({ id, mailHash }) + if (onboarding === 'done') { + // `done` requires a linked connected account for the transfer path. + await db.update(schema.users) + .set({ stripeConnectedAccountId: `acct_seed_${id.slice(0, 8)}` }) + .where(eq(schema.users.id, id)) + await setPayoutOnboardingStatus(db, id, 'done') + } + userIds.push(id) + return { email, id } +} + +function req(jomonRef: string, payeeEmail: string, amount = 4000): JomonTransferRequest { + refs.push(jomonRef) + return { jomonRef, payeeEmail, amount, currency: 'jpy' } +} + +describe('upsertPayoutByJomonRef (idempotent ingestion)', () => { + it('keeps a single row and preserves state on re-ingest', async () => { + if (!available) { + return + } + const jomonRef = `jmn-idem-${tag}` + refs.push(jomonRef) + + const first = await upsertPayoutByJomonRef(db, { jomonRef, amount: 4000, currency: 'jpy' }) + expect(first.status).toBe('pending') + + // Advance the row to a non-default state, then re-ingest the same ref. + await db.update(schema.payouts) + .set({ status: 'paid', stripeTransferId: 'tr_keep' }) + .where(eq(schema.payouts.jomonRef, jomonRef)) + + const second = await upsertPayoutByJomonRef(db, { jomonRef, amount: 9999, currency: 'usd' }) + // Same id (no duplicate) and the accumulated state is preserved (no-op update). + expect(second.id).toBe(first.id) + expect(second.status).toBe('paid') + expect(second.stripeTransferId).toBe('tr_keep') + // amount/currency are NOT overwritten by re-ingest. + expect(second.amount).toBe(4000) + expect(second.currency).toBe('jpy') + }) +}) + +describe('processApprovedPayouts (orchestration)', () => { + it('leaves an unidentifiable payee as pending and does not pay out', async () => { + if (!available) { + return + } + const jomon = new StubJomonClient([req(`jmn-unres-${tag}`, `nobody-${tag}@m.isct.ac.jp`)]) + const summary = await processApprovedPayouts( + { db, stripe: fakeStripe(), jomon }, + config, + ) + expect(summary.unresolved).toBe(1) + expect(summary.paid).toBe(0) + + const row = await getPayoutByJomonRef(db, `jmn-unres-${tag}`) + expect(row?.userId).toBeNull() + expect(row?.status).toBe('pending') + // No write-back for an unresolved request. + expect(jomon.writeBacks).toHaveLength(0) + }) + + it('parks a resolved-but-not-onboarded payee as onboarding_waiting', async () => { + if (!available) { + return + } + const payee = await makePayee('waiting', 'none') + const jomon = new StubJomonClient([req(`jmn-wait-${tag}`, payee.email)]) + const summary = await processApprovedPayouts( + { db, stripe: fakeStripe(), jomon }, + config, + ) + expect(summary.onboardingWaiting).toBe(1) + + const row = await getPayoutByJomonRef(db, `jmn-wait-${tag}`) + expect(row?.userId).toBe(payee.id) + expect(row?.status).toBe('onboarding_waiting') + expect(jomon.writeBacks).toHaveLength(0) + }) + + it('pays out a done payee and writes the result back to Jomon', async () => { + if (!available) { + return + } + const payee = await makePayee('paid', 'done') + const ref = `jmn-paid-${tag}` + const jomon = new StubJomonClient([req(ref, payee.email)]) + const summary = await processApprovedPayouts( + { db, stripe: fakeStripe({ transferOk: true }), jomon }, + config, + ) + expect(summary.paid).toBe(1) + + const row = await getPayoutByJomonRef(db, ref) + expect(row?.status).toBe('paid') + expect(row?.stripeTransferId).toBeTruthy() + expect(jomon.writeBacks).toEqual([ + { jomonRef: ref, result: expect.objectContaining({ status: 'paid' }) }, + ]) + }) + + it('does not re-transfer a paid payout on re-run (idempotent)', async () => { + if (!available) { + return + } + const payee = await makePayee('again', 'done') + const ref = `jmn-again-${tag}` + // Pre-mark this ref paid AND already-written-back, then re-execute via the + // stub. A fully-settled paid row short-circuits with no transfer and no + // new write-back. (jomonWrittenBackAt set ⇒ write-back not re-attempted.) + const jomon = new StubJomonClient([req(ref, payee.email)]) + await upsertPayoutByJomonRef(db, { jomonRef: ref, amount: 4000, currency: 'jpy' }) + await db.update(schema.payouts) + .set({ status: 'paid', stripeTransferId: 'tr_existing', userId: payee.id, jomonWrittenBackAt: new Date() }) + .where(eq(schema.payouts.jomonRef, ref)) + + const result = await executePayout({ db, stripe: fakeStripe({ transferOk: true }), jomon }, config, ref) + expect(result.outcome).toBe('already_paid') + + const row = await getPayoutByJomonRef(db, ref) + expect(row?.stripeTransferId).toBe('tr_existing') + // No new write-back for a short-circuited, already-written-back paid payout. + expect(jomon.writeBacks).toHaveLength(0) + }) + + it('marks failed and writes back when the transfer is declined', async () => { + if (!available) { + return + } + const payee = await makePayee('fail', 'done') + const ref = `jmn-fail-${tag}` + const jomon = new StubJomonClient([req(ref, payee.email)]) + const summary = await processApprovedPayouts( + { db, stripe: fakeStripe({ transferOk: false }), jomon }, + config, + ) + expect(summary.failed).toBe(1) + + const row = await getPayoutByJomonRef(db, ref) + expect(row?.status).toBe('failed') + expect(jomon.writeBacks).toEqual([ + { jomonRef: ref, result: expect.objectContaining({ status: 'failed' }) }, + ]) + }) + + it('records jomonWrittenBackAt on a successful payout', async () => { + if (!available) { + return + } + const payee = await makePayee('wbts', 'done') + const ref = `jmn-wbts-${tag}` + const jomon = new StubJomonClient([req(ref, payee.email)]) + await processApprovedPayouts({ db, stripe: fakeStripe({ transferOk: true }), jomon }, config) + + const row = await getPayoutByJomonRef(db, ref) + expect(row?.status).toBe('paid') + expect(row?.jomonWrittenBackAt).toBeInstanceOf(Date) + }) + + it('does NOT auto-retry a failed row in processApproved (skips it)', async () => { + if (!available) { + return + } + const payee = await makePayee('skip', 'done') + const ref = `jmn-skip-${tag}` + await upsertPayoutByJomonRef(db, { jomonRef: ref, amount: 4000, currency: 'jpy' }) + await db.update(schema.payouts) + .set({ status: 'failed', userId: payee.id }) + .where(eq(schema.payouts.jomonRef, ref)) + + const jomon = new StubJomonClient([req(ref, payee.email)]) + const summary = await processApprovedPayouts( + { db, stripe: fakeStripe({ transferOk: true }), jomon }, + config, + ) + expect(summary.skippedFailed).toBe(1) + expect(summary.paid).toBe(0) + + const row = await getPayoutByJomonRef(db, ref) + // Still failed — no transfer, no write-back. + expect(row?.status).toBe('failed') + expect(jomon.writeBacks).toHaveLength(0) + }) + + it('lets the admin execute RETRY a failed row (paying it out)', async () => { + if (!available) { + return + } + const payee = await makePayee('retry', 'done') + const ref = `jmn-retry-${tag}` + await upsertPayoutByJomonRef(db, { jomonRef: ref, amount: 4000, currency: 'jpy' }) + await db.update(schema.payouts) + .set({ status: 'failed', userId: payee.id }) + .where(eq(schema.payouts.jomonRef, ref)) + + const jomon = new StubJomonClient([req(ref, payee.email)]) + const result = await executePayout( + { db, stripe: fakeStripe({ transferOk: true }), jomon }, + config, + ref, + ) + expect(result.outcome).toBe('paid') + + const row = await getPayoutByJomonRef(db, ref) + expect(row?.status).toBe('paid') + expect(jomon.writeBacks).toEqual([ + { jomonRef: ref, result: expect.objectContaining({ status: 'paid' }) }, + ]) + }) + + it('flags a userId mismatch for manual review and does not relink/transfer', async () => { + if (!available) { + return + } + const original = await makePayee('orig', 'done') + const other = await makePayee('other', 'done') + const ref = `jmn-immut-${tag}` + // Row already linked to `original`; the request now resolves to `other`. + await upsertPayoutByJomonRef(db, { jomonRef: ref, amount: 4000, currency: 'jpy' }) + await db.update(schema.payouts) + .set({ userId: original.id }) + .where(eq(schema.payouts.jomonRef, ref)) + + const jomon = new StubJomonClient([req(ref, other.email)]) + const summary = await processApprovedPayouts( + { db, stripe: fakeStripe({ transferOk: true }), jomon }, + config, + ) + expect(summary.needsReview).toBe(1) + expect(summary.paid).toBe(0) + + const row = await getPayoutByJomonRef(db, ref) + // userId is immutable — still the original; no transfer, no write-back. + expect(row?.userId).toBe(original.id) + expect(jomon.writeBacks).toHaveLength(0) + }) + + it('re-attempts the Jomon write-back for a paid row without re-transferring', async () => { + if (!available) { + return + } + const payee = await makePayee('wbretry', 'done') + const ref = `jmn-wbretry-${tag}` + // Already paid, but write-back never recorded (jomonWrittenBackAt NULL). + await upsertPayoutByJomonRef(db, { jomonRef: ref, amount: 4000, currency: 'jpy' }) + await db.update(schema.payouts) + .set({ status: 'paid', stripeTransferId: 'tr_done', userId: payee.id }) + .where(eq(schema.payouts.jomonRef, ref)) + + const jomon = new StubJomonClient([req(ref, payee.email)]) + // A transfer here would throw (transferOk: false) — proving no re-transfer. + const result = await executePayout( + { db, stripe: fakeStripe({ transferOk: false }), jomon }, + config, + ref, + ) + expect(result.outcome).toBe('already_paid') + + const row = await getPayoutByJomonRef(db, ref) + expect(row?.stripeTransferId).toBe('tr_done') + expect(row?.jomonWrittenBackAt).toBeInstanceOf(Date) + // Write-back was re-attempted exactly once with the original transfer id. + expect(jomon.writeBacks).toEqual([ + { jomonRef: ref, result: { status: 'paid', stripeTransferId: 'tr_done' } }, + ]) + }) + + it('does NOT re-attempt write-back when jomonWrittenBackAt is already set', async () => { + if (!available) { + return + } + const payee = await makePayee('wbskip', 'done') + const ref = `jmn-wbskip-${tag}` + await upsertPayoutByJomonRef(db, { jomonRef: ref, amount: 4000, currency: 'jpy' }) + await db.update(schema.payouts) + .set({ status: 'paid', stripeTransferId: 'tr_done', userId: payee.id, jomonWrittenBackAt: new Date() }) + .where(eq(schema.payouts.jomonRef, ref)) + + const jomon = new StubJomonClient([req(ref, payee.email)]) + const result = await executePayout( + { db, stripe: fakeStripe({ transferOk: false }), jomon }, + config, + ref, + ) + expect(result.outcome).toBe('already_paid') + // Already written back — no repeat. + expect(jomon.writeBacks).toHaveLength(0) + }) + + it('isolates a per-item error so the rest of the batch still runs', async () => { + if (!available) { + return + } + const goodPayee = await makePayee('iso-good', 'done') + const badPayee = await makePayee('iso-bad', 'done') + const goodRef = `jmn-iso-good-${tag}` + const badRef = `jmn-iso-bad-${tag}` + + // A stub whose write-back throws ONLY for the bad ref, so its item errors. + class FlakyJomon extends StubJomonClient { + override async writeBackResult(jomonRef: string, result: { status: 'paid' | 'failed', stripeTransferId?: string, message?: string }): Promise { + if (jomonRef === badRef) { + throw new Error('jomon write-back boom') + } + return super.writeBackResult(jomonRef, result) + } + } + const jomon = new FlakyJomon([req(badRef, badPayee.email), req(goodRef, goodPayee.email)]) + + const summary = await processApprovedPayouts( + { db, stripe: fakeStripe({ transferOk: true }), jomon }, + config, + ) + // The good item still paid out despite the bad item throwing. + expect(summary.paid).toBe(1) + expect(summary.errored).toBe(1) + expect(summary.errors).toContain(badRef) + + const goodRow = await getPayoutByJomonRef(db, goodRef) + expect(goodRow?.status).toBe('paid') + // The bad item transferred (paid) but its write-back failed; left for retry + // with jomonWrittenBackAt still NULL (so a later run retries write-back only). + const badRow = await getPayoutByJomonRef(db, badRef) + expect(badRow?.status).toBe('paid') + expect(badRow?.jomonWrittenBackAt).toBeNull() + }) +}) diff --git a/packages/api/src/payouts/execute.ts b/packages/api/src/payouts/execute.ts new file mode 100644 index 0000000..04343a5 --- /dev/null +++ b/packages/api/src/payouts/execute.ts @@ -0,0 +1,407 @@ +import type { Database } from '@checkin/db' +import { deriveMailHash } from '../auth/crypto' +import { getUserById, getUserByMailHash } from '../auth/identity' +import type { StripeClient } from '../stripe/client' +import { createAccountOnboardingLink, getOrCreateConnectedAccount } from '../stripe/connect' +import { createTransfer } from '../stripe/transfers' +import type { JomonClient, JomonTransferRequest } from '../jomon/types' +import { nextPayoutStatus, type PayoutStatus } from './status' +import { + claimPayoutForExecution, + getPayoutByJomonRef, + setPayoutJomonWrittenBackAt, + setPayoutStatus, + setPayoutUserId, + upsertPayoutByJomonRef, + type PayoutRow, +} from './store' + +/** + * Payout execution orchestration (design D4): thin wiring over the pure state + * machine. Stripe/Jomon access goes through the adapters passed in as args; this + * module never imports the Stripe SDK. It pulls approved requests, identifies the + * payee, gates on onboarding, runs the transfer (idempotently), and writes the + * result back to Jomon. + */ + +/** Dependencies injected into the orchestration (host wires the concrete ones). */ +export interface PayoutDeps { + db: Database + stripe: StripeClient + jomon: JomonClient +} + +/** Config the orchestration needs (resolved by the host). */ +export interface PayoutExecuteConfig { + /** HMAC secret to derive `mail_hash` from a payee email. */ + mailHashSecret: string + /** App origin for onboarding refresh/return URLs. */ + appOrigin: string + /** Default payout currency when a request omits one (e.g. 'jpy'). */ + defaultCurrency: string +} + +/** + * The outcome of advancing a single payout one step. + * - `unresolved`: payee not identified by mail_hash — not paid out, 要対応. + * - `onboarding_waiting`: onboarding not done — link issued, parked. + * - `paid`: transfer succeeded. + * - `failed`: transfer attempted but failed. + * - `already_paid`: terminal `paid` — short-circuited, no re-transfer. + * - `needs_review`: a guard prevented progress and a human must act, 要対応: + * the resolved user differs from the immutably-linked one (mis-mapping), a + * row was already claimed/`processing` by a concurrent run, or `processApproved` + * skipped a `failed` row (only the admin `execute` may retry `failed`). + * - `skipped_failed`: `processApproved` did not auto-retry a `failed` row. + */ +export type PayoutOutcome + = 'unresolved' | 'onboarding_waiting' | 'paid' | 'failed' | 'already_paid' + | 'needs_review' | 'skipped_failed' + +/** Per-payout result returned to callers (Stripe/Jomon-type-free). */ +export interface PayoutStepResult { + jomonRef: string + outcome: PayoutOutcome + status: PayoutStatus + /** Hosted onboarding URL, present when the outcome is `onboarding_waiting`. */ + onboardingUrl?: string +} + +/** Aggregate summary of a `processApproved` run. */ +export interface ProcessApprovedSummary { + /** Total approved requests ingested this run. */ + ingested: number + /** Count newly/again paid out. */ + paid: number + /** Count waiting on onboarding. */ + onboardingWaiting: number + /** Count whose payee could not be identified (要対応). */ + unresolved: number + /** Count whose transfer failed. */ + failed: number + /** Count already `paid` and short-circuited. */ + alreadyPaid: number + /** Count flagged for manual review (userId mis-map / claimed by another run). */ + needsReview: number + /** Count of `failed` rows skipped (not auto-retried by `processApproved`). */ + skippedFailed: number + /** Count of items that threw and were isolated (one bad item never aborts the batch). */ + errored: number + /** `jomon_ref`s of items that threw, for accountant follow-up. */ + errors: string[] +} + +/** + * Ingest approved requests and advance each one step (the accounting trigger). + * + * Idempotent end-to-end: ingestion upserts by `jomon_ref`, `paid` payouts + * short-circuit, and the transfer uses a deterministic idempotency key — so a + * repeated run never double-pays. (design D4 / D5; spec §取込は jomon_ref で冪等) + */ +export async function processApprovedPayouts( + deps: PayoutDeps, + config: PayoutExecuteConfig, +): Promise { + const requests = await deps.jomon.listApprovedTransferRequests() + + const summary: ProcessApprovedSummary = { + ingested: 0, + paid: 0, + onboardingWaiting: 0, + unresolved: 0, + failed: 0, + alreadyPaid: 0, + needsReview: 0, + skippedFailed: 0, + errored: 0, + errors: [], + } + + for (const req of requests) { + // Per-item error isolation: one bad item (Jomon write-back, DB hiccup, + // malformed request, etc.) must NOT abort the rest of the batch. Record it + // and continue. (Codex hardening) + try { + // 1. Idempotent upsert by jomon_ref (preserves any existing state). + await upsertPayoutByJomonRef(deps.db, { + jomonRef: req.jomonRef, + amount: req.amount, + currency: req.currency || config.defaultCurrency, + }) + summary.ingested += 1 + + // `processApproved` is the automated trigger: it must NOT auto-retry + // `failed` rows — only the admin single-item `execute` may. (Codex hardening) + const result = await advancePayout(deps, config, req, { allowFailedRetry: false }) + tally(summary, result.outcome) + } + catch (err) { + summary.errored += 1 + summary.errors.push(req.jomonRef) + // Swallow: isolation is the point. Detail is surfaced via the summary. + void err + } + } + + return summary +} + +/** + * Advance a single payout (by `jomonRef`) one step — for manual resume of an + * `onboarding_waiting` payout or a retry after `failed`. Re-pulls the request + * from Jomon so amount/payee are current; throws if it is no longer approved. + * (design D5: `payouts.execute`) + */ +export async function executePayout( + deps: PayoutDeps, + config: PayoutExecuteConfig, + jomonRef: string, +): Promise { + const requests = await deps.jomon.listApprovedTransferRequests() + const req = requests.find(r => r.jomonRef === jomonRef) + if (!req) { + // It may have already settled (and been removed from "approved"). If we have + // a local row, advance using its stored amount/currency; else surface it. + const existing = await getPayoutByJomonRef(deps.db, jomonRef) + if (!existing) { + throw new Error(`payout not found and not approved in Jomon: ${jomonRef}`) + } + // Admin single-item path: `execute` MAY retry a `failed` row. (Codex hardening) + return advancePayout(deps, config, { + jomonRef: existing.jomonRef, + payeeEmail: '', + amount: existing.amount, + currency: existing.currency, + }, { allowFailedRetry: true }) + } + // Ensure the row exists (idempotent) before advancing. + await upsertPayoutByJomonRef(deps.db, { + jomonRef: req.jomonRef, + amount: req.amount, + currency: req.currency || config.defaultCurrency, + }) + // Admin single-item path: `execute` MAY retry a `failed` row. (Codex hardening) + return advancePayout(deps, config, req, { allowFailedRetry: true }) +} + +/** Options that gate how `advancePayout` treats certain statuses. */ +interface AdvanceOptions { + /** + * Whether a `failed` row may be retried. `processApproved` (automated trigger) + * passes `false` — it must NOT auto-retry failures; only the admin single-item + * `execute` passes `true`. (Codex hardening: §explicit retry policy for failed) + */ + allowFailedRetry: boolean +} + +/** + * Advance one payout exactly one step. Assumes the row already exists. Resolves + * the payee, enforces userId immutability, gates on onboarding, ATOMICALLY claims + * the row before the transfer, runs the transfer (idempotently), persists status, + * and writes the settled result back to Jomon. (design D4 + Codex hardening) + */ +async function advancePayout( + deps: PayoutDeps, + config: PayoutExecuteConfig, + req: JomonTransferRequest, + options: AdvanceOptions, +): Promise { + const row = await getPayoutByJomonRef(deps.db, req.jomonRef) + if (!row) { + throw new Error(`payout row missing for jomon_ref: ${req.jomonRef}`) + } + + // `paid` is terminal — never re-transfer. But the Jomon write-back is decoupled + // from the transfer: if a previous run paid but the write-back failed, retry + // ONLY the write-back here (it never re-transfers). (Codex hardening: §retryable + // write-back / spec §再実行は二重送金しない) + if (row.status === 'paid') { + if (row.jomonWrittenBackAt === null) { + await reattemptWriteBack(deps, row) + } + return { jomonRef: req.jomonRef, outcome: 'already_paid', status: 'paid' } + } + + // A row already mid-flight (`processing`) was claimed by a concurrent run; do + // NOT advance/transfer it. Stale `processing` after a crash needs an admin's + // attention; the claim deliberately excludes `processing`, and Stripe's + // idempotency key prevents double money movement on any manual re-run. + if (row.status === 'processing') { + return { jomonRef: req.jomonRef, outcome: 'needs_review', status: 'processing' } + } + + // Retry policy for `failed`: only the admin single-item `execute` may retry; + // `processApproved` skips them so the batch never auto-re-pays a failure. The + // atomic claim WHERE-clause stays inclusive of `failed`; we gate the difference + // here at the orchestration level. (Codex hardening: §explicit retry policy) + if (row.status === 'failed' && !options.allowFailedRetry) { + return { jomonRef: req.jomonRef, outcome: 'skipped_failed', status: 'failed' } + } + + // 2. Identify the payee via the correspondence table (mail_hash). We prefer the + // freshly-pulled email; an empty one (resume without a live request) falls + // back to the already-linked userId. Unresolved ⇒ leave userId null, no payout. + const user = await resolvePayee(deps, config, req, row) + if (!user) { + return { jomonRef: req.jomonRef, outcome: 'unresolved', status: row.status } + } + + // userId is IMMUTABLE once set. If a later run resolves a DIFFERENT user from + // the email, do NOT relink — that signals a mail mapping change / mis-route, so + // flag for manual review and never transfer. Only set userId when still null. + // (Codex hardening: §userId immutability) + if (row.userId === null) { + await setPayoutUserId(deps.db, req.jomonRef, user.id) + } + else if (row.userId !== user.id) { + return { jomonRef: req.jomonRef, outcome: 'needs_review', status: row.status } + } + + // 3. Onboarding gate: not `done` ⇒ get-or-create the connected account, issue a + // hosted onboarding link, and park the payout as `onboarding_waiting`. + if (user.payoutOnboardingStatus !== 'done') { + const accountId = await getOrCreateConnectedAccount(deps.stripe, deps.db, { + userId: user.id, + stripeConnectedAccountId: user.stripeConnectedAccountId, + mailHash: user.mailHash, + }) + const onboardingUrl = await createAccountOnboardingLink(deps.stripe, { + accountId, + refreshUrl: `${config.appOrigin}/payouts/onboarding/refresh`, + returnUrl: `${config.appOrigin}/payouts/onboarding/return`, + }) + const status = nextPayoutStatus(row.status, { onboardingDone: false }) + await setPayoutStatus(deps.db, req.jomonRef, status) + return { jomonRef: req.jomonRef, outcome: 'onboarding_waiting', status, onboardingUrl } + } + + // 4. Onboarding is `done`. + if (!user.stripeConnectedAccountId) { + // Defensive: `done` implies a linked account; if not, treat as not-ready. + const status = nextPayoutStatus(row.status, { onboardingDone: false }) + await setPayoutStatus(deps.db, req.jomonRef, status) + return { jomonRef: req.jomonRef, outcome: 'onboarding_waiting', status } + } + + // 4a. ATOMICALLY claim the row for execution BEFORE the transfer. Only one + // concurrent run can flip a claimable row (`pending`/`onboarding_waiting`/ + // `failed`) to `processing`; the loser re-reads and short-circuits without + // transferring, so two callers can never both pass the pre-transfer check + // and double-pay. (Codex hardening: §atomic execution claim) + const claimed = await claimPayoutForExecution(deps.db, req.jomonRef) + if (!claimed) { + const fresh = await getPayoutByJomonRef(deps.db, req.jomonRef) + // Already paid by the winning run ⇒ short-circuit (retry write-back if needed). + if (fresh?.status === 'paid') { + if (fresh.jomonWrittenBackAt === null) { + await reattemptWriteBack(deps, fresh) + } + return { jomonRef: req.jomonRef, outcome: 'already_paid', status: 'paid' } + } + // Otherwise it is mid-flight (`processing`) or in an unexpected state ⇒ leave + // it to the winner / an admin. Never transfer. (stale `processing` = 要対応) + return { jomonRef: req.jomonRef, outcome: 'needs_review', status: fresh?.status ?? row.status } + } + + // 4b. Claim won — run the transfer (idempotency key `payout:${ref}`; a retry + // with the same key returns the original Transfer, never a second one) and + // settle to `paid`+stripeTransferId or `failed`. + let transferOk: boolean + let transferId: string | undefined + let failureMessage: string | undefined + try { + const result = await createTransfer(deps.stripe, { + destinationAccountId: user.stripeConnectedAccountId, + amount: row.amount, + currency: row.currency, + idempotencyKey: `payout:${req.jomonRef}`, + metadata: { jomon_ref: req.jomonRef }, + }) + transferId = result.transferId + transferOk = true + } + catch (err) { + transferOk = false + failureMessage = err instanceof Error ? err.message : String(err) + } + + // Settle from `processing`: success ⇒ `paid`, failure ⇒ `failed`. We never let + // a stale caller overwrite `paid` because the claim excludes `paid`/`processing`. + const status = transferOk ? 'paid' : 'failed' + await setPayoutStatus(deps.db, req.jomonRef, status, transferId) + + // 5. Write the settled result back to Jomon, then record the write-back so a + // later `paid` re-run does not repeat it. A failure here throws (caught and + // isolated by the batch loop) and leaves jomonWrittenBackAt NULL, so the + // write-back is retried on the next pass WITHOUT re-transferring. + await deps.jomon.writeBackResult(req.jomonRef, { + status: transferOk ? 'paid' : 'failed', + stripeTransferId: transferId, + message: failureMessage, + }) + await setPayoutJomonWrittenBackAt(deps.db, req.jomonRef) + + return { + jomonRef: req.jomonRef, + outcome: transferOk ? 'paid' : 'failed', + status, + } +} + +/** + * Re-attempt ONLY the Jomon write-back for an already-`paid` payout whose + * write-back has not yet been recorded, then mark it done. Never re-transfers. + * (Codex hardening: §retryable write-back avoids re-transfer) + */ +async function reattemptWriteBack(deps: PayoutDeps, row: PayoutRow): Promise { + await deps.jomon.writeBackResult(row.jomonRef, { + status: 'paid', + stripeTransferId: row.stripeTransferId ?? undefined, + }) + await setPayoutJomonWrittenBackAt(deps.db, row.jomonRef) +} + +/** Resolve the payee for a request: by fresh email, else by the linked userId. */ +async function resolvePayee( + deps: PayoutDeps, + config: PayoutExecuteConfig, + req: JomonTransferRequest, + row: PayoutRow, +) { + if (req.payeeEmail) { + const mailHash = deriveMailHash(req.payeeEmail, config.mailHashSecret) + return getUserByMailHash(deps.db, mailHash) + } + // No email (resume of an existing row): use the already-linked payee if any. + if (row.userId) { + return getUserById(deps.db, row.userId) + } + return null +} + +/** Increment the matching summary counter for an outcome. */ +function tally(summary: ProcessApprovedSummary, outcome: PayoutOutcome): void { + switch (outcome) { + case 'paid': + summary.paid += 1 + break + case 'onboarding_waiting': + summary.onboardingWaiting += 1 + break + case 'unresolved': + summary.unresolved += 1 + break + case 'failed': + summary.failed += 1 + break + case 'already_paid': + summary.alreadyPaid += 1 + break + case 'needs_review': + summary.needsReview += 1 + break + case 'skipped_failed': + summary.skippedFailed += 1 + break + } +} diff --git a/packages/api/src/payouts/index.ts b/packages/api/src/payouts/index.ts index cb00e1b..0d50ab4 100644 --- a/packages/api/src/payouts/index.ts +++ b/packages/api/src/payouts/index.ts @@ -1 +1,4 @@ export * from './onboarding' +export * from './status' +export * from './store' +export * from './execute' diff --git a/packages/api/src/payouts/status.test.ts b/packages/api/src/payouts/status.test.ts new file mode 100644 index 0000000..ca7af0a --- /dev/null +++ b/packages/api/src/payouts/status.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import { nextPayoutStatus, type PayoutStatus } from './status' + +describe('nextPayoutStatus', () => { + it('parks at onboarding_waiting when onboarding is not done', () => { + expect(nextPayoutStatus('pending', { onboardingDone: false })) + .toBe('onboarding_waiting') + expect(nextPayoutStatus('onboarding_waiting', { onboardingDone: false })) + .toBe('onboarding_waiting') + expect(nextPayoutStatus('failed', { onboardingDone: false })) + .toBe('onboarding_waiting') + }) + + it('advances to paid when onboarding is done and the transfer succeeds', () => { + expect(nextPayoutStatus('pending', { onboardingDone: true, transferOk: true })) + .toBe('paid') + expect(nextPayoutStatus('onboarding_waiting', { onboardingDone: true, transferOk: true })) + .toBe('paid') + }) + + it('goes to failed when onboarding is done but the transfer fails', () => { + expect(nextPayoutStatus('pending', { onboardingDone: true, transferOk: false })) + .toBe('failed') + expect(nextPayoutStatus('failed', { onboardingDone: true, transferOk: false })) + .toBe('failed') + }) + + it('keeps the current status when done but no transfer was attempted', () => { + expect(nextPayoutStatus('pending', { onboardingDone: true })) + .toBe('pending') + expect(nextPayoutStatus('onboarding_waiting', { onboardingDone: true })) + .toBe('onboarding_waiting') + }) + + it('treats paid as terminal — never regresses or re-transfers', () => { + expect(nextPayoutStatus('paid', { onboardingDone: false })).toBe('paid') + expect(nextPayoutStatus('paid', { onboardingDone: true, transferOk: true })).toBe('paid') + expect(nextPayoutStatus('paid', { onboardingDone: true, transferOk: false })).toBe('paid') + }) + + it('is idempotent — re-applying the settled state is a fixed point', () => { + const paid: PayoutStatus = nextPayoutStatus('pending', { onboardingDone: true, transferOk: true }) + expect(nextPayoutStatus(paid, { onboardingDone: true, transferOk: true })).toBe('paid') + // Even a later "not ready" re-eval cannot un-pay. + expect(nextPayoutStatus(paid, { onboardingDone: false })).toBe('paid') + }) + + it('settles a claimed (processing) row by the transfer outcome', () => { + // The execution claim flips a claimable row to `processing`; the same step + // then settles it. The pure function treats `processing` like any non-paid + // current state: it never regresses to/from paid incorrectly. + expect(nextPayoutStatus('processing', { onboardingDone: true, transferOk: true })).toBe('paid') + expect(nextPayoutStatus('processing', { onboardingDone: true, transferOk: false })).toBe('failed') + expect(nextPayoutStatus('processing', { onboardingDone: false })).toBe('onboarding_waiting') + }) +}) diff --git a/packages/api/src/payouts/status.ts b/packages/api/src/payouts/status.ts new file mode 100644 index 0000000..5261596 --- /dev/null +++ b/packages/api/src/payouts/status.ts @@ -0,0 +1,62 @@ +/** + * Payout state machine — pure, Stripe/Jomon-independent logic. + * + * This module must NOT call any SDK. It decides the next payout status from + * structural inputs, so the rules are trivially testable and the adapter + * boundaries stay in `stripe/*.ts` / `jomon/*.ts`. (design D4) + */ + +/** + * The states of the payout state machine. + * + * `processing` is an application-level execution claim: a single ATOMIC + * conditional UPDATE flips a claimable row (`pending` / `onboarding_waiting` / + * `failed`) to it immediately before the Stripe transfer, so concurrent runs + * cannot both pass the pre-transfer check and double-pay. It is transient — the + * same step settles it to `paid` or `failed`. (§状態機械 / §二重送金しない) + */ +export type PayoutStatus + = 'pending' | 'onboarding_waiting' | 'processing' | 'paid' | 'failed' + +/** Inputs that decide a payout's next status. */ +export interface NextPayoutStatusInput { + /** Whether the payee's connected-account onboarding is `done`. */ + onboardingDone: boolean + /** + * Whether the Stripe transfer succeeded. Only meaningful when a transfer was + * actually attempted (i.e. `onboardingDone` is true). Omit/undefined when no + * transfer was attempted yet. + */ + transferOk?: boolean +} + +/** + * Next payout status given the current status and computed inputs. + * + * Rules (payout-execution spec: §状態機械 / §再実行は二重送金しない): + * - `paid` is terminal — never regress or re-transfer. + * - onboarding not `done` ⇒ `onboarding_waiting` (do not pay out). + * - onboarding `done` + transfer succeeded ⇒ `paid`. + * - onboarding `done` + transfer failed ⇒ `failed`. + * - onboarding `done` but no transfer attempted yet ⇒ keep current. + * + * Idempotent: re-evaluating a `paid` payout stays `paid`, so re-runs never + * trigger a second transfer. + */ +export function nextPayoutStatus( + current: PayoutStatus, + input: NextPayoutStatusInput, +): PayoutStatus { + // `paid` is terminal and must not regress (no re-transfer on re-run). + if (current === 'paid') { + return 'paid' + } + if (!input.onboardingDone) { + return 'onboarding_waiting' + } + // Onboarding is done; decide by the transfer outcome (if one was attempted). + if (input.transferOk === undefined) { + return current + } + return input.transferOk ? 'paid' : 'failed' +} diff --git a/packages/api/src/payouts/store.ts b/packages/api/src/payouts/store.ts new file mode 100644 index 0000000..cc2a730 --- /dev/null +++ b/packages/api/src/payouts/store.ts @@ -0,0 +1,169 @@ +import { randomUUID } from 'node:crypto' +import { and, eq, inArray } from 'drizzle-orm' +import { schema, type Database } from '@checkin/db' +import type { PayoutStatus } from './status' + +/** A payout row as the domain reads it (Stripe/Jomon-type-free). */ +export interface PayoutRow { + id: string + jomonRef: string + userId: string | null + amount: number + currency: string + status: PayoutStatus + stripeTransferId: string | null + /** When the settled result was written back to Jomon, or null if not yet. */ + jomonWrittenBackAt: Date | null +} + +const PAYOUT_COLUMNS = { + id: schema.payouts.id, + jomonRef: schema.payouts.jomonRef, + userId: schema.payouts.userId, + amount: schema.payouts.amount, + currency: schema.payouts.currency, + status: schema.payouts.status, + stripeTransferId: schema.payouts.stripeTransferId, + jomonWrittenBackAt: schema.payouts.jomonWrittenBackAt, +} as const + +/** Statuses from which a payout may be claimed for execution (not `paid`/`processing`). */ +const CLAIMABLE_STATUSES: PayoutStatus[] = ['pending', 'onboarding_waiting', 'failed'] + +/** Inputs to upsert a payout from an ingested Jomon transfer request. */ +export interface UpsertPayoutInput { + jomonRef: string + amount: number + currency: string +} + +/** + * Idempotently record an ingested transfer request and return its row. + * + * Upsert by `jomon_ref` with an `INSERT ... ON DUPLICATE KEY UPDATE` no-op + * (mirrors `getOrCreateUserByMailHash`): re-ingesting the same request keeps the + * single existing row and its accumulated state (status / userId / transfer id) + * — it never creates a duplicate or resets progress. (payout-execution spec: + * §取込は jomon_ref で冪等) + */ +export async function upsertPayoutByJomonRef( + db: Database, + input: UpsertPayoutInput, +): Promise { + const id = randomUUID() + await db + .insert(schema.payouts) + .values({ + id, + jomonRef: input.jomonRef, + amount: input.amount, + currency: input.currency, + }) + // No-op on conflict: preserve existing status/userId/stripeTransferId. We + // touch `jomonRef` (the conflict key itself) so the row is "updated" without + // changing any meaningful column. + .onDuplicateKeyUpdate({ set: { jomonRef: input.jomonRef } }) + + const row = await getPayoutByJomonRef(db, input.jomonRef) + if (!row) { + throw new Error('failed to upsert payout by jomon_ref') + } + return row +} + +/** Fetch a payout row by its Jomon ref, or null. */ +export async function getPayoutByJomonRef( + db: Database, + jomonRef: string, +): Promise { + const [row] = await db + .select(PAYOUT_COLUMNS) + .from(schema.payouts) + .where(eq(schema.payouts.jomonRef, jomonRef)) + .limit(1) + return row ?? null +} + +/** List payouts, optionally filtered by status (newest first). */ +export async function listPayouts( + db: Database, + filter?: { status?: PayoutStatus }, +): Promise { + const base = db.select(PAYOUT_COLUMNS).from(schema.payouts) + const rows = filter?.status + ? await base.where(eq(schema.payouts.status, filter.status)) + : await base + return rows +} + +/** Link a payout to a resolved payee (`users.id`). */ +export async function setPayoutUserId( + db: Database, + jomonRef: string, + userId: string, +): Promise { + await db + .update(schema.payouts) + .set({ userId }) + .where(eq(schema.payouts.jomonRef, jomonRef)) +} + +/** Update a payout's status (and optionally its Stripe transfer id). */ +export async function setPayoutStatus( + db: Database, + jomonRef: string, + status: PayoutStatus, + stripeTransferId?: string, +): Promise { + await db + .update(schema.payouts) + .set({ status, ...(stripeTransferId ? { stripeTransferId } : {}) }) + .where(eq(schema.payouts.jomonRef, jomonRef)) +} + +/** + * Atomically claim a payout for execution: a single conditional UPDATE flips it + * to `processing` ONLY if it is currently in a claimable state (`pending` / + * `onboarding_waiting` / `failed`) — i.e. NOT already `paid` or `processing`. + * + * UPDATE payouts SET status='processing' + * WHERE jomon_ref=? AND status IN ('pending','onboarding_waiting','failed') + * + * Returns `true` iff this call won the claim (`affectedRows === 1`). Two + * concurrent executions for the same `jomon_ref` cannot both win — the loser + * sees the row already `processing`/`paid` and must short-circuit (NOT transfer). + * The Stripe idempotency key (`payout:${jomonRef}`) is the last line of defence + * against double money movement if a claim is ever bypassed. (Codex hardening) + */ +export async function claimPayoutForExecution( + db: Database, + jomonRef: string, +): Promise { + const result = await db + .update(schema.payouts) + .set({ status: 'processing' }) + .where(and( + eq(schema.payouts.jomonRef, jomonRef), + inArray(schema.payouts.status, CLAIMABLE_STATUSES), + )) + + // mysql2 returns affectedRows; 1 ⇒ we won the claim, 0 ⇒ already paid/processing. + // (Same affectedRows extraction as email-verification / identity.) + const affected = (result as unknown as { affectedRows?: number })?.affectedRows ?? 0 + const claimed = Array.isArray(result) + ? ((result[0] as { affectedRows?: number })?.affectedRows ?? 0) + : affected + return claimed === 1 +} + +/** Mark a payout's Jomon write-back as completed (records the timestamp). */ +export async function setPayoutJomonWrittenBackAt( + db: Database, + jomonRef: string, + at: Date = new Date(), +): Promise { + await db + .update(schema.payouts) + .set({ jomonWrittenBackAt: at }) + .where(eq(schema.payouts.jomonRef, jomonRef)) +} diff --git a/packages/api/src/router.ts b/packages/api/src/router.ts index 6b45c6d..2c3f608 100644 --- a/packages/api/src/router.ts +++ b/packages/api/src/router.ts @@ -18,7 +18,14 @@ import { listCheckoutSessions, listInvoices, } from './stripe' -import { nextOnboardingStatus } from './payouts' +import { + executePayout, + listPayouts, + nextOnboardingStatus, + processApprovedPayouts, + type PayoutExecuteConfig, + type PayoutStatus, +} from './payouts' import { checkoutSessionToRow, clampLimit, @@ -323,5 +330,63 @@ export const appRouter = { hasConnectedAccount: row.stripeConnectedAccountId !== null, } }), + + /** + * Ingest Jomon's approved transfer requests and advance each one step + * (the accounting trigger). Admin (accountant) only + `assertCsrf` (it + * mutates: upserts payouts, may issue links / run transfers / write back). + * Returns a plain count summary — no Stripe/Jomon types leak out. + * Idempotent: jomon_ref upsert + `paid` short-circuit + Stripe idempotency + * key prevent double payouts. (payout-execution spec: §払い戻し操作は会計のみ) + */ + processApproved: adminProc + .handler(async ({ context }) => { + context.assertCsrf() + return processApprovedPayouts( + { db: context.db, stripe: context.stripe, jomon: context.jomon }, + payoutExecuteConfig(context), + ) + }), + + /** + * List payouts for the accountant, optionally filtered by status. Read-only: + * admin, no `assertCsrf`. Rows are Stripe/Jomon-type-free DTOs. + */ + list: adminProc + .input(z.object({ + status: z.enum(['pending', 'onboarding_waiting', 'processing', 'paid', 'failed']).optional(), + })) + .handler(async ({ input, context }) => { + const items = await listPayouts(context.db, { status: input.status as PayoutStatus | undefined }) + return { items } + }), + + /** + * Advance / retry a single payout by `jomon_ref` (manual resume of an + * `onboarding_waiting` payout or a retry after `failed`). Admin only + + * `assertCsrf`. Returns a plain step result (no Stripe/Jomon types). + */ + execute: adminProc + .input(z.object({ jomonRef: z.string().min(1) })) + .handler(async ({ input, context }) => { + context.assertCsrf() + return executePayout( + { db: context.db, stripe: context.stripe, jomon: context.jomon }, + payoutExecuteConfig(context), + input.jomonRef, + ) + }), }, } + +/** Build the payout orchestration config from the request Context. */ +function payoutExecuteConfig(context: { + config: { mailHashSecret: string, appOrigin: string } + jomonConfig: { payoutCurrency: string } +}): PayoutExecuteConfig { + return { + mailHashSecret: context.config.mailHashSecret, + appOrigin: context.config.appOrigin, + defaultCurrency: context.jomonConfig.payoutCurrency, + } +} diff --git a/packages/api/src/stripe/index.ts b/packages/api/src/stripe/index.ts index dc8a734..ddae197 100644 --- a/packages/api/src/stripe/index.ts +++ b/packages/api/src/stripe/index.ts @@ -3,4 +3,5 @@ export * from './connect' export * from './customers' export * from './invoices' export * from './listing' +export * from './transfers' export * from './webhook' diff --git a/packages/api/src/stripe/transfers.ts b/packages/api/src/stripe/transfers.ts new file mode 100644 index 0000000..8cc483e --- /dev/null +++ b/packages/api/src/stripe/transfers.ts @@ -0,0 +1,40 @@ +import type { StripeClient } from './client' + +/** Inputs for a Stripe Connect transfer from the platform to a connected account. */ +export interface CreateTransferInput { + /** Destination connected account id (the payee's, resolved via onboarding). */ + destinationAccountId: string + /** Amount in the currency's smallest unit (jpy = whole yen). */ + amount: number + /** ISO currency code, e.g. 'jpy'. */ + currency: string + /** + * Idempotency key — `payout:${jomonRef}`. A retry with the same key returns + * the original Transfer instead of creating a second one, so re-runs never + * double-pay. (payout-execution spec: §再実行は二重送金しない) + */ + idempotencyKey: string + /** Optional non-PII metadata for the Stripe dashboard (e.g. jomon_ref). */ + metadata?: Record +} + +/** + * Create a Stripe Connect transfer (platform → connected account) and return + * its id. Stripe types stay inside this adapter; the domain sees only the plain + * `{ transferId }`. (design D3) + */ +export async function createTransfer( + stripe: StripeClient, + input: CreateTransferInput, +): Promise<{ transferId: string }> { + const transfer = await stripe.sdk.transfers.create( + { + amount: input.amount, + currency: input.currency, + destination: input.destinationAccountId, + metadata: input.metadata, + }, + { idempotencyKey: input.idempotencyKey }, + ) + return { transferId: transfer.id } +} diff --git a/packages/db/drizzle/0004_mature_sentinels.sql b/packages/db/drizzle/0004_mature_sentinels.sql new file mode 100644 index 0000000..21303cf --- /dev/null +++ b/packages/db/drizzle/0004_mature_sentinels.sql @@ -0,0 +1,15 @@ +CREATE TABLE `payouts` ( + `id` varchar(36) NOT NULL, + `jomon_ref` varchar(255) NOT NULL, + `user_id` varchar(36), + `amount` int NOT NULL, + `currency` varchar(8) NOT NULL, + `status` enum('pending','onboarding_waiting','paid','failed') NOT NULL DEFAULT 'pending', + `stripe_transfer_id` varchar(255), + `created_at` timestamp NOT NULL DEFAULT (now()), + `updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `payouts_id` PRIMARY KEY(`id`), + CONSTRAINT `payouts_jomon_ref_unique` UNIQUE(`jomon_ref`) +); +--> statement-breakpoint +ALTER TABLE `payouts` ADD CONSTRAINT `payouts_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE no action ON UPDATE no action; \ No newline at end of file diff --git a/packages/db/drizzle/0005_blushing_blindfold.sql b/packages/db/drizzle/0005_blushing_blindfold.sql new file mode 100644 index 0000000..65c3785 --- /dev/null +++ b/packages/db/drizzle/0005_blushing_blindfold.sql @@ -0,0 +1,2 @@ +ALTER TABLE `payouts` MODIFY COLUMN `status` enum('pending','onboarding_waiting','processing','paid','failed') NOT NULL DEFAULT 'pending';--> statement-breakpoint +ALTER TABLE `payouts` ADD `jomon_written_back_at` timestamp; \ No newline at end of file diff --git a/packages/db/drizzle/meta/0004_snapshot.json b/packages/db/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..e34bd0c --- /dev/null +++ b/packages/db/drizzle/meta/0004_snapshot.json @@ -0,0 +1,421 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "6446710c-d4b0-48ee-98ec-e063c9beaa7e", + "prevId": "7e4eab66-6c1b-4005-8ffd-8fc690c1101c", + "tables": { + "email_verifications": { + "name": "email_verifications", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mail_hash": { + "name": "mail_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect": { + "name": "redirect", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "email_verifications_id": { + "name": "email_verifications_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "email_verifications_token_hash_unique": { + "name": "email_verifications_token_hash_unique", + "columns": [ + "token_hash" + ] + } + }, + "checkConstraint": {} + }, + "payouts": { + "name": "payouts", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "jomon_ref": { + "name": "jomon_ref", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "amount": { + "name": "amount", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "currency": { + "name": "currency", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('pending','onboarding_waiting','paid','failed')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "stripe_transfer_id": { + "name": "stripe_transfer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "payouts_user_id_users_id_fk": { + "name": "payouts_user_id_users_id_fk", + "tableFrom": "payouts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "payouts_id": { + "name": "payouts_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "payouts_jomon_ref_unique": { + "name": "payouts_jomon_ref_unique", + "columns": [ + "jomon_ref" + ] + } + }, + "checkConstraint": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id_hash": { + "name": "id_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_type": { + "name": "actor_type", + "type": "enum('user','admin')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "traq_id": { + "name": "traq_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sessions_id": { + "name": "sessions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "sessions_id_hash_unique": { + "name": "sessions_id_hash_unique", + "columns": [ + "id_hash" + ] + } + }, + "checkConstraint": {} + }, + "stripe_events": { + "name": "stripe_events", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "received_at": { + "name": "received_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "stripe_events_id": { + "name": "stripe_events_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "stripe_events_event_id_unique": { + "name": "stripe_events_event_id_unique", + "columns": [ + "event_id" + ] + } + }, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mail_hash": { + "name": "mail_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripe_connected_account_id": { + "name": "stripe_connected_account_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payout_onboarding_status": { + "name": "payout_onboarding_status", + "type": "enum('none','requested','done')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'none'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "users_mail_hash_unique": { + "name": "users_mail_hash_unique", + "columns": [ + "mail_hash" + ] + }, + "users_stripe_connected_account_id_unique": { + "name": "users_stripe_connected_account_id_unique", + "columns": [ + "stripe_connected_account_id" + ] + } + }, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/0005_snapshot.json b/packages/db/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..e21ab8b --- /dev/null +++ b/packages/db/drizzle/meta/0005_snapshot.json @@ -0,0 +1,428 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "b5f06577-c647-4637-a061-df68cdddd7dc", + "prevId": "6446710c-d4b0-48ee-98ec-e063c9beaa7e", + "tables": { + "email_verifications": { + "name": "email_verifications", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mail_hash": { + "name": "mail_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect": { + "name": "redirect", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "email_verifications_id": { + "name": "email_verifications_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "email_verifications_token_hash_unique": { + "name": "email_verifications_token_hash_unique", + "columns": [ + "token_hash" + ] + } + }, + "checkConstraint": {} + }, + "payouts": { + "name": "payouts", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "jomon_ref": { + "name": "jomon_ref", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "amount": { + "name": "amount", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "currency": { + "name": "currency", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('pending','onboarding_waiting','processing','paid','failed')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "stripe_transfer_id": { + "name": "stripe_transfer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "jomon_written_back_at": { + "name": "jomon_written_back_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "payouts_user_id_users_id_fk": { + "name": "payouts_user_id_users_id_fk", + "tableFrom": "payouts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "payouts_id": { + "name": "payouts_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "payouts_jomon_ref_unique": { + "name": "payouts_jomon_ref_unique", + "columns": [ + "jomon_ref" + ] + } + }, + "checkConstraint": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id_hash": { + "name": "id_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_type": { + "name": "actor_type", + "type": "enum('user','admin')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "traq_id": { + "name": "traq_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sessions_id": { + "name": "sessions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "sessions_id_hash_unique": { + "name": "sessions_id_hash_unique", + "columns": [ + "id_hash" + ] + } + }, + "checkConstraint": {} + }, + "stripe_events": { + "name": "stripe_events", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "received_at": { + "name": "received_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "stripe_events_id": { + "name": "stripe_events_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "stripe_events_event_id_unique": { + "name": "stripe_events_event_id_unique", + "columns": [ + "event_id" + ] + } + }, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mail_hash": { + "name": "mail_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripe_connected_account_id": { + "name": "stripe_connected_account_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payout_onboarding_status": { + "name": "payout_onboarding_status", + "type": "enum('none','requested','done')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'none'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "users_mail_hash_unique": { + "name": "users_mail_hash_unique", + "columns": [ + "mail_hash" + ] + }, + "users_stripe_connected_account_id_unique": { + "name": "users_stripe_connected_account_id_unique", + "columns": [ + "stripe_connected_account_id" + ] + } + }, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 914ab72..0783db3 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -29,6 +29,20 @@ "when": 1781882910210, "tag": "0003_happy_cobalt_man", "breakpoints": true + }, + { + "idx": 4, + "version": "5", + "when": 1781883548651, + "tag": "0004_mature_sentinels", + "breakpoints": true + }, + { + "idx": 5, + "version": "5", + "when": 1781884730370, + "tag": "0005_blushing_blindfold", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index cca70ee..873c9e4 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -1,4 +1,4 @@ -import { mysqlTable, varchar, timestamp, mysqlEnum } from 'drizzle-orm/mysql-core' +import { mysqlTable, varchar, timestamp, mysqlEnum, int } from 'drizzle-orm/mysql-core' /** * Auth / identity foundation (OpenSpec change: add-auth-foundation). @@ -80,3 +80,46 @@ export const stripeEvents = mysqlTable('stripe_events', { type: varchar('type', { length: 255 }).notNull(), receivedAt: timestamp('received_at').notNull().defaultNow(), }) + +/** + * Approved transfer requests pulled from Jomon and paid out via Stripe Connect + * (OpenSpec change: add-payout-execution). Jomon owns approval/application; + * Checkin only executes the payout and writes the result back (design §5.3). + * + * `jomon_ref` is the unique source key: ingestion upserts by it (`INSERT ... ON + * DUPLICATE KEY UPDATE` no-op), so re-importing the same request never creates a + * duplicate row or a double payout. Combined with the Stripe idempotency key + * (`payout:${jomon_ref}`) and the terminal `paid` short-circuit, payouts run at + * most once. (payout-execution spec: §取込は jomon_ref で冪等 / §二重送金しない) + */ +export const payouts = mysqlTable('payouts', { + id: varchar('id', { length: 36 }).primaryKey(), + // Opaque Jomon transfer-request id. Unique: one payout row per Jomon request. + jomonRef: varchar('jomon_ref', { length: 255 }).notNull().unique(), + // Resolved payee (`users.id`), or NULL when the payee could not be identified + // by `mail_hash` — such rows stay `pending`, are not paid out, and need an + // accountant to act. (payout-execution spec: §本人特定(対応表 mail_hash)) + userId: varchar('user_id', { length: 36 }).references(() => users.id), + // Amount in the currency's smallest unit (Stripe convention; jpy has no minor + // unit so this is whole yen). Currency code, e.g. 'jpy'. + amount: int('amount').notNull(), + currency: varchar('currency', { length: 8 }).notNull(), + // Payout state machine: pending → onboarding_waiting (payee onboarding not + // done) → processing (execution claimed, transfer in flight) → paid (transfer + // succeeded) / failed (transfer failed). `paid` is terminal. `processing` is an + // application-level claim: a single ATOMIC conditional UPDATE flips a row to it + // before the Stripe transfer, so two concurrent runs cannot both pass the + // pre-transfer check and double-pay. (payout-execution spec: §状態機械 / §二重送金しない) + status: mysqlEnum('status', ['pending', 'onboarding_waiting', 'processing', 'paid', 'failed']) + .notNull() + .default('pending'), + // Stripe Transfer id, set once the payout succeeds (status `paid`). + stripeTransferId: varchar('stripe_transfer_id', { length: 255 }), + // When the settled result was successfully written back to Jomon (NULL until + // then). Decouples the write-back from the transfer: a transfer can succeed + // (`paid`) while the write-back fails, so a `paid` re-run with a NULL value + // here re-attempts ONLY the write-back — it never re-transfers. (Codex hardening) + jomonWrittenBackAt: timestamp('jomon_written_back_at'), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow().onUpdateNow(), +}) From cd0c61c68252a8e567760b5057d54ed1310a5549 Mon Sep 17 00:00:00 2001 From: kaitoyama Date: Sat, 20 Jun 2026 22:11:20 +0900 Subject: [PATCH 05/30] feat: traQ member auth + traq_id linkage (dual-identity sessions) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit add-traq-member-auth (OpenSpec): rework auth so a session can carry BOTH a traQ identity and an isct user identity, so Jomon payouts (keyed by traQ ID) can resolve to our users. - Dual-identity session: SessionIdentity { traqId, isAdmin, userId, mailHash }. traQ login now establishes a MEMBER session for any traQ user; accountant (isAdmin) is the env allow-list subset (was: reject non-allowlisted). requireMember/requireUser/requireAdmin + memberProc/userProc/adminProc (auth before input validation). - users.traq_id (UNIQUE) + race-safe linkTraqId (compare-and-set, dup-key via .cause → 'conflict', never overwrites/relinks). - Linkage: traQ login resolves a linked user; isct confirm on an existing traQ session links the two (link-first, and on conflict mint a plain user session instead of a corrupted dual one); issueInvoice links the caller's own traQ id and stamps it into Customer metadata (never a conflicting/foreign traq id). - auth.me → { authenticated, member, admin, hasUser, traqId }. Migration 0006 (sessions.is_admin, users.traq_id; drop actor_type). Codex-reviewed (link-before-attach ordering + metadata-poisoning on conflict fixed). Delta specs synced (admin-authorization/session MODIFIED, identity/membership-billing ADDED) and archived. Gates green: lint, typecheck, build, test (104). Uncommitted member UI adjusted minimally to compile; it is reworked in add-member-ui. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/app/app.config.ts | 8 + apps/web/app/app.vue | 8 +- apps/web/app/assets/css/main.css | 2 + apps/web/app/composables/useAuthMe.ts | 10 + apps/web/app/composables/useCsrf.ts | 42 + .../app/composables/useSanitizeRedirect.ts | 25 + apps/web/app/layouts/default.vue | 87 ++ apps/web/app/pages/index.vue | 76 +- apps/web/app/pages/membership.vue | 221 +++ apps/web/app/pages/verify-email.vue | 109 ++ apps/web/nuxt.config.ts | 3 +- apps/web/package.json | 3 + apps/web/server/routes/login/callback.get.ts | 23 +- .../server/routes/verify-email/confirm.get.ts | 54 +- eslint.config.mjs | 5 +- openspec/changes/add-member-ui/.openspec.yaml | 2 + openspec/changes/add-member-ui/design.md | 79 + openspec/changes/add-member-ui/proposal.md | 32 + .../add-member-ui/specs/member-ui/spec.md | 72 + openspec/changes/add-member-ui/tasks.md | 23 + .../.openspec.yaml | 2 + .../2026-06-20-add-traq-member-auth/design.md | 77 + .../proposal.md | 36 + .../specs/admin-authorization/spec.md | 29 + .../specs/identity/spec.md | 25 + .../specs/membership-billing/spec.md | 20 + .../specs/session/spec.md | 54 + .../2026-06-20-add-traq-member-auth/tasks.md | 36 + openspec/specs/admin-authorization/spec.md | 18 +- openspec/specs/identity/spec.md | 24 + openspec/specs/membership-billing/spec.md | 24 + openspec/specs/session/spec.md | 40 +- packages/api/src/auth/auth.test.ts | 29 +- packages/api/src/auth/context.ts | 38 +- packages/api/src/auth/identity.test.ts | 174 +++ packages/api/src/auth/identity.ts | 103 +- packages/api/src/auth/session.ts | 82 +- packages/api/src/orpc.ts | 15 +- packages/api/src/router.ts | 43 +- packages/db/drizzle/0006_wild_nemesis.sql | 4 + packages/db/drizzle/meta/0006_snapshot.json | 442 ++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/schema.ts | 23 +- pnpm-lock.yaml | 1376 ++++++++++++++++- 44 files changed, 3450 insertions(+), 155 deletions(-) create mode 100644 apps/web/app/app.config.ts create mode 100644 apps/web/app/assets/css/main.css create mode 100644 apps/web/app/composables/useAuthMe.ts create mode 100644 apps/web/app/composables/useCsrf.ts create mode 100644 apps/web/app/composables/useSanitizeRedirect.ts create mode 100644 apps/web/app/layouts/default.vue create mode 100644 apps/web/app/pages/membership.vue create mode 100644 apps/web/app/pages/verify-email.vue create mode 100644 openspec/changes/add-member-ui/.openspec.yaml create mode 100644 openspec/changes/add-member-ui/design.md create mode 100644 openspec/changes/add-member-ui/proposal.md create mode 100644 openspec/changes/add-member-ui/specs/member-ui/spec.md create mode 100644 openspec/changes/add-member-ui/tasks.md create mode 100644 openspec/changes/archive/2026-06-20-add-traq-member-auth/.openspec.yaml create mode 100644 openspec/changes/archive/2026-06-20-add-traq-member-auth/design.md create mode 100644 openspec/changes/archive/2026-06-20-add-traq-member-auth/proposal.md create mode 100644 openspec/changes/archive/2026-06-20-add-traq-member-auth/specs/admin-authorization/spec.md create mode 100644 openspec/changes/archive/2026-06-20-add-traq-member-auth/specs/identity/spec.md create mode 100644 openspec/changes/archive/2026-06-20-add-traq-member-auth/specs/membership-billing/spec.md create mode 100644 openspec/changes/archive/2026-06-20-add-traq-member-auth/specs/session/spec.md create mode 100644 openspec/changes/archive/2026-06-20-add-traq-member-auth/tasks.md create mode 100644 packages/api/src/auth/identity.test.ts create mode 100644 packages/db/drizzle/0006_wild_nemesis.sql create mode 100644 packages/db/drizzle/meta/0006_snapshot.json diff --git a/apps/web/app/app.config.ts b/apps/web/app/app.config.ts new file mode 100644 index 0000000..ca70362 --- /dev/null +++ b/apps/web/app/app.config.ts @@ -0,0 +1,8 @@ +export default defineAppConfig({ + ui: { + colors: { + primary: 'blue', + neutral: 'slate', + }, + }, +}) diff --git a/apps/web/app/app.vue b/apps/web/app/app.vue index 5c9e4ff..767e1e7 100644 --- a/apps/web/app/app.vue +++ b/apps/web/app/app.vue @@ -1,4 +1,8 @@ diff --git a/apps/web/app/assets/css/main.css b/apps/web/app/assets/css/main.css new file mode 100644 index 0000000..7c95c6f --- /dev/null +++ b/apps/web/app/assets/css/main.css @@ -0,0 +1,2 @@ +@import "tailwindcss"; +@import "@nuxt/ui"; diff --git a/apps/web/app/composables/useAuthMe.ts b/apps/web/app/composables/useAuthMe.ts new file mode 100644 index 0000000..7127722 --- /dev/null +++ b/apps/web/app/composables/useAuthMe.ts @@ -0,0 +1,10 @@ +/** + * Shared `auth.me` state. A single `useAsyncData` keyed `auth-me` is reused + * across the layout and pages so the identity is fetched once (SSR-friendly) and + * can be refreshed after login/logout. Returns the dual-identity shape + * `{ authenticated, member, admin, hasUser, traqId }`. + */ +export function useAuthMe() { + const { $orpc } = useNuxtApp() + return useAsyncData('auth-me', () => $orpc.auth.me()) +} diff --git a/apps/web/app/composables/useCsrf.ts b/apps/web/app/composables/useCsrf.ts new file mode 100644 index 0000000..59addd5 --- /dev/null +++ b/apps/web/app/composables/useCsrf.ts @@ -0,0 +1,42 @@ +const CSRF_COOKIE = '__Host-checkin_csrf' + +/** Read the (non-HttpOnly) CSRF cookie value on the client. */ +function readCsrfCookie(): string | undefined { + if (import.meta.server) { + return undefined + } + const hit = document.cookie.split('; ').find(c => c.startsWith(`${CSRF_COOKIE}=`)) + return hit ? decodeURIComponent(hit.slice(CSRF_COOKIE.length + 1)) : undefined +} + +/** + * CSRF helper for the browser-redirect Nitro routes (`POST /logout`). Reads the + * double-submit cookie set by `GET /csrf`, fetching it once if absent, then + * exposes `logout()` which posts `/logout` with the `x-csrf-token` header, + * refreshes `auth.me`, and navigates home. + */ +export function useCsrf() { + /** Ensure a CSRF cookie exists (fetching `GET /csrf` once if needed) and return its value. */ + async function ensureCsrfToken(): Promise { + let token = readCsrfCookie() + if (!token) { + await $fetch('/csrf', { credentials: 'same-origin' }) + token = readCsrfCookie() + } + return token + } + + /** Destroy the session via `POST /logout`, then refresh auth state and go home. */ + async function logout(): Promise { + const token = await ensureCsrfToken() + await $fetch('/logout', { + method: 'POST', + credentials: 'same-origin', + headers: token ? { 'x-csrf-token': token } : {}, + }) + await useAuthMe().refresh() + await navigateTo('/') + } + + return { ensureCsrfToken, logout } +} diff --git a/apps/web/app/composables/useSanitizeRedirect.ts b/apps/web/app/composables/useSanitizeRedirect.ts new file mode 100644 index 0000000..77bba62 --- /dev/null +++ b/apps/web/app/composables/useSanitizeRedirect.ts @@ -0,0 +1,25 @@ +/** + * Sanitize a `redirect` query to a same-site path, mirroring the server's + * `sanitizeRedirect` (packages/api/src/auth/redirect.ts). Only same-origin + * absolute-path URLs (a single leading `/`) survive; anything else (external, + * protocol-relative `//`, backslash tricks) falls back. Reimplemented here so + * the client imports only the server's *types*, never its implementation. + */ +export function sanitizeRedirect(redirect: string | null | undefined, fallback = '/'): string { + if (!redirect) { + return fallback + } + if (!redirect.startsWith('/') || redirect.startsWith('//') || redirect.startsWith('/\\')) { + return fallback + } + try { + const url = new URL(redirect, 'http://placeholder.invalid') + if (url.origin !== 'http://placeholder.invalid') { + return fallback + } + return url.pathname + url.search + url.hash + } + catch { + return fallback + } +} diff --git a/apps/web/app/layouts/default.vue b/apps/web/app/layouts/default.vue new file mode 100644 index 0000000..004a3e6 --- /dev/null +++ b/apps/web/app/layouts/default.vue @@ -0,0 +1,87 @@ + + + diff --git a/apps/web/app/pages/index.vue b/apps/web/app/pages/index.vue index 7321222..5dbc09a 100644 --- a/apps/web/app/pages/index.vue +++ b/apps/web/app/pages/index.vue @@ -1,14 +1,74 @@ diff --git a/apps/web/app/pages/membership.vue b/apps/web/app/pages/membership.vue new file mode 100644 index 0000000..1001cf5 --- /dev/null +++ b/apps/web/app/pages/membership.vue @@ -0,0 +1,221 @@ + + + diff --git a/apps/web/app/pages/verify-email.vue b/apps/web/app/pages/verify-email.vue new file mode 100644 index 0000000..dcd16dc --- /dev/null +++ b/apps/web/app/pages/verify-email.vue @@ -0,0 +1,109 @@ + + + diff --git a/apps/web/nuxt.config.ts b/apps/web/nuxt.config.ts index 40c68d8..681821a 100644 --- a/apps/web/nuxt.config.ts +++ b/apps/web/nuxt.config.ts @@ -1,7 +1,8 @@ // https://nuxt.com/docs/api/configuration/nuxt-config export default defineNuxtConfig({ - modules: ['@nuxt/eslint'], + modules: ['@nuxt/eslint', '@nuxt/ui'], devtools: { enabled: true }, + css: ['~/assets/css/main.css'], runtimeConfig: { // Server-only secrets/config. Override at runtime via NUXT_* env vars. databaseUrl: process.env.DATABASE_URL ?? '', diff --git a/apps/web/package.json b/apps/web/package.json index f4abaaa..8530a59 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -13,9 +13,12 @@ "dependencies": { "@checkin/api": "workspace:*", "@checkin/db": "workspace:*", + "@nuxt/ui": "^3.3.7", "@orpc/client": "^1.14.0", "@orpc/server": "^1.14.0", "nuxt": "^4.4.0", + "tailwindcss": "^4.3.1", + "tslib": "^2.8.1", "vue": "^3.5.0" }, "devDependencies": { diff --git a/apps/web/server/routes/login/callback.get.ts b/apps/web/server/routes/login/callback.get.ts index 5238c9b..9bc2d45 100644 --- a/apps/web/server/routes/login/callback.get.ts +++ b/apps/web/server/routes/login/callback.get.ts @@ -2,6 +2,7 @@ import { createSession, exchangeCodeForToken, fetchTraqUserId, + getUserByTraqId, isAccountant, safeEqual, sanitizeRedirect, @@ -9,7 +10,11 @@ import { /** * GET /login/callback — validate state, exchange the code, resolve the traQ ID, - * gate on the accountant allow-list, then establish an admin session. + * then establish a **member** session for ANY successful traQ login. The env + * allow-list only decides `isAdmin` (accountant) — it no longer gates login. + * If the traQ ID is already linked to a user (`users.traq_id`), the resolved + * userId is put on the session too (linked members skip re-confirmation). + * (admin-authorization spec: §traQ OAuth による会計ログイン / §env 許可リストによる会計判定) */ export default defineEventHandler(async (event) => { const config = resolveAuthConfig() @@ -33,12 +38,20 @@ export default defineEventHandler(async (event) => { redirectUri, }) const traqId = await fetchTraqUserId(config.traq, accessToken) + const db = useDatabase() - if (!isAccountant(traqId, config.accountantTraqIds)) { - return sendRedirect(event, '/?login=forbidden') - } + // Accountant权限 is a subset: granted only to allow-listed traQ IDs. Login + // succeeds regardless (everyone becomes a member). + const isAdmin = isAccountant(traqId, config.accountantTraqIds) + // Already-linked member → resolve their user so the session carries both + // identities without requiring a fresh isct confirmation. + const linked = await getUserByTraqId(db, traqId) - const token = await createSession(useDatabase(), { actor: 'admin', traqId }, config.sessionTtlSec) + const token = await createSession( + db, + { traqId, isAdmin, userId: linked?.id ?? null }, + config.sessionTtlSec, + ) setSessionCookie(event, token, config.sessionTtlSec) return sendRedirect(event, sanitizeRedirect(cookies.redirect)) } diff --git a/apps/web/server/routes/verify-email/confirm.get.ts b/apps/web/server/routes/verify-email/confirm.get.ts index 371ad95..8e3f0c1 100644 --- a/apps/web/server/routes/verify-email/confirm.get.ts +++ b/apps/web/server/routes/verify-email/confirm.get.ts @@ -1,9 +1,28 @@ -import { consumeEmailVerification, createSession, getOrCreateUserByMailHash, sanitizeRedirect } from '@checkin/api' +import { + attachUserToSession, + consumeEmailVerification, + createSession, + getOrCreateUserByMailHash, + linkTraqId, + resolveSession, + sanitizeRedirect, +} from '@checkin/api' /** * GET /verify-email/confirm — consume a magic-link token (single-use, expiry, - * reuse-safe), get-or-create the person row, establish a user session, then - * navigate to the stored same-site redirect. + * reuse-safe) and get-or-create the person row. + * + * If the request already carries a traQ **member** session that has no user yet, + * we first try to `linkTraqId` the authenticated traQ ID onto the confirmed user + * row. Only when that link is coherent (`linked`/`exists`, i.e. the traq_id truly + * belongs to this user) do we attach `user_id` onto the SAME session, so the + * member ends up with both identities on one session. On `conflict` (the traq_id + * is owned by a DIFFERENT user) we do NOT attach to the traQ session — that would + * fabricate a dual identity the DB disagrees with. Instead we mint a FRESH plain + * user session so the person is logged in as their own isct identity, and warn for + * admin attention. Otherwise (no traQ session) we mint a plain user session as + * before. Then navigate to the stored same-site redirect. + * (session spec: §連結済みは両アイデンティティを持つ) */ export default defineEventHandler(async (event) => { const config = resolveAuthConfig() @@ -20,7 +39,32 @@ export default defineEventHandler(async (event) => { } const user = await getOrCreateUserByMailHash(db, consumed.mailHash) - const sessionToken = await createSession(db, { actor: 'user', userId: user.id }, config.sessionTtlSec) - setSessionCookie(event, sessionToken, config.sessionTtlSec) + + // An existing traQ member session without a linked user → connect them in place. + const sessionToken = getCookie(event, SESSION_COOKIE) + const existing = await resolveSession(db, sessionToken) + if (sessionToken && existing?.traqId && !existing.userId) { + // Link BEFORE attaching: only fuse the two identities onto the live traQ + // session when the traq_id legitimately belongs to this user. A `conflict` + // (the traq_id is owned by someone else) must NOT be attached, or the session + // would carry a traQ linkage the DB attributes to a different person. + const result = await linkTraqId(db, user.id, existing.traqId) + if (result === 'conflict') { + console.warn(`linkTraqId conflict on verify-email confirm: traqId=${existing.traqId} userId=${user.id}`) + // Don't fabricate a dual identity. Mint a fresh plain user session so the + // person is logged in as their own isct identity (no false traQ linkage). + const conflictToken = await createSession(db, { userId: user.id }, config.sessionTtlSec) + setSessionCookie(event, conflictToken, config.sessionTtlSec) + return sendRedirect(event, sanitizeRedirect(consumed.redirect)) + } + // Coherent linkage (linked/exists) → attach the user onto the SAME session so + // it carries both identities. Same cookie/token — no need to reset it. + await attachUserToSession(db, sessionToken, user.id) + return sendRedirect(event, sanitizeRedirect(consumed.redirect)) + } + + // No traQ session (isct-only): establish a fresh user session as before. + const newToken = await createSession(db, { userId: user.id }, config.sessionTtlSec) + setSessionCookie(event, newToken, config.sessionTtlSec) return sendRedirect(event, sanitizeRedirect(consumed.redirect)) }) diff --git a/eslint.config.mjs b/eslint.config.mjs index 748eab5..22b1221 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -14,8 +14,9 @@ export default withNuxt({ '.claude/**', ], }, { - // Nuxt pages map to routes, so single-word filenames (index, login, ...) are fine. - files: ['apps/web/app/pages/**/*.vue'], + // Nuxt pages/layouts map to routes/named layouts, so single-word filenames + // (index, login, default, ...) are fine. + files: ['apps/web/app/pages/**/*.vue', 'apps/web/app/layouts/**/*.vue'], rules: { 'vue/multi-word-component-names': 'off', }, diff --git a/openspec/changes/add-member-ui/.openspec.yaml b/openspec/changes/add-member-ui/.openspec.yaml new file mode 100644 index 0000000..ff1fbc8 --- /dev/null +++ b/openspec/changes/add-member-ui/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-19 diff --git a/openspec/changes/add-member-ui/design.md b/openspec/changes/add-member-ui/design.md new file mode 100644 index 0000000..b4fb14f --- /dev/null +++ b/openspec/changes/add-member-ui/design.md @@ -0,0 +1,79 @@ +## Context + +バックエンド([[add-auth-foundation]] / [[add-membership-collection]])は揃ったが UI が無い。利用者向けの最小フロー(トップ/isct メール確認/部費支払い)を `@nuxt/ui` で実装し、集金をエンドツーエンドで使えるようにする。会計(管理)UI は後続。 + +確定済み前提(§2 認証方針): 利用者=isct マジックリンク、会計=traQ OAuth。よって **支払い者は全員 isct 確認**で統一し、design §5.1 旧フローの「現役→traQ」は採らない(reconcile)。 + +既存サーバ機能(UI から使う): +- oRPC: `auth.me`(`{actor:null|'user'|'admin', traqId?}`)、`auth.requestEmailVerification({email, redirect?})`、`membership.issueInvoice({email,name,feeType})`→`{invoiceId, hostedInvoiceUrl}`。 +- Nitro(フルページ遷移): `GET /verify-email/confirm`(メールリンク先)、`GET /login`(traQ)、`POST /logout`、`GET /csrf`。 +- CSRF: 既存 oRPC クライアントプラグインが mutation に `x-csrf-token` を自動付与(cookie 由来)。 + +## Goals / Non-Goals + +**Goals:** +- `@nuxt/ui` 導入と共通レイアウト(ヘッダー:ロゴ+ログイン状態)。 +- `/`・`/verify-email`・`/membership` の利用者向けページ。 +- `auth.me` による状態別出し分け、verify の redirect 保持、issueInvoice→支払いページ誘導。 + +**Non-Goals:** +- `/payments`・`/admins`・払い戻し管理(後続)。ステップバー、入部フォーム、決済状況の自動反映。 + +## Decisions + +### D1. @nuxt/ui 導入 + +- `apps/web` に `@nuxt/ui`(Nuxt 4 対応版)を追加。`nuxt.config` の `modules` に `'@nuxt/ui'` を追加(`@nuxt/eslint` と併用)。 +- Tailwind v4(@nuxt/ui 同梱)。`app/assets/css/main.css` に `@import "tailwindcss";` + `@import "@nuxt/ui";`、`nuxt.config` の `css` に登録。`app/app.config.ts` でテーマ(primary 色等)を設定。 +- `app/app.vue` を `` でラップ(トースト/オーバーレイ提供)し、``。 + +### D2. レイアウト/ヘッダー(`app/layouts/default.vue`) + +- ヘッダー: サービスロゴ(`Checkin`)、右側にログイン状態。`auth.me` を共有 composable `useAuthMe()`(`useAsyncData('auth-me', () => $orpc.auth.me())`、`refresh` 公開)で取得。 +- 未ログイン: 「部費を払う」(/membership)、「会計の方はこちら」(``)。 +- ログイン中: アクター表示(user/会計 traqId)+ログアウトボタン。 +- 全ページ `layout: 'default'`。 + +### D3. ログアウト(CSRF 対応) + +- `POST /logout` は CSRF が要る。`useCsrf()` ヘルパ: `__Host-checkin_csrf` cookie を読み、無ければ `GET /csrf` を取得 → `x-csrf-token` 付きで `POST /logout` → 成功後 `useAuthMe().refresh()` + `/` へ。 + +### D4. `/`(`app/pages/index.vue`、既存 health 版を置換) + +- `auth.me` で出し分け。未ログイン: サービス説明+「部費を払う」+会計ログイン導線。ログイン中: アクター表示+主要導線。 + +### D5. `/verify-email`(`app/pages/verify-email.vue`) + +- `UInput`(email)+`UButton`。送信で `auth.requestEmailVerification({ email, redirect })`(`redirect` は `route.query.redirect` を sanitize して付与)。 +- 成功: 「確認メールを送信しました。メール内のリンクから続行してください」をトースト/`UAlert`。失敗(ドメイン外等)はエラー表示。二重送信防止(送信中は disabled)。 + +### D6. `/membership`(`app/pages/membership.vue`) + +- `auth.me` を取得。 +- 未ログイン(`actor !== 'user'`): `新規入部 / 再入部 / 現役` の選択(`UButton`/カード)。いずれも `navigateTo('/verify-email?redirect=' + encodeURIComponent('/membership?type=' + sel))`(選択を type クエリで持ち越し)。 +- ログイン済み(`actor==='user'`): 請求書フォーム(`email`・`name`・`区分`セレクト。`type` クエリがあれば区分を初期選択)。送信で区分→feeType(`新規入部`/`再入部`→`new`、`現役`→`continuation`)に変換し `membership.issueInvoice`。 + - 成功: `hostedInvoiceUrl` への「支払いページへ進む」ボタン(`` or `navigateTo(url, {external:true})`)。 + - 失敗: エラー表示(例: メール不一致=「確認したメールと一致しません」、その他=汎用)。送信中 disabled。 +- 注: ログイン中はセッションに `mailHash` のみ保持しメール平文は持たないため、フォームでメールを再入力させ、サーバが `mail_hash` 一致を検証する(既存仕様)。 + +### D7. 型安全な oRPC 呼び出し + +- 既存 `$orpc`(`useNuxtApp().$orpc`、型は `AppRouter`)を使用。サーバ実装は import しない(型のみ)。 + +## Risks / Trade-offs + +- **@nuxt/ui / Tailwind v4 と既存 ESLint(stylistic) の相性** → Mitigation: 生成物を lint:fix で整え、必要なら .vue の stylistic 設定を確認。ビルド/型チェックをグリーンに保つ。 +- **Stripe キー未投入で issueInvoice が失敗** → Mitigation: UI はエラーを表示。verify-email フロー(LogMailer)と分岐は鍵なしでも確認可能。 +- **§5.1 旧フローとの差異(現役の traQ ログイン廃止)** → Mitigation: §2 準拠として proposal/design に明記。会計導線は別に提供。SSR の `auth.me` は cookie 転送済み(既存プラグイン)で機能。 + +## Migration Plan + +1. `@nuxt/ui` 追加・設定(modules/css/app.config/app.vue)。 +2. `useAuthMe`/`useCsrf` composable → `layouts/default.vue` → `index.vue` 置換 → `verify-email.vue` → `membership.vue`。 +3. `pnpm lint`/`typecheck`/`build` グリーン。ローカル起動で 分岐・verify 送信(LogMailer)・membership フォーム描画を確認。 + +## Open Questions + +- ロゴ画像は当面テキスト(後で差し替え)。 +- `membership` の区分 3 種のうち「再入部」を `new` 扱いにする点は要確認(入部費を再度払う想定)。本 change では `new` とする。 +- 会計ログイン後に利用者向け請求書を出す導線は出さない(会計セッションは issueInvoice 不可)。会計は管理 UI(後続)へ。 diff --git a/openspec/changes/add-member-ui/proposal.md b/openspec/changes/add-member-ui/proposal.md new file mode 100644 index 0000000..9f689f1 --- /dev/null +++ b/openspec/changes/add-member-ui/proposal.md @@ -0,0 +1,32 @@ +## Why + +これまでバックエンド(認証・集金・一覧・払い戻し)を仕様駆動で整備してきたが、利用者が触れる画面が無い。第一弾の価値(部費・入部費の集金)をエンドツーエンドで使えるよう、**利用者向けの UI**(トップ/isct メール確認/部費支払い)を用意する。design.md §8 のうち利用者向けページを対象とし、会計(管理)画面は後続 change とする。 + +## What Changes + +- **UI 基盤の導入**: `@nuxt/ui` を導入し、共通レイアウト(ヘッダー:サービスロゴ/ログイン時はアイコン)と基本コンポーネント(フォーム・ボタン・通知)を使えるようにする。 +- **`/`(トップ)**: ログイン状態・権限で出し分け。未ログインは「部費を払う」導線+「会計の方はこちら(traQ ログイン)」、ログイン中はアクター表示+ログアウト。`auth.me` を参照。 +- **`/verify-email`(isct メール確認)**: isct メールを入力 → `auth.requestEmailVerification` で確認メール送信 → 「確認メールを送信しました」。`redirect` クエリを保持し、確認後に元のページへ戻す。 +- **`/membership`(部費支払い)**: §5.1 の振り分けを**確定済み認証方針(§2)に合わせて整理**する。 + - 未ログイン(=isct 未確認)→ `新規入部 / 再入部 / 現役` を選ぶと `/verify-email?redirect=/membership` へ誘導。 + - ログイン済み(isct 確認済み)→ 請求書フォーム(メール再入力・氏名・区分)→ `membership.issueInvoice` → 発行された支払いページ(`hostedInvoiceUrl`)へ誘導。 + - 区分マッピング: `新規入部`・`再入部` → `feeType: 'new'`(前期/後期で自動価格)、`現役` → `feeType: 'continuation'`(標準)。特別 ¥2,000 は会計のみ(UI 非表示)。 + - **reconcile**: design §5.1 旧フローの「現役 → traQ ログイン」は、確定方針(利用者=isct マジックリンク/会計=traQ、§2)と矛盾するため、**支払い者は全員 isct 確認**に統一。traQ ログインは会計導線として別に出す。 +- 既存スカフォルドの `index.vue`(health 確認用)を上記トップに置き換える。 + +明確に**スコープ外**(後続): `/payments`・`/admins`・払い戻し管理などの会計(管理)UI、ステップバー、入部フォーム機能、決済状況のリアルタイム反映。 + +## Capabilities + +### New Capabilities +- `member-ui`: 利用者向け Web UI。共通レイアウト/ヘッダー(ログイン状態表示)、`/` の状態別出し分け、`/verify-email` の確認メール送信フロー(redirect 保持)、`/membership` の区分振り分けと請求書発行 → 支払いページ誘導。サーバ状態は既存 oRPC(`auth.me` / `auth.requestEmailVerification` / `membership.issueInvoice`)と Nitro ルート(`/verify-email/confirm` / `/login` / `/logout` / `/csrf`)越しに扱う。 + +### Modified Capabilities + + +## Impact + +- **Web(`apps/web`)**: `@nuxt/ui` 追加(`nuxt.config` modules、Tailwind/CSS、`app.config`)。`app/layouts/default.vue`(ヘッダー)、`app/pages/index.vue`(置換)、`app/pages/verify-email.vue`、`app/pages/membership.vue`。`auth.me` を使う composable(例 `useAuthMe`)。 +- **API/DB**: 変更なし(既存 oRPC/Nitro を利用)。CSRF は既存の oRPC クライアントプラグインが自動付与。 +- **依存**: `@nuxt/ui`(+ Tailwind 依存)を追加。 +- **検証**: ページのレンダリング・分岐・フォーム送信(verify-email は LogMailer で確認可能)をローカル起動で確認。`membership.issueInvoice` の実発行は Stripe test キー投入後に E2E(キー未投入では送信時エラーになる旨を UI で表示)。 diff --git a/openspec/changes/add-member-ui/specs/member-ui/spec.md b/openspec/changes/add-member-ui/specs/member-ui/spec.md new file mode 100644 index 0000000..5098cc8 --- /dev/null +++ b/openspec/changes/add-member-ui/specs/member-ui/spec.md @@ -0,0 +1,72 @@ +## ADDED Requirements + +### Requirement: 共通レイアウトとログイン状態表示 + +システムは、全利用者向けページに共通ヘッダー(サービスロゴ)を表示 SHALL。`auth.me` で現在のアクターを取得し、ログイン中はその旨(アイコン/識別表示)とログアウト導線を、未ログイン時はログイン/確認への導線を出す MUST。 + +#### Scenario: 未ログインのヘッダー + +- **WHEN** 未ログインでページを開く +- **THEN** ロゴと「部費を払う/会計の方はこちら」等の導線が表示され、ログアウトは表示されない + +#### Scenario: ログイン中のヘッダー + +- **WHEN** 利用者または会計としてログイン中にページを開く +- **THEN** ログイン状態(アイコン/識別)とログアウト導線が表示される + +### Requirement: トップページの状態別出し分け + +`/` は、ログイン状態・権限に応じて機能への導線を出し分け SHALL。未ログインは部費支払いと会計ログインへの導線、ログイン済みは現在のアクター表示と主要導線を表示する MUST。 + +#### Scenario: 未ログインのトップ + +- **WHEN** 未ログインで `/` を開く +- **THEN** 「部費を払う(/membership)」と「会計の方はこちら(traQ ログイン)」への導線が出る + +#### Scenario: ログイン済みのトップ + +- **WHEN** ログイン済みで `/` を開く +- **THEN** 現在のアクターが表示され、ログアウトできる + +### Requirement: isct メール確認フロー(redirect 保持) + +`/verify-email` は、isct メールアドレスの入力を受け、`auth.requestEmailVerification` を呼んで確認メールを送信し、送信完了を利用者に明示 SHALL。`redirect` クエリを保持し、確認後にその安全な遷移先へ戻せるようにする MUST。許可ドメイン外・エラー時はその旨を表示する。 + +#### Scenario: 確認メール送信 + +- **WHEN** 利用者が isct メールを入力して送信する +- **THEN** 確認メール送信が要求され、「確認メールを送信しました」と表示される + +#### Scenario: redirect を保持する + +- **WHEN** `/verify-email?redirect=/membership` から確認を開始する +- **THEN** 確認リンクは `redirect=/membership` を保持し、確認完了後に `/membership` へ戻る + +#### Scenario: ドメイン外の入力 + +- **WHEN** 許可ドメイン外のメールを送信する +- **THEN** エラーが表示され、確認メールは送られない + +### Requirement: 部費支払いの振り分けと発行 + +`/membership` は、ログイン状態に応じて振り分け SHALL。未ログイン(isct 未確認)では `新規入部 / 再入部 / 現役` の選択を提示し、いずれも `/verify-email?redirect=/membership` へ誘導する MUST。ログイン済み(isct 確認済み)では請求書フォーム(メール・氏名・区分)から `membership.issueInvoice` を呼び、発行された支払いページ(`hostedInvoiceUrl`)へ誘導する MUST。区分は `新規入部`・`再入部` を `new`、`現役` を `continuation` に対応づける。特別 ¥2,000 は会計のみで、利用者 UI には出さない SHALL。 + +#### Scenario: 未ログインは確認へ誘導 + +- **WHEN** 未ログインで `/membership` を開き区分を選ぶ +- **THEN** `/verify-email?redirect=/membership` へ誘導される + +#### Scenario: ログイン済みは請求書を発行できる + +- **WHEN** ログイン済み利用者がメール・氏名・区分を入力して発行する +- **THEN** `membership.issueInvoice` が呼ばれ、成功時は支払いページ(`hostedInvoiceUrl`)への導線が表示される + +#### Scenario: 区分が価格種別に対応する + +- **WHEN** 区分として `現役` を選ぶ +- **THEN** `feeType: 'continuation'` で発行される(`新規入部`・`再入部` は `feeType: 'new'`) + +#### Scenario: 発行失敗の表示 + +- **WHEN** 発行要求がエラーになる(例: 設定不足や認可エラー) +- **THEN** 利用者にエラーが表示され、二重送信を促さない diff --git a/openspec/changes/add-member-ui/tasks.md b/openspec/changes/add-member-ui/tasks.md new file mode 100644 index 0000000..ceb62b3 --- /dev/null +++ b/openspec/changes/add-member-ui/tasks.md @@ -0,0 +1,23 @@ +## 1. @nuxt/ui 導入 + +- [ ] 1.1 `apps/web` に `@nuxt/ui`(Nuxt 4 対応版)を追加(`pnpm install`) +- [ ] 1.2 `nuxt.config` の `modules` に `'@nuxt/ui'` を追加。`app/assets/css/main.css`(`@import "tailwindcss"; @import "@nuxt/ui";`)+ `css` 登録、`app/app.config.ts`(テーマ) +- [ ] 1.3 `app/app.vue` を `` + `` に + +## 2. 共通基盤(composable・レイアウト) + +- [ ] 2.1 `useAuthMe()`(`useAsyncData('auth-me', () => $orpc.auth.me())`、`refresh` 公開) +- [ ] 2.2 `useCsrf()`(`__Host-checkin_csrf` cookie 読取、無ければ `GET /csrf`)+ ログアウト処理(`x-csrf-token` 付き `POST /logout` → refresh) +- [ ] 2.3 `app/layouts/default.vue`: ヘッダー(ロゴ・ログイン状態・ログアウト/会計ログイン導線) + +## 3. ページ + +- [ ] 3.1 `app/pages/index.vue`(既存 health 版を置換): 状態別出し分け +- [ ] 3.2 `app/pages/verify-email.vue`: メール入力 → `auth.requestEmailVerification`(redirect 保持)→ 成功/失敗表示、送信中 disabled +- [ ] 3.3 `app/pages/membership.vue`: 未ログインは 新規/再入部/現役 → `/verify-email?redirect=/membership?type=...`;ログイン済みはフォーム(email/name/区分)→ `membership.issueInvoice` → `hostedInvoiceUrl` 誘導。区分→feeType 変換、エラー/二重送信対策 + +## 4. 検証 + +- [ ] 4.1 `pnpm lint` / `pnpm typecheck` / `pnpm build` をグリーンにする +- [ ] 4.2 ローカル起動で確認: 未ログイン `/membership` の分岐、`/verify-email` 送信(LogMailer のリンク)、確認リンク→ログイン→`/membership` フォーム描画、ヘッダーのログイン状態・ログアウト +- [ ] 4.3 (Stripe test キー投入後)`/membership` フォームから実発行→支払いページの E2E diff --git a/openspec/changes/archive/2026-06-20-add-traq-member-auth/.openspec.yaml b/openspec/changes/archive/2026-06-20-add-traq-member-auth/.openspec.yaml new file mode 100644 index 0000000..ff1fbc8 --- /dev/null +++ b/openspec/changes/archive/2026-06-20-add-traq-member-auth/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-19 diff --git a/openspec/changes/archive/2026-06-20-add-traq-member-auth/design.md b/openspec/changes/archive/2026-06-20-add-traq-member-auth/design.md new file mode 100644 index 0000000..7ff8c65 --- /dev/null +++ b/openspec/changes/archive/2026-06-20-add-traq-member-auth/design.md @@ -0,0 +1,77 @@ +## Context + +Jomon の払い戻しは受取人を traQ ID で渡す(実 API 調査で確定: v1 `trap_id`、v2 は User UUID→`name`)。Checkin の `users` は `mail_hash` のみで traQ ID を持たず、payout を本人解決できない。ユーザー決定により、traQ ログインを会員セッションにも使い、現役が traQ 認証済みで支払った時に `users.traq_id` を連結する。本 change はそのための認証基盤改修([[add-auth-foundation]] / [[add-membership-collection]] への MODIFIED)。後続で Jomon アダプタ修正(fix-jomon-payout)と UI(add-member-ui)を行う。 + +## Goals / Non-Goals + +**Goals:** +- セッションを traqId(traQ 認証)+ user(userId/mailHash) のデュアル・アイデンティティ+`isAdmin` に拡張。 +- traQ ログイン=会員セッション、会計は許可リスト・サブセット。 +- `users.traq_id`(unique) 追加と、認証済み traQ ID の連結(traQ ログイン解決/支払い時保存)。 +- `requireMember`/`requireUser`/`requireAdmin` と対応する手続きビルダ。 + +**Non-Goals:** +- Jomon アダプタの実 API 修正・payout の payee 解決(後続 fix-jomon-payout)。 +- UI(後続 add-member-ui)。許可リストの traQ グループ移行。 + +## Decisions + +### D1. セッションのデータモデル + +- `sessions` テーブルは現状 `actor_type('user'|'admin')` + `user_id` + `traq_id`。これを**デュアル**に拡張: 1 セッションが `user_id`(nullable)と `traq_id`(nullable)と `is_admin`(bool, default false)を持てるよう変更。`actor_type` enum は廃し、保持アイデンティティで表現する(`user_id` 有=利用者、`traq_id` 有=会員、`is_admin` 真=会計)。 +- マイグレーション: `sessions` に `is_admin boolean not null default false` を追加し、`actor_type` を撤廃(drizzle で列削除)。既存セッションは無効化されてよい(再ログインで再確立、運用影響小)。 + +### D2. SessionActor 型(`packages/api/src/auth/session.ts`) + +``` +interface SessionIdentity { + traqId: string | null // traQ 認証済みなら + isAdmin: boolean // 許可リスト判定 + userId: string | null // 連結/確認済みの利用者 + mailHash: string | null +} +``` +`resolveSession` はこの形を返す(null=未ログイン)。`createSession`/`updateSession` で各フィールドを設定。`requireMember`=traqId 必須、`requireUser`=userId 必須、`requireAdmin`=isAdmin 必須(`packages/api/src/auth/context.ts`、oRPC `memberProc`/`userProc`/`adminProc`)。 + +### D3. ログイン経路の更新 + +- **traQ コールバック(`apps/web/server/routes/login/callback.get.ts`)**: code 交換→traq_id 取得→`isAdmin = allowlist.includes(traq_id)`→`getUserByTraqId(traq_id)` で連結済み本人を解決(あれば userId/mailHash も)→**会員セッション確立**(許可リスト外でも確立)。 +- **isct 確認(`apps/web/server/routes/verify-email/confirm.get.ts`)**: トークン消費→`getOrCreateUserByMailHash`→ **既存セッションに traqId があれば**、その user 行へ `linkTraqId`(競合安全、unique)し、同一セッションに user を載せる(既存セッション更新 or 新規に traqId 引継ぎ)。traqId 無しなら従来どおり利用者セッション。 + +### D4. identity の連結ヘルパ(`packages/api/src/auth/identity.ts`) + +- `getUserByTraqId(db, traqId)`:`users.traq_id` で本人行を引く。 +- `linkTraqId(db, userId, traqId): Promise<'linked'|'exists'|'conflict'>`:競合安全な compale-and-set(`UPDATE users SET traq_id=? WHERE id=? AND traq_id IS NULL`)。unique 制約違反(他人が同 traq_id)は `conflict`。既に同値なら `exists`。 +- `BillingUserRow`/`getUserById` に `traqId` を含める。 + +### D5. 支払い時連結(`packages/api/src/router.ts` membership.issueInvoice) + +- 既存の本人確認(提出メール mail_hash==session.mailHash)の後、`session.traqId` があれば `linkTraqId(db, user.userId, session.traqId)`(結果は best-effort。`conflict` はログ)。Customer 作成/更新の metadata に traQ ID を含める(既存 metadata.mail_hash に併記)。 + +### D6. データモデル(`packages/db`) + +- `users.traq_id varchar(255)` nullable **unique** を追加。 +- `sessions`: `is_admin boolean` 追加、`actor_type` 撤廃(D1)。 +- `pnpm db:generate`(`0005` 以降。既存 baseline 保持)。 + +## Risks / Trade-offs + +- **既存 SessionActor を使う箇所の破壊的変更** → Mitigation: `auth.me`・`requireUser/Admin`・Nitro context・全 oRPC 手続きを新型へ移行。型で漏れを検出(typecheck)。 +- **sessions スキーマ変更で既存セッション無効化** → Mitigation: 開発初期で実害なし。再ログインで回復。 +- **traq_id の信頼**: 認証済みセッション由来のみ連結 → フォーム入力は使わない(§4.1 準拠)。 +- **連結の競合**(同一人物の二重行/他人の traq_id)→ Mitigation: unique 制約+compare-and-set、`conflict` は要対応ログ。 +- **会計が自分の請求を出すか**: 会計セッションでも user 未連結なら requireUser 不可。会計は管理操作(adminProc)を使う。問題なし。 + +## Migration Plan + +1. `packages/db` スキーマ(users.traq_id, sessions.is_admin, actor_type 撤廃)→ generate → migrate。 +2. session/context 型をデュアルへ拡張、`requireMember`/`memberProc` 追加、全手続き・Nitro context を移行。 +3. ログイン経路(traQ callback=会員セッション+連結解決、verify-email confirm=traqId 連結)更新。 +4. issueInvoice で連結保存+Customer metadata。`auth.me` 返却更新。 +5. ユニット(linkTraqId/getUserByTraqId/require*)+ローカルで isct ログイン・auth.me・issueInvoice(Stripe 無しはエラー表示)確認。 +6. ロールバック: 本 change のマイグレーション revert + 型を旧 SessionActor へ戻す。 + +## Open Questions + +- `sessions.actor_type` 撤廃ではなく残置でもよいが、デュアル化に伴い意味が薄れるため撤廃(D1)。要承認なら残置も可。 +- traQ コールバックで user 連結解決した際、`auth.me` がどこまで返すか(member/admin/has-user)。実装で `{ member: bool, admin: bool, hasUser: bool }` 程度に。 diff --git a/openspec/changes/archive/2026-06-20-add-traq-member-auth/proposal.md b/openspec/changes/archive/2026-06-20-add-traq-member-auth/proposal.md new file mode 100644 index 0000000..860967d --- /dev/null +++ b/openspec/changes/archive/2026-06-20-add-traq-member-auth/proposal.md @@ -0,0 +1,36 @@ +## Why + +Jomon の払い戻しは受取人を **traQ ID** で渡してくる(v1 `trap_id` / v2 は User UUID→`name`、実 API 調査で確定)。しかし Checkin の `users` は `mail_hash`(isct メール)だけで識別し traQ ID を持たないため、payout を本人に解決できない。 + +解決策(ユーザー決定): **traQ ログインを「現役会員セッション」にも使い**、現役が **traQ 認証済みで支払った時に `users.traq_id` を保存**して、isct アイデンティティ(mail_hash)と traQ アイデンティティを 1 本に紐付ける。以後 payout は `traQ ID → users.traq_id → connected account` で解決できる。これは旧 §2「traQ=会計のみ」を更新し、design §5.1 の「現役→traQ ログイン」フローを正とする。認証済み traQ ID のみ信頼する(フォーム入力の traQ ID は §4.1 通り非信頼)。 + +## What Changes + +- **traQ ログイン=会員セッション**: `GET /login`(traQ OAuth)成立で**会員**セッションを確立する。会計(管理者)は env 許可リストに含まれる traQ ID の**サブセット**(`isAdmin`)。許可リスト外でもログインは成立し、会員として扱う。 + - **BREAKING(admin-authorization 更新)**: 旧「許可リスト外はセッション確立せず」を改め、会員セッションは確立しつつ会計権限のみ許可リストで判定。 +- **デュアル・アイデンティティのセッション**: セッションは **traqId(traQ 認証)** と **user(mail_hash 由来の userId)** の双方を保持できる。`requireUser` は請求可能な user(userId)を要求、`requireAdmin` は `isAdmin` を要求。 + - traQ ログイン時、`users.traq_id` が一致する行があれば user も解決してセッションに載せる(紐付け済み現役は再確認不要)。 + - isct メール確認時、既存セッションに traqId があれば、その user 行に traqId を**連結**し、同一セッションに user を載せる。 +- **本人キーの拡張**: `users` に `traq_id`(nullable, unique)を追加。**BREAKING(identity 更新)**: 非 PII 参照として traq_id を保持してよい(認証済み traQ ID のみ)。 +- **支払い時の連結(membership-billing 更新)**: `issueInvoice` がセッションに traqId と user を併せ持つとき、`users.traq_id` を保存し、Customer の metadata にも traQ ID を入れる(参照キーは引き続き mail_hash/customer_id)。 + +明確に**スコープ外**(後続): Jomon アダプタの実 API 修正(`fix-jomon-payout`)、UI(`add-member-ui`)、許可リストの traQ グループ移行。 + +## Capabilities + +### New Capabilities + + +### Modified Capabilities +- `admin-authorization`: traQ OAuth は**会員セッション**を確立し、会計はその許可リスト・サブセット(`isAdmin`)。許可リスト外もログイン成立(会員)。 +- `session`: セッションは traqId と user(userId/mailHash)の双方+`isAdmin` を保持可能。`requireUser`=user 必須、`requireAdmin`=isAdmin 必須。`requireMember`(traQ 認証)を追加。 +- `identity`: `users.traq_id`(nullable, unique)を追加。認証済み traQ ID の連結ルール(一致で解決、支払い時に保存)。 +- `membership-billing`: traQ 認証済みで支払うと `users.traq_id` を保存(Customer metadata にも traQ ID)。請求の認可は user(mail_hash 一致)に加え、紐付け済み traQ 会員も自分の請求を出せる。 + +## Impact + +- **DB(`packages/db`)**: `users.traq_id varchar(255)` nullable **unique** 追加。マイグレーション生成・適用。 +- **API(`packages/api`)**: `SessionActor`/session 作成・復元を**デュアル・アイデンティティ**へ拡張(traqId/userId/mailHash/isAdmin)。`requireUser`/`requireAdmin`/`requireMember` と `userProc`/`adminProc`/`memberProc`。`identity` に traq_id 連結ヘルパ(`getUserByTraqId`、`linkTraqId` 競合安全)。`membership.issueInvoice` で連結保存。 +- **Web/Nitro(`apps/web`)**: `GET /login/callback` を会員セッション確立へ(許可リストは isAdmin 判定に降格)。`GET /verify-email/confirm` を「既存 traQ セッションに user を連結」に対応。`auth.me` 返却に会員/会計の区別と user 有無を含める。 +- **既存 change との関係**: auth-foundation/membership-collection の挙動を MODIFIED で更新。payout-execution の payee 解決を traq_id 経由にするのは後続 `fix-jomon-payout`。 +- **テスト/検証**: セッションのデュアル解決・連結・許可リスト降格・issueInvoice 連結保存をユニット+ローカル E2E(traQ はテスト困難なため連結ロジックは関数単位で検証)。 diff --git a/openspec/changes/archive/2026-06-20-add-traq-member-auth/specs/admin-authorization/spec.md b/openspec/changes/archive/2026-06-20-add-traq-member-auth/specs/admin-authorization/spec.md new file mode 100644 index 0000000..52ce95c --- /dev/null +++ b/openspec/changes/archive/2026-06-20-add-traq-member-auth/specs/admin-authorization/spec.md @@ -0,0 +1,29 @@ +## MODIFIED Requirements + +### Requirement: traQ OAuth による会計ログイン + +利用者(現役会員)・会計はともに traQ OAuth の authorization code フロー(PKCE 併用)でログイン SHALL。`GET /login` は state と PKCE code_verifier を生成・保存して traQ の認可エンドポイントへリダイレクトし、コールバックで `state` を検証し、code を access token に交換して traQ のユーザー情報(traq_id)を取得 MUST。ログイン成立で**会員セッション**を確立する(traQ 認証済み)。`state` 不一致や code 交換失敗時はセッションを確立せずエラーとする SHALL。 + +#### Scenario: 正常な OAuth ログインで会員セッション + +- **WHEN** traQ で認可してコールバックに戻る +- **THEN** `state` が検証され、code が token に交換され、traq_id を持つ会員セッションが確立される + +#### Scenario: state 不一致は拒否 + +- **WHEN** コールバックの `state` が保存値と一致しない +- **THEN** セッションは確立されず、エラーとして扱われる + +### Requirement: env 許可リストによる会計判定 + +システムは、traQ ログインで得た traq_id が環境変数の会計 traQ ID 許可リストに含まれる場合に**会計権限(`isAdmin`)**を付与 SHALL。許可リスト外の traq_id には会計権限を付与しては SHALL NOT が、ログイン自体は成立し**会員セッション**として扱う MUST。会計の管理は DB の管理者テーブルではなく、この許可リスト(将来 traQ グループ連携へ拡張可能)で行う。 + +#### Scenario: 許可リスト内は会計になる + +- **WHEN** 許可リストに含まれる traq_id でログインが完了する +- **THEN** 会員セッションに会計権限(`isAdmin`)が付与される + +#### Scenario: 許可リスト外は会員のまま + +- **WHEN** 許可リストに含まれない traq_id でログインが完了する +- **THEN** ログインは成立し会員セッションになるが、会計権限は付与されない diff --git a/openspec/changes/archive/2026-06-20-add-traq-member-auth/specs/identity/spec.md b/openspec/changes/archive/2026-06-20-add-traq-member-auth/specs/identity/spec.md new file mode 100644 index 0000000..f293e5e --- /dev/null +++ b/openspec/changes/archive/2026-06-20-add-traq-member-auth/specs/identity/spec.md @@ -0,0 +1,25 @@ +## ADDED Requirements + +### Requirement: traQ ID による本人解決と連結 + +`mail_hash` を本人の主キーとしつつ、システムは**認証済みの traQ ID** を `users.traq_id`(nullable, unique)として本人行に連結 SHALL。`traq_id` は解決の副キーであり、Jomon の払い戻し(traQ ID で受取人を渡す)を本人行へ解決するために用いる。連結に使う traQ ID は**traQ OAuth で認証済みのセッション由来**のものに限る MUST(フォーム入力の traQ ID は §4.1 通り信頼せず連結に使っては SHALL NOT)。`traq_id` は一意であり、二人に同じ traQ ID を連結しては SHALL NOT。 + +#### Scenario: 連結済み traq_id で本人解決 + +- **WHEN** `users.traq_id` に連結済みの traq_id で解決を要求する +- **THEN** 対応する本人行が返る + +#### Scenario: 未連結は解決できない + +- **WHEN** どの本人行にも連結されていない traq_id で解決を要求する +- **THEN** 本人は解決されない(払い戻しでは要対応扱い) + +#### Scenario: フォーム入力の traQ ID は連結に使わない + +- **WHEN** 請求フォーム等で traQ ID が入力される(未認証) +- **THEN** その traQ ID は `users.traq_id` の連結・参照に使われない + +#### Scenario: traq_id は一意 + +- **WHEN** 既に他の本人行へ連結済みの traq_id を別の本人行へ連結しようとする +- **THEN** 連結は拒否される(unique 制約) diff --git a/openspec/changes/archive/2026-06-20-add-traq-member-auth/specs/membership-billing/spec.md b/openspec/changes/archive/2026-06-20-add-traq-member-auth/specs/membership-billing/spec.md new file mode 100644 index 0000000..526f701 --- /dev/null +++ b/openspec/changes/archive/2026-06-20-add-traq-member-auth/specs/membership-billing/spec.md @@ -0,0 +1,20 @@ +## ADDED Requirements + +### Requirement: 支払い時の traQ ID 連結 + +請求書発行時、セッションが **traQ 認証(traqId)と利用者(userId)の双方**を持つ場合、システムは当該本人行の `users.traq_id` を保存(未設定時)し、Stripe Customer の `metadata` にも traQ ID を入れる SHALL。参照キーは引き続き `mail_hash` / `stripe_customer_id` であり、traQ ID は連結・ログ用に留める。`users.traq_id` が既に設定済みなら上書きしては SHALL NOT。traQ 未認証(traqId 無し)の発行では連結しない。 + +#### Scenario: traQ 認証済みの支払いで連結される + +- **WHEN** traQ 認証済みかつ user 解決済みのセッションで標準請求書を発行する +- **THEN** その本人行の `users.traq_id` が(未設定なら)保存され、Customer metadata にも traQ ID が入る + +#### Scenario: 既に連結済みなら維持 + +- **WHEN** 既に `users.traq_id` を持つ本人が再度発行する +- **THEN** 既存の `traq_id` は上書きされない + +#### Scenario: traQ 未認証では連結しない + +- **WHEN** isct のみ(traQ 未認証)のセッションで発行する +- **THEN** `users.traq_id` は設定されない(発行自体は従来どおり可能) diff --git a/openspec/changes/archive/2026-06-20-add-traq-member-auth/specs/session/spec.md b/openspec/changes/archive/2026-06-20-add-traq-member-auth/specs/session/spec.md new file mode 100644 index 0000000..26b70f3 --- /dev/null +++ b/openspec/changes/archive/2026-06-20-add-traq-member-auth/specs/session/spec.md @@ -0,0 +1,54 @@ +## MODIFIED Requirements + +### Requirement: セッションは利用者・会計のいずれかのアクターを表す + +セッション実体は、**traQ 認証(traqId)** と **利用者(`mail_hash` 由来の userId/mailHash)** の双方のアイデンティティを保持でき、会計権限フラグ(`isAdmin`)を持つ SHALL。1 つのセッションが両アイデンティティを併せ持てる(traQ ログイン済みの現役が isct 確認も済ませた場合)。アイデンティティを取り違えて権限を付与しては SHALL NOT。 + +#### Scenario: 会員(traQ)セッション + +- **WHEN** traQ OAuth でログインする +- **THEN** セッションは traqId を持つ会員として確立される(user 未連結なら userId は持たない) + +#### Scenario: 利用者(isct)セッション + +- **WHEN** isct メール確認でログインする +- **THEN** セッションは userId/mailHash を持つ利用者として確立される + +#### Scenario: 連結済みは両アイデンティティを持つ + +- **WHEN** traQ 会員が isct 確認も済ませる、または `users.traq_id` が連結済みの traq_id でログインする +- **THEN** セッションは traqId と userId/mailHash の双方を持つ + +#### Scenario: 会計はフラグで表す + +- **WHEN** 許可リストに含まれる traq_id でログインする +- **THEN** セッションは `isAdmin` を持つ + +### Requirement: 認可ヘルパ requireUser / requireAdmin + +システムは、API のリクエスト Context にセッションを復元し、`requireMember` / `requireUser` / `requireAdmin` の認可ヘルパを提供 SHALL。`requireMember` は traQ 認証(traqId)が無ければ拒否する。`requireUser` は請求可能な利用者アイデンティティ(userId)が無ければ拒否し、操作対象を当該利用者「自分のもの」に限定する手段を与える MUST。`requireAdmin` は会計権限(`isAdmin`)が無ければ拒否する MUST。 + +#### Scenario: 未ログインで requireUser + +- **WHEN** セッションの無いリクエストが `requireUser` を要する操作を呼ぶ +- **THEN** 認証エラーで拒否される + +#### Scenario: user 未連結の会員が requireUser + +- **WHEN** traQ 会員だが user 未連結(userId 無し)のセッションで `requireUser` を要する操作を呼ぶ +- **THEN** 認証エラーで拒否される(isct 確認/連結が必要) + +#### Scenario: 会員が requireMember + +- **WHEN** traQ 認証済みセッションで `requireMember` を要する操作を呼ぶ +- **THEN** 操作が許可される + +#### Scenario: 会員(非会計)が requireAdmin + +- **WHEN** `isAdmin` を持たないセッションで `requireAdmin` を要する操作を呼ぶ +- **THEN** 認可エラーで拒否される + +#### Scenario: 会計が requireAdmin + +- **WHEN** `isAdmin` を持つセッションで `requireAdmin` を要する操作を呼ぶ +- **THEN** 操作が許可される diff --git a/openspec/changes/archive/2026-06-20-add-traq-member-auth/tasks.md b/openspec/changes/archive/2026-06-20-add-traq-member-auth/tasks.md new file mode 100644 index 0000000..eba605f --- /dev/null +++ b/openspec/changes/archive/2026-06-20-add-traq-member-auth/tasks.md @@ -0,0 +1,36 @@ +## 1. データモデル + +- [x] 1.1 `users` に `traq_id varchar(255)` nullable **unique** を追加 +- [x] 1.2 `sessions` に `is_admin boolean not null default false` を追加し、`actor_type` enum を撤廃(保持アイデンティティで表現) +- [x] 1.3 `pnpm db:generate`(baseline 0000–0005 保持)→ `pnpm db:migrate` + +## 2. セッション/認可のデュアル化(packages/api) + +- [x] 2.1 `SessionIdentity`(traqId/isAdmin/userId/mailHash)へ型変更。`createSession`/`resolveSession`/`destroySession` を更新(user_id・traq_id・is_admin を保存/復元) +- [x] 2.2 既存セッションに後からアイデンティティを足す `attachUserToSession`(traQ セッションに user を連結)を実装 +- [x] 2.3 `context.ts`: `requireMember`/`requireUser`/`requireAdmin` を新型に。`orpc.ts` に `memberProc`/`userProc`/`adminProc`(middleware は入力検証より前) +- [x] 2.4 Nitro `buildRequestContext` と `auth.me`(`{member, admin, hasUser, traqId?}` 等)を新型へ移行。既存の全 oRPC 手続き(payments/payouts/membership)が型エラー無く動くよう調整 + +## 3. identity の traq_id 連結(packages/api/src/auth/identity.ts) + +- [x] 3.1 `users.traq_id` を `BillingUserRow`/`getUserById` に含める +- [x] 3.2 `getUserByTraqId(db, traqId)` +- [x] 3.3 `linkTraqId(db, userId, traqId)`:競合安全な compare-and-set(`WHERE traq_id IS NULL`)。unique 違反は `conflict`、同値は `exists` + +## 4. ログイン経路の更新(apps/web) + +- [x] 4.1 `login/callback.get.ts`: 会員セッション確立(許可リスト外も成立)、`isAdmin=allowlist`、`getUserByTraqId` で連結済み user を解決して載せる +- [x] 4.2 `verify-email/confirm.get.ts`: 既存セッションに traqId があれば `linkTraqId` + user を同一セッションに連結(無ければ従来の利用者セッション) + +## 5. 支払い時連結(membership) + +- [x] 5.1 `membership.issueInvoice`: 本人確認後、`session.traqId` があれば `linkTraqId`(best-effort、conflict はログ)。Customer 作成/更新の metadata に traQ ID を併記 + +## 6. 検証 + +- [x] 6.1 ユニットテスト: `linkTraqId`(linked/exists/conflict)、`getUserByTraqId`、`requireMember/User/Admin`、セッションのデュアル解決 +- [x] 6.2 `pnpm lint` / `pnpm typecheck` / `pnpm build` / `pnpm test` をグリーンに +- [x] 6.3 ローカル: isct ログイン→`auth.me`、issueInvoice(Stripe 無しはエラー表示)、許可リスト判定の単体確認。traQ 実ログインはキー要のため関数単位で担保 + - 連結ロジック・デュアル解決・許可リスト判定(`isAccountant`)・`auth.me` 形状は DB バック/ユニットテストで担保(`identity.test.ts` 9件 + `auth.test.ts`)。 + - BLOCKED(実 E2E): traQ OAuth 実ログイン・Stripe 実 issueInvoice はクレデンシャル要のため未実施(コールバック/confirm ルートはビルド通過で静的検証済み)。 + - Codex review fixes: confirm.get.ts は link→(linked/exists のみ)attach 順に修正し conflict 時は別人の traQ 連結を主張しない fresh user session を発行。issueInvoice は conflict の session.traqId を Customer metadata に焼かない(linkedTraqId ?? row.traqId)。`linkTraqId` conflict が他ユーザーの連結を破壊しない点は identity.test.ts で担保。 diff --git a/openspec/specs/admin-authorization/spec.md b/openspec/specs/admin-authorization/spec.md index 273aa7d..910a68d 100644 --- a/openspec/specs/admin-authorization/spec.md +++ b/openspec/specs/admin-authorization/spec.md @@ -2,18 +2,18 @@ ## Purpose -会計(管理者)を traQ OAuth で認証し、環境変数の traQ ID 許可リストで認可して管理者セッションを確立する。会計管理は DB の管理者テーブルではなく許可リスト(将来 traQ グループ連携へ拡張可能)で行う。 +traQ OAuth でログインを認証し、**会員セッション**を確立する。会計(管理者)はその traQ ID 許可リスト・サブセット(`isAdmin`)。会計管理は DB の管理者テーブルではなく許可リスト(将来 traQ グループ連携へ拡張可能)で行う。 ## Requirements ### Requirement: traQ OAuth による会計ログイン -会計(管理者)は traQ OAuth の authorization code フロー(PKCE 併用)でログイン SHALL。`GET /login` は state と PKCE code_verifier を生成・保存して traQ の認可エンドポイントへリダイレクトし、コールバックで `state` を検証し、code を access token に交換して traQ のユーザー情報(traq_id)を取得 MUST。`state` 不一致や code 交換失敗時はセッションを確立せずエラーとする SHALL。 +利用者(現役会員)・会計はともに traQ OAuth の authorization code フロー(PKCE 併用)でログイン SHALL。`GET /login` は state と PKCE code_verifier を生成・保存して traQ の認可エンドポイントへリダイレクトし、コールバックで `state` を検証し、code を access token に交換して traQ のユーザー情報(traq_id)を取得 MUST。ログイン成立で**会員セッション**を確立する(traQ 認証済み)。`state` 不一致や code 交換失敗時はセッションを確立せずエラーとする SHALL。 -#### Scenario: 正常な OAuth ログイン +#### Scenario: 正常な OAuth ログインで会員セッション -- **WHEN** 会計が `GET /login` を開始し、traQ で認可してコールバックに戻る -- **THEN** `state` が検証され、code が token に交換され、traq_id が取得される +- **WHEN** traQ で認可してコールバックに戻る +- **THEN** `state` が検証され、code が token に交換され、traq_id を持つ会員セッションが確立される #### Scenario: state 不一致は拒否 @@ -22,17 +22,17 @@ ### Requirement: env 許可リストによる会計判定 -システムは、OAuth で得た traq_id が環境変数の会計 traQ ID 許可リストに含まれる場合のみ、管理者(会計)セッションを確立 SHALL。許可リスト外の traq_id には管理者権限を付与しては SHALL NOT。会計の管理は DB の管理者テーブルではなく、この許可リスト(将来 traQ グループ連携へ拡張可能)で行う MUST。 +システムは、traQ ログインで得た traq_id が環境変数の会計 traQ ID 許可リストに含まれる場合に**会計権限(`isAdmin`)**を付与 SHALL。許可リスト外の traq_id には会計権限を付与しては SHALL NOT が、ログイン自体は成立し**会員セッション**として扱う MUST。会計の管理は DB の管理者テーブルではなく、この許可リスト(将来 traQ グループ連携へ拡張可能)で行う。 #### Scenario: 許可リスト内は会計になる - **WHEN** 許可リストに含まれる traq_id でログインが完了する -- **THEN** 管理者(会計)セッションが確立される +- **THEN** 会員セッションに会計権限(`isAdmin`)が付与される -#### Scenario: 許可リスト外は会計にならない +#### Scenario: 許可リスト外は会員のまま - **WHEN** 許可リストに含まれない traq_id でログインが完了する -- **THEN** 管理者セッションは確立されず、会計権限は付与されない +- **THEN** ログインは成立し会員セッションになるが、会計権限は付与されない ### Requirement: ログインの redirect 保持 diff --git a/openspec/specs/identity/spec.md b/openspec/specs/identity/spec.md index fa67b9b..c0bd1ce 100644 --- a/openspec/specs/identity/spec.md +++ b/openspec/specs/identity/spec.md @@ -66,3 +66,27 @@ - **WHEN** 既に行のある `mail_hash` で get-or-create を呼ぶ - **THEN** 新しい行は作られず、既存の行が返る + +### Requirement: traQ ID による本人解決と連結 + +`mail_hash` を本人の主キーとしつつ、システムは**認証済みの traQ ID** を `users.traq_id`(nullable, unique)として本人行に連結 SHALL。`traq_id` は解決の副キーであり、Jomon の払い戻し(traQ ID で受取人を渡す)を本人行へ解決するために用いる。連結に使う traQ ID は**traQ OAuth で認証済みのセッション由来**のものに限る MUST(フォーム入力の traQ ID は §4.1 通り信頼せず連結に使っては SHALL NOT)。`traq_id` は一意であり、二人に同じ traQ ID を連結しては SHALL NOT。 + +#### Scenario: 連結済み traq_id で本人解決 + +- **WHEN** `users.traq_id` に連結済みの traq_id で解決を要求する +- **THEN** 対応する本人行が返る + +#### Scenario: 未連結は解決できない + +- **WHEN** どの本人行にも連結されていない traq_id で解決を要求する +- **THEN** 本人は解決されない(払い戻しでは要対応扱い) + +#### Scenario: フォーム入力の traQ ID は連結に使わない + +- **WHEN** 請求フォーム等で traQ ID が入力される(未認証) +- **THEN** その traQ ID は `users.traq_id` の連結・参照に使われない + +#### Scenario: traq_id は一意 + +- **WHEN** 既に他の本人行へ連結済みの traq_id を別の本人行へ連結しようとする +- **THEN** 連結は拒否される(unique 制約) diff --git a/openspec/specs/membership-billing/spec.md b/openspec/specs/membership-billing/spec.md index eb9fd04..389ee5c 100644 --- a/openspec/specs/membership-billing/spec.md +++ b/openspec/specs/membership-billing/spec.md @@ -93,3 +93,27 @@ - **WHEN** 同一条件の発行要求が短時間に複数回届く - **THEN** 冪等キーにより Stripe 側で重複が抑止され、同一の Invoice が返る + +### Requirement: 支払い時の traQ ID 連結 + +請求書発行時、セッションが **traQ 認証(traqId)と利用者(userId)の双方**を持つ場合、システムは当該本人行の `users.traq_id` を保存(未設定時)し、Stripe Customer の `metadata` にも traQ ID を入れる SHALL。参照キーは引き続き `mail_hash` / `stripe_customer_id` であり、traQ ID は連結・ログ用に留める。`users.traq_id` が既に設定済みなら上書きしては SHALL NOT。traQ 未認証(traqId 無し)の発行では連結しない。連結が競合(その traq_id が他の本人に属する)した場合は、その traq_id を当該 Customer の metadata に書いては SHALL NOT。 + +#### Scenario: traQ 認証済みの支払いで連結される + +- **WHEN** traQ 認証済みかつ user 解決済みのセッションで標準請求書を発行する +- **THEN** その本人行の `users.traq_id` が(未設定なら)保存され、Customer metadata にも traQ ID が入る + +#### Scenario: 既に連結済みなら維持 + +- **WHEN** 既に `users.traq_id` を持つ本人が再度発行する +- **THEN** 既存の `traq_id` は上書きされない + +#### Scenario: traQ 未認証では連結しない + +- **WHEN** isct のみ(traQ 未認証)のセッションで発行する +- **THEN** `users.traq_id` は設定されない(発行自体は従来どおり可能) + +#### Scenario: 競合 traq_id は metadata に書かない + +- **WHEN** 連結が競合(その traq_id が他の本人に属する)する +- **THEN** その traq_id は Customer metadata に書かれず、発行自体は継続する diff --git a/openspec/specs/session/spec.md b/openspec/specs/session/spec.md index 5055287..ce1d5e5 100644 --- a/openspec/specs/session/spec.md +++ b/openspec/specs/session/spec.md @@ -22,17 +22,27 @@ ### Requirement: セッションは利用者・会計のいずれかのアクターを表す -セッション実体は、確立元に応じて「利用者(`mail_hash` で識別)」または「会計(traq_id で識別)」のいずれのアクターであるかを保持 SHALL。両者を取り違えて権限を付与しては SHALL NOT。 +セッション実体は、**traQ 認証(traqId)** と **利用者(`mail_hash` 由来の userId/mailHash)** の双方のアイデンティティを保持でき、会計権限フラグ(`isAdmin`)を持つ SHALL。1 つのセッションが両アイデンティティを併せ持てる(traQ ログイン済みの現役が isct 確認も済ませた場合)。アイデンティティを取り違えて権限を付与しては SHALL NOT。 -#### Scenario: 利用者セッション +#### Scenario: 会員(traQ)セッション -- **WHEN** isct メール確認によりセッションが確立される -- **THEN** セッションは利用者アクターとして `mail_hash` に紐づく +- **WHEN** traQ OAuth でログインする +- **THEN** セッションは traqId を持つ会員として確立される(user 未連結なら userId は持たない) -#### Scenario: 会計セッション +#### Scenario: 利用者(isct)セッション -- **WHEN** traQ OAuth + 許可リストによりセッションが確立される -- **THEN** セッションは会計アクターとして traq_id に紐づく +- **WHEN** isct メール確認でログインする +- **THEN** セッションは userId/mailHash を持つ利用者として確立される + +#### Scenario: 連結済みは両アイデンティティを持つ + +- **WHEN** traQ 会員が isct 確認も済ませる、または `users.traq_id` が連結済みの traq_id でログインする +- **THEN** セッションは traqId と userId/mailHash の双方を持つ + +#### Scenario: 会計はフラグで表す + +- **WHEN** 許可リストに含まれる traq_id でログインする +- **THEN** セッションは `isAdmin` を持つ ### Requirement: CSRF cookie の発行(double-submit) @@ -59,16 +69,26 @@ ### Requirement: 認可ヘルパ requireUser / requireAdmin -システムは、API のリクエスト Context にセッションを復元し、`requireUser` と `requireAdmin` の認可ヘルパを提供 SHALL。`requireUser` は有効な利用者セッションが無ければ拒否し、操作対象を当該利用者「自分のもの」に限定する手段を与える MUST。`requireAdmin` は会計セッションが無ければ拒否する MUST。 +システムは、API のリクエスト Context にセッションを復元し、`requireMember` / `requireUser` / `requireAdmin` の認可ヘルパを提供 SHALL。`requireMember` は traQ 認証(traqId)が無ければ拒否する。`requireUser` は請求可能な利用者アイデンティティ(userId)が無ければ拒否し、操作対象を当該利用者「自分のもの」に限定する手段を与える MUST。`requireAdmin` は会計権限(`isAdmin`)が無ければ拒否する MUST。 #### Scenario: 未ログインで requireUser - **WHEN** セッションの無いリクエストが `requireUser` を要する操作を呼ぶ - **THEN** 認証エラーで拒否される -#### Scenario: 利用者が requireAdmin +#### Scenario: user 未連結の会員が requireUser + +- **WHEN** traQ 会員だが user 未連結(userId 無し)のセッションで `requireUser` を要する操作を呼ぶ +- **THEN** 認証エラーで拒否される(isct 確認/連結が必要) + +#### Scenario: 会員が requireMember + +- **WHEN** traQ 認証済みセッションで `requireMember` を要する操作を呼ぶ +- **THEN** 操作が許可される + +#### Scenario: 会員(非会計)が requireAdmin -- **WHEN** 利用者セッション(会計ではない)で `requireAdmin` を要する操作を呼ぶ +- **WHEN** `isAdmin` を持たないセッションで `requireAdmin` を要する操作を呼ぶ - **THEN** 認可エラーで拒否される #### Scenario: 会計が requireAdmin diff --git a/packages/api/src/auth/auth.test.ts b/packages/api/src/auth/auth.test.ts index e0be6cb..64f6de1 100644 --- a/packages/api/src/auth/auth.test.ts +++ b/packages/api/src/auth/auth.test.ts @@ -3,7 +3,7 @@ import { normalizeEmail, deriveMailHash, safeEqual } from './crypto' import { sanitizeRedirect } from './redirect' import { isAllowedEmailDomain, isAccountant } from './config' import { createAuthHelpers } from './context' -import type { SessionActor } from './session' +import type { SessionIdentity } from './session' const SECRET = 'test-secret' @@ -71,18 +71,35 @@ describe('config: allow-lists', () => { }) }) -describe('context: auth helpers', () => { - const user: SessionActor = { actor: 'user', userId: 'u1', mailHash: 'h1' } - const admin: SessionActor = { actor: 'admin', traqId: 'alice' } +describe('context: auth helpers (dual identity)', () => { + // A plain isct user: userId/mailHash, no traQ, not admin. + const user: SessionIdentity = { traqId: null, isAdmin: false, userId: 'u1', mailHash: 'h1' } + // A traQ member without a linked user. + const member: SessionIdentity = { traqId: 'alice', isAdmin: false, userId: null, mailHash: null } + // An accountant (allow-listed traQ ID). + const admin: SessionIdentity = { traqId: 'alice', isAdmin: true, userId: null, mailHash: null } + // A fully-linked member: both identities present. + const linked: SessionIdentity = { traqId: 'bob', isAdmin: false, userId: 'u2', mailHash: 'h2' } - it('requireUser: rejects anonymous, accepts user', () => { + it('requireMember: rejects anonymous and isct-only user, accepts traQ member', () => { + expect(() => createAuthHelpers(null, true).requireMember()).toThrow() + expect(() => createAuthHelpers(user, true).requireMember()).toThrow() + expect(createAuthHelpers(member, true).requireMember()).toEqual({ traqId: 'alice' }) + expect(createAuthHelpers(linked, true).requireMember()).toEqual({ traqId: 'bob' }) + }) + + it('requireUser: rejects anonymous and user-less member, accepts a billable user', () => { expect(() => createAuthHelpers(null, true).requireUser()).toThrow() + // A traQ member with no linked user cannot issue invoices. + expect(() => createAuthHelpers(member, true).requireUser()).toThrow() expect(createAuthHelpers(user, true).requireUser()).toEqual({ userId: 'u1', mailHash: 'h1' }) + expect(createAuthHelpers(linked, true).requireUser()).toEqual({ userId: 'u2', mailHash: 'h2' }) }) - it('requireAdmin: anonymous and plain user are rejected, admin accepted', () => { + it('requireAdmin: anonymous, plain user and non-admin member are rejected, admin accepted', () => { expect(() => createAuthHelpers(null, true).requireAdmin()).toThrow() expect(() => createAuthHelpers(user, true).requireAdmin()).toThrow() + expect(() => createAuthHelpers(member, true).requireAdmin()).toThrow() expect(createAuthHelpers(admin, true).requireAdmin()).toEqual({ traqId: 'alice' }) }) diff --git a/packages/api/src/auth/context.ts b/packages/api/src/auth/context.ts index a51fc7c..a0cfc8a 100644 --- a/packages/api/src/auth/context.ts +++ b/packages/api/src/auth/context.ts @@ -1,39 +1,57 @@ import { ORPCError } from '@orpc/server' -import type { SessionActor } from './session' +import type { SessionIdentity } from './session' +/** A traQ-authenticated member (the traQ identity on the session). */ +export interface MemberActor { + traqId: string +} + +/** A billable isct user (the user identity on the session). */ export interface UserActor { userId: string mailHash: string } +/** An accountant (the `isAdmin` flag on the session). */ export interface AdminActor { - traqId: string + /** The traQ ID, when present (admin sessions are always traQ-authenticated). */ + traqId: string | null } export interface AuthHelpers { - /** Require a logged-in isct user. Throws UNAUTHORIZED otherwise. */ + /** Require a traQ-authenticated member (traqId). Throws UNAUTHORIZED otherwise. */ + requireMember(): MemberActor + /** Require a billable isct user (userId). Throws UNAUTHORIZED otherwise. */ requireUser(): UserActor - /** Require an accountant (admin). Throws UNAUTHORIZED/FORBIDDEN otherwise. */ + /** Require an accountant (isAdmin). Throws UNAUTHORIZED/FORBIDDEN otherwise. */ requireAdmin(): AdminActor /** Enforce double-submit CSRF on a state-changing call. Throws FORBIDDEN if invalid. */ assertCsrf(): void } /** - * Build the per-request authorization helpers from the resolved session and - * CSRF validity. (session spec: §認可ヘルパ requireUser / requireAdmin, - * §状態変更要求の CSRF 検証) + * Build the per-request authorization helpers from the resolved (dual-identity) + * session and CSRF validity. Each helper checks the relevant identity field: + * `requireMember` needs traqId, `requireUser` needs userId, `requireAdmin` needs + * isAdmin. A single session may satisfy several. (session spec: §認可ヘルパ + * requireMember / requireUser / requireAdmin, §状態変更要求の CSRF 検証) */ -export function createAuthHelpers(session: SessionActor | null, csrfValid: boolean): AuthHelpers { +export function createAuthHelpers(session: SessionIdentity | null, csrfValid: boolean): AuthHelpers { return { + requireMember() { + if (session?.traqId) { + return { traqId: session.traqId } + } + throw new ORPCError('UNAUTHORIZED', { message: 'traQ login required' }) + }, requireUser() { - if (session?.actor === 'user') { + if (session?.userId && session.mailHash) { return { userId: session.userId, mailHash: session.mailHash } } throw new ORPCError('UNAUTHORIZED', { message: 'login required' }) }, requireAdmin() { - if (session?.actor === 'admin') { + if (session?.isAdmin) { return { traqId: session.traqId } } if (session) { diff --git a/packages/api/src/auth/identity.test.ts b/packages/api/src/auth/identity.test.ts new file mode 100644 index 0000000..0447c63 --- /dev/null +++ b/packages/api/src/auth/identity.test.ts @@ -0,0 +1,174 @@ +import { randomUUID } from 'node:crypto' +import { eq } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { createDatabase, schema, type Database } from '@checkin/db' +import { getUserByTraqId, linkTraqId } from './identity' +import { attachUserToSession, createSession, destroySession, resolveSession } from './session' + +/** + * DB-backed tests for the traQ-ID linking helpers and dual-identity session + * resolution (add-traq-member-auth). They run against the MariaDB pointed at by + * DATABASE_URL; if unreachable they self-skip so `pnpm test` stays green without + * infrastructure. No live traQ/Stripe calls are made. (tasks 6.1) + */ + +let db: Database +let available = false + +beforeAll(async () => { + try { + db = createDatabase(process.env.DATABASE_URL ?? 'mysql://checkin:password@localhost:3306/checkin') + await db.select({ id: schema.users.id }).from(schema.users).limit(1) + available = true + } + catch { + available = false + } +}) + +/** Unique suffix so repeated runs don't collide on traq_id / mail_hash. */ +const tag = randomUUID().slice(0, 8) +const userIds: string[] = [] + +afterAll(async () => { + if (!available) { + return + } + for (const id of userIds) { + await db.delete(schema.sessions).where(eq(schema.sessions.userId, id)) + await db.delete(schema.users).where(eq(schema.users.id, id)) + } +}) + +/** Insert a bare user row with a unique mail_hash; returns its id. */ +async function makeUser(): Promise { + const id = randomUUID() + const mailHash = `hash_${tag}_${id.slice(0, 8)}` + await db.insert(schema.users).values({ id, mailHash }) + userIds.push(id) + return id +} + +describe('identity: linkTraqId + getUserByTraqId', () => { + it('links a free traq_id, then resolves the person by it', async () => { + if (!available) { + return + } + const userId = await makeUser() + const traqId = `traq_${tag}_${randomUUID().slice(0, 6)}` + + expect(await linkTraqId(db, userId, traqId)).toBe('linked') + + const resolved = await getUserByTraqId(db, traqId) + expect(resolved?.id).toBe(userId) + expect(resolved?.traqId).toBe(traqId) + }) + + it('returns "exists" when the SAME traq_id is re-linked (idempotent, no overwrite)', async () => { + if (!available) { + return + } + const userId = await makeUser() + const traqId = `traq_${tag}_${randomUUID().slice(0, 6)}` + + expect(await linkTraqId(db, userId, traqId)).toBe('linked') + expect(await linkTraqId(db, userId, traqId)).toBe('exists') + }) + + it('returns "conflict" when another person already owns that traq_id (unique)', async () => { + if (!available) { + return + } + const owner = await makeUser() + const other = await makeUser() + const traqId = `traq_${tag}_${randomUUID().slice(0, 6)}` + + expect(await linkTraqId(db, owner, traqId)).toBe('linked') + // Another user tries to claim the same traq_id → unique violation. + expect(await linkTraqId(db, other, traqId)).toBe('conflict') + // The owner is unchanged. + expect((await getUserByTraqId(db, traqId))?.id).toBe(owner) + }) + + it('returns "conflict" when the row already holds a DIFFERENT traq_id (no overwrite)', async () => { + if (!available) { + return + } + const userId = await makeUser() + const first = `traq_${tag}_${randomUUID().slice(0, 6)}` + const second = `traq_${tag}_${randomUUID().slice(0, 6)}` + + expect(await linkTraqId(db, userId, first)).toBe('linked') + // Compare-and-set finds no NULL row → conflict; the first traq_id stays. + expect(await linkTraqId(db, userId, second)).toBe('conflict') + expect((await getUserByTraqId(db, first))?.id).toBe(userId) + expect(await getUserByTraqId(db, second)).toBeNull() + }) + + it('getUserByTraqId returns null for an unlinked traq_id', async () => { + if (!available) { + return + } + expect(await getUserByTraqId(db, `nobody_${tag}_${randomUUID()}`)).toBeNull() + }) +}) + +describe('session: dual-identity resolution', () => { + it('resolves a member-only session (traqId, no userId/mailHash)', async () => { + if (!available) { + return + } + const traqId = `traq_${tag}_${randomUUID().slice(0, 6)}` + const token = await createSession(db, { traqId, isAdmin: true }, 3600) + + const session = await resolveSession(db, token) + expect(session).toEqual({ traqId, isAdmin: true, userId: null, mailHash: null }) + + await destroySession(db, token) + expect(await resolveSession(db, token)).toBeNull() + }) + + it('resolves a user-only session with mailHash joined from users', async () => { + if (!available) { + return + } + const userId = await makeUser() + const [row] = await db + .select({ mailHash: schema.users.mailHash }) + .from(schema.users) + .where(eq(schema.users.id, userId)) + .limit(1) + const token = await createSession(db, { userId }, 3600) + + const session = await resolveSession(db, token) + expect(session).toEqual({ traqId: null, isAdmin: false, userId, mailHash: row!.mailHash }) + await destroySession(db, token) + }) + + it('attachUserToSession upgrades a member session to carry both identities', async () => { + if (!available) { + return + } + const userId = await makeUser() + const traqId = `traq_${tag}_${randomUUID().slice(0, 6)}` + const token = await createSession(db, { traqId, isAdmin: false }, 3600) + + // Before: member only. + expect((await resolveSession(db, token))?.userId).toBeNull() + + await attachUserToSession(db, token, userId) + const session = await resolveSession(db, token) + expect(session?.traqId).toBe(traqId) + expect(session?.userId).toBe(userId) + expect(session?.mailHash).not.toBeNull() + await destroySession(db, token) + }) + + it('returns null for an unknown token', async () => { + if (!available) { + return + } + expect(await resolveSession(db, 'not-a-real-token')).toBeNull() + expect(await resolveSession(db, undefined)).toBeNull() + }) +}) diff --git a/packages/api/src/auth/identity.ts b/packages/api/src/auth/identity.ts index d33b035..d9b4466 100644 --- a/packages/api/src/auth/identity.ts +++ b/packages/api/src/auth/identity.ts @@ -9,11 +9,14 @@ export interface UserRow { /** * A person row including its (nullable) Stripe Customer link and Connect payout - * fields — for billing and payouts. (identity spec: §Stripe 参照・状態は保持してよい) + * fields — for billing and payouts. `traqId` is the linked authenticated traQ ID + * (副キー) used to resolve Jomon payouts. (identity spec: §Stripe 参照・状態は保持してよい + * / §traQ ID による本人解決と連結) */ export interface BillingUserRow { id: string mailHash: string + traqId: string | null stripeCustomerId: string | null stripeConnectedAccountId: string | null payoutOnboardingStatus: 'none' | 'requested' | 'done' @@ -25,6 +28,7 @@ export async function getUserById(db: Database, id: string): Promise { + const [row] = await db + .select({ + id: schema.users.id, + mailHash: schema.users.mailHash, + traqId: schema.users.traqId, + stripeCustomerId: schema.users.stripeCustomerId, + stripeConnectedAccountId: schema.users.stripeConnectedAccountId, + payoutOnboardingStatus: schema.users.payoutOnboardingStatus, + }) + .from(schema.users) + .where(eq(schema.users.traqId, traqId)) + .limit(1) + return row ?? null +} + /** Fetch a person row by its Connect connected-account id, or null. */ export async function getUserByConnectedAccountId( db: Database, @@ -69,6 +97,7 @@ export async function getUserByConnectedAccountId( .select({ id: schema.users.id, mailHash: schema.users.mailHash, + traqId: schema.users.traqId, stripeCustomerId: schema.users.stripeCustomerId, stripeConnectedAccountId: schema.users.stripeConnectedAccountId, payoutOnboardingStatus: schema.users.payoutOnboardingStatus, @@ -108,6 +137,78 @@ export async function claimConnectedAccountId( return claimed > 0 } +/** + * Race-safe compare-and-set of a person's authenticated `traq_id`: only links it + * when the column is still NULL. Mirrors {@link claimConnectedAccountId} and the + * spec's "未設定なら保存・上書きしない" rule. + * + * - `'linked'` — this call set the column (`affectedRows === 1`). + * - `'exists'` — the row already carries the SAME traq_id (idempotent re-link). + * - `'conflict'` — another person already owns that traq_id (unique violation), + * OR our compare-and-set matched no row because this row holds + * a DIFFERENT traq_id. Caller logs and proceeds (best-effort). + * + * The `unique` constraint on `users.traq_id` backs the conditional UPDATE: even + * if the IS NULL guard were lost to a race, the second writer hits ER_DUP_ENTRY. + * (identity spec: §traQ ID による本人解決と連結 / membership-billing spec: §既に連結済みなら維持) + */ +export async function linkTraqId( + db: Database, + userId: string, + traqId: string, +): Promise<'linked' | 'exists' | 'conflict'> { + // UPDATE ... WHERE id = ? AND traq_id IS NULL — only fills an unset column. + let result: unknown + try { + result = await db + .update(schema.users) + .set({ traqId }) + .where(and( + eq(schema.users.id, userId), + isNull(schema.users.traqId), + )) + } + catch (err) { + // Someone else already owns this traq_id (unique violation) → conflict. + if (isDuplicateKeyError(err)) { + return 'conflict' + } + throw err + } + + // mysql2 returns affectedRows; 1 ⇒ we set it. 0 ⇒ the IS NULL guard failed + // (the row already has a traq_id — same value is idempotent, a different value + // is a conflict we must not overwrite). + const affected = (result as { affectedRows?: number })?.affectedRows ?? 0 + const claimed = Array.isArray(result) + ? ((result[0] as { affectedRows?: number })?.affectedRows ?? 0) + : affected + if (claimed > 0) { + return 'linked' + } + + const existing = await getUserById(db, userId) + return existing?.traqId === traqId ? 'exists' : 'conflict' +} + +/** + * Whether an error is a MySQL/MariaDB duplicate-key (ER_DUP_ENTRY = 1062). Walks + * the `cause` chain because drizzle wraps the mysql2 error ("Failed query: ...") + * and carries the original (with `code`/`errno`) as `.cause`. + */ +function isDuplicateKeyError(err: unknown): boolean { + let cur: unknown = err + for (let depth = 0; cur && depth < 5; depth++) { + const code = (cur as { code?: string }).code + const errno = (cur as { errno?: number }).errno + if (code === 'ER_DUP_ENTRY' || errno === 1062) { + return true + } + cur = (cur as { cause?: unknown }).cause + } + return false +} + /** Update a person's payout onboarding status (none / requested / done). */ export async function setPayoutOnboardingStatus( db: Database, diff --git a/packages/api/src/auth/session.ts b/packages/api/src/auth/session.ts index 02c7ac7..212dbce 100644 --- a/packages/api/src/auth/session.ts +++ b/packages/api/src/auth/session.ts @@ -3,23 +3,44 @@ import { eq } from 'drizzle-orm' import { schema, type Database } from '@checkin/db' import { generateToken, sha256hex } from './crypto' -/** The authenticated principal a session represents. Exactly one actor kind. */ -export type SessionActor - = | { actor: 'user', userId: string, mailHash: string } - | { actor: 'admin', traqId: string } +/** + * The dual identity a session represents. A session can carry a traQ identity + * (`traqId`, from traQ OAuth), an isct user identity (`userId`/`mailHash`, from + * email confirmation), or both (a member who has also confirmed their isct + * email). `isAdmin` is the accountant flag. Any field may be absent — the + * principal is expressed by which fields are set, not by an actor enum. + * (session spec: §セッションは利用者・会計のいずれかのアクターを表す) + */ +export interface SessionIdentity { + /** Authenticated traQ ID, or null if not logged in via traQ. */ + traqId: string | null + /** Accountant権限 (env allow-list). */ + isAdmin: boolean + /** Linked/confirmed isct user (`users.id`), or null if no user is resolved. */ + userId: string | null + /** The user's `mail_hash` (joined from `users` when `userId` is set), or null. */ + mailHash: string | null +} -/** What to store when creating a session. */ -export type NewSessionActor - = | { actor: 'user', userId: string } - | { actor: 'admin', traqId: string } +/** What to store when creating a session. `mailHash` is NOT stored — it is joined from `users`. */ +export interface NewSession { + /** Authenticated traQ ID to attach, if any. */ + traqId?: string | null + /** Whether the session is an accountant. Defaults to false. */ + isAdmin?: boolean + /** Linked isct user id, if already resolved at creation time. */ + userId?: string | null +} /** * Create a server-side session and return the opaque token to put in the cookie. * Only the token's hash is persisted, so a DB leak cannot reconstruct live cookies. + * Stores `user_id`, `traq_id`, and `is_admin`; `mail_hash` is never stored (it is + * resolved by joining `users` on `user_id` in {@link resolveSession}). */ export async function createSession( db: Database, - actor: NewSessionActor, + identity: NewSession, ttlSec: number, ): Promise { const token = generateToken() @@ -27,28 +48,29 @@ export async function createSession( await db.insert(schema.sessions).values({ id: randomUUID(), idHash: sha256hex(token), - actorType: actor.actor, - userId: actor.actor === 'user' ? actor.userId : null, - traqId: actor.actor === 'admin' ? actor.traqId : null, + userId: identity.userId ?? null, + traqId: identity.traqId ?? null, + isAdmin: identity.isAdmin ?? false, expiresAt, }) return token } /** - * Resolve a session token to its actor, or null if missing/expired. Expired - * sessions are best-effort deleted. (session spec: §期限切れセッションは無効) + * Resolve a session token to its dual identity, or null if missing/expired. + * Expired sessions are best-effort deleted. The left join on `users` supplies + * `mailHash` whenever the session has a `user_id`. (session spec: §期限切れセッションは無効) */ -export async function resolveSession(db: Database, token: string | undefined): Promise { +export async function resolveSession(db: Database, token: string | undefined): Promise { if (!token) { return null } const idHash = sha256hex(token) const [row] = await db .select({ - actorType: schema.sessions.actorType, userId: schema.sessions.userId, traqId: schema.sessions.traqId, + isAdmin: schema.sessions.isAdmin, expiresAt: schema.sessions.expiresAt, mailHash: schema.users.mailHash, }) @@ -65,13 +87,31 @@ export async function resolveSession(db: Database, token: string | undefined): P return null } - if (row.actorType === 'admin' && row.traqId) { - return { actor: 'admin', traqId: row.traqId } + return { + traqId: row.traqId ?? null, + isAdmin: row.isAdmin, + // Only expose a userId when the joined `users` row actually exists (mailHash + // present) — a dangling user_id (e.g. deleted user) must not look logged-in. + userId: row.userId && row.mailHash ? row.userId : null, + mailHash: row.mailHash ?? null, } - if (row.actorType === 'user' && row.userId && row.mailHash) { - return { actor: 'user', userId: row.userId, mailHash: row.mailHash } +} + +/** + * Attach an isct user to an EXISTING session (keeping the same token/cookie). + * Used when an isct email confirmation happens on a live traQ member session: + * we link `user_id` onto that session rather than minting a new one, so the + * member ends up with both identities on one session. Safe to call on an + * unknown token (no-op). (session spec: §連結済みは両アイデンティティを持つ) + */ +export async function attachUserToSession(db: Database, token: string | undefined, userId: string): Promise { + if (!token) { + return } - return null + await db + .update(schema.sessions) + .set({ userId }) + .where(eq(schema.sessions.idHash, sha256hex(token))) } /** Invalidate a session (logout). Safe to call with an unknown/expired token. */ diff --git a/packages/api/src/orpc.ts b/packages/api/src/orpc.ts index 02c56c9..cb03c52 100644 --- a/packages/api/src/orpc.ts +++ b/packages/api/src/orpc.ts @@ -2,7 +2,7 @@ import { os } from '@orpc/server' import type { Database } from '@checkin/db' import type { AuthConfig } from './auth/config' import type { Mailer } from './auth/mailer' -import type { SessionActor } from './auth/session' +import type { SessionIdentity } from './auth/session' import type { AuthHelpers } from './auth/context' import type { BillingConfig } from './billing/config' import type { StripeClient } from './stripe/client' @@ -28,13 +28,22 @@ export interface Context extends AuthHelpers { jomon: JomonClient /** Resolved Jomon config (carries the default payout currency). */ jomonConfig: JomonConfig - /** The authenticated actor, or null when unauthenticated. */ - session: SessionActor | null + /** The resolved dual identity (traQ member / isct user / admin), or null. */ + session: SessionIdentity | null } /** Base procedure builder — start all procedures from here. */ export const pub = os.$context() +/** + * Member procedure builder — requires a traQ-authenticated session (traqId). As + * with the others, the check runs as oRPC middleware ahead of input validation. + */ +export const memberProc = pub.use(({ context, next }) => { + context.requireMember() + return next() +}) + /** * Authenticated procedure builder. The auth check runs as oRPC middleware, so it * executes BEFORE `.input()` validation — an unauthenticated caller sending diff --git a/packages/api/src/router.ts b/packages/api/src/router.ts index 2c3f608..c354190 100644 --- a/packages/api/src/router.ts +++ b/packages/api/src/router.ts @@ -7,6 +7,7 @@ import { sanitizeRedirect, createEmailVerification, getUserById, + linkTraqId, setPayoutOnboardingStatus, } from './auth' import { computeActivityYear, computeTerm, selectPriceId } from './billing' @@ -81,16 +82,19 @@ export const appRouter = { return { ok: true as const } }), - /** Return the current actor (own info only). */ + /** + * Return the current dual identity (own info only). `member` = traQ-authed, + * `admin` = accountant, `hasUser` = has a billable isct user linked. + */ me: pub.handler(({ context }) => { const session = context.session - if (!session) { - return { actor: null } - } - if (session.actor === 'admin') { - return { actor: 'admin' as const, traqId: session.traqId } + return { + authenticated: session !== null, + member: !!session?.traqId, + admin: !!session?.isAdmin, + hasUser: !!session?.userId, + traqId: session?.traqId ?? null, } - return { actor: 'user' as const } }), }, @@ -117,6 +121,25 @@ export const appRouter = { throw new ORPCError('FORBIDDEN', { message: 'cannot issue an invoice for another person' }) } + // If the caller is also traQ-authenticated, link their authenticated traQ + // ID onto this user row (未設定なら保存; 上書きしない). Best-effort: a + // conflict (traq_id owned by another user) is logged, never fails the + // invoice. We track whether the session traq_id legitimately belongs to + // this user (`linked`/`exists`) so we never stamp a conflicting (foreign) + // traQ ID into the Customer metadata below. + // (membership-billing spec: §支払い時の traQ ID 連結) + const traqId = context.session?.traqId ?? null + let linkedTraqId: string | null = null + if (traqId) { + const result = await linkTraqId(context.db, user.userId, traqId) + if (result === 'conflict') { + console.warn(`linkTraqId conflict on issueInvoice: traqId=${traqId} userId=${user.userId}`) + } + else { + linkedTraqId = traqId + } + } + // Standard issuance only (special is admin-only via issueSpecialInvoice). const now = new Date() const term = computeTerm(now) @@ -137,6 +160,12 @@ export const appRouter = { email: input.email, name: input.name, mailHash: user.mailHash, + // Stamp the authenticated traQ ID into Customer metadata when present + // (reference keys stay mail_hash / customer_id). Use the session traq_id + // ONLY when it legitimately belongs to this user (link result + // linked/exists); on conflict it is owned by someone else, so fall back + // to the user's already-linked id and never the foreign session traq_id. + traqId: linkedTraqId ?? row.traqId ?? undefined, }) const activityYear = computeActivityYear(now) diff --git a/packages/db/drizzle/0006_wild_nemesis.sql b/packages/db/drizzle/0006_wild_nemesis.sql new file mode 100644 index 0000000..580dd3c --- /dev/null +++ b/packages/db/drizzle/0006_wild_nemesis.sql @@ -0,0 +1,4 @@ +ALTER TABLE `sessions` ADD `is_admin` boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE `users` ADD `traq_id` varchar(255);--> statement-breakpoint +ALTER TABLE `users` ADD CONSTRAINT `users_traq_id_unique` UNIQUE(`traq_id`);--> statement-breakpoint +ALTER TABLE `sessions` DROP COLUMN `actor_type`; \ No newline at end of file diff --git a/packages/db/drizzle/meta/0006_snapshot.json b/packages/db/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000..75180cd --- /dev/null +++ b/packages/db/drizzle/meta/0006_snapshot.json @@ -0,0 +1,442 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "687a0892-e654-400f-a672-305388fd4d60", + "prevId": "b5f06577-c647-4637-a061-df68cdddd7dc", + "tables": { + "email_verifications": { + "name": "email_verifications", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mail_hash": { + "name": "mail_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect": { + "name": "redirect", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "email_verifications_id": { + "name": "email_verifications_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "email_verifications_token_hash_unique": { + "name": "email_verifications_token_hash_unique", + "columns": [ + "token_hash" + ] + } + }, + "checkConstraint": {} + }, + "payouts": { + "name": "payouts", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "jomon_ref": { + "name": "jomon_ref", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "amount": { + "name": "amount", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "currency": { + "name": "currency", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('pending','onboarding_waiting','processing','paid','failed')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "stripe_transfer_id": { + "name": "stripe_transfer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "jomon_written_back_at": { + "name": "jomon_written_back_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "payouts_user_id_users_id_fk": { + "name": "payouts_user_id_users_id_fk", + "tableFrom": "payouts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "payouts_id": { + "name": "payouts_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "payouts_jomon_ref_unique": { + "name": "payouts_jomon_ref_unique", + "columns": [ + "jomon_ref" + ] + } + }, + "checkConstraint": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id_hash": { + "name": "id_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "traq_id": { + "name": "traq_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sessions_id": { + "name": "sessions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "sessions_id_hash_unique": { + "name": "sessions_id_hash_unique", + "columns": [ + "id_hash" + ] + } + }, + "checkConstraint": {} + }, + "stripe_events": { + "name": "stripe_events", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "received_at": { + "name": "received_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "stripe_events_id": { + "name": "stripe_events_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "stripe_events_event_id_unique": { + "name": "stripe_events_event_id_unique", + "columns": [ + "event_id" + ] + } + }, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mail_hash": { + "name": "mail_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "traq_id": { + "name": "traq_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripe_connected_account_id": { + "name": "stripe_connected_account_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payout_onboarding_status": { + "name": "payout_onboarding_status", + "type": "enum('none','requested','done')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'none'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "users_mail_hash_unique": { + "name": "users_mail_hash_unique", + "columns": [ + "mail_hash" + ] + }, + "users_traq_id_unique": { + "name": "users_traq_id_unique", + "columns": [ + "traq_id" + ] + }, + "users_stripe_connected_account_id_unique": { + "name": "users_stripe_connected_account_id_unique", + "columns": [ + "stripe_connected_account_id" + ] + } + }, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 0783db3..18774fc 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1781884730370, "tag": "0005_blushing_blindfold", "breakpoints": true + }, + { + "idx": 6, + "version": "5", + "when": 1781959782887, + "tag": "0006_wild_nemesis", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 873c9e4..59347d2 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -1,4 +1,4 @@ -import { mysqlTable, varchar, timestamp, mysqlEnum, int } from 'drizzle-orm/mysql-core' +import { mysqlTable, varchar, timestamp, mysqlEnum, int, boolean } from 'drizzle-orm/mysql-core' /** * Auth / identity foundation (OpenSpec change: add-auth-foundation). @@ -12,6 +12,14 @@ export const users = mysqlTable('users', { id: varchar('id', { length: 36 }).primaryKey(), // hex HMAC-SHA256 → 64 chars. Unique: one row per person. mailHash: varchar('mail_hash', { length: 64 }).notNull().unique(), + // Authenticated traQ ID (non-PII secondary key). Added by add-traq-member-auth: + // Jomon hands refunds keyed by traQ ID, so we link the authenticated traQ ID + // (from an OAuth session, never form input) to this person row to resolve + // payouts to a user. Unique: a traQ ID belongs to exactly one person — MariaDB + // allows multiple NULLs under a unique index, so nullable + unique fits the + // "not yet linked" case and backs the race-safe compare-and-set in + // `linkTraqId`. (identity spec: §traQ ID による本人解決と連結) + traqId: varchar('traq_id', { length: 255 }).unique(), // Stripe Customer reference (non-PII). Added by add-membership-collection: the // collection flow does a get-or-create against Stripe and persists the id here // so subsequent invoices reuse the same Customer. Plaintext email is still @@ -39,15 +47,22 @@ export const users = mysqlTable('users', { /** * Server-side sessions. The opaque session id lives in the `__Host-checkin_session` * cookie; only its hash is stored here so a DB leak does not expose live cookies. - * A session represents exactly one actor: a `user` (isct member, by `user_id`) or - * an `admin` (accountant, by `traq_id`). + * + * A session carries a **dual identity** (add-traq-member-auth): it can hold a + * traQ identity (`traq_id`, set when logged in via traQ OAuth) and/or an isct + * user identity (`user_id`, set when the isct email is confirmed), plus an + * `is_admin` accountant flag. The old `actor_type` enum is gone — the principal + * is expressed by *which* of `user_id` / `traq_id` / `is_admin` are set. + * (session spec: §セッションは利用者・会計のいずれかのアクターを表す) */ export const sessions = mysqlTable('sessions', { id: varchar('id', { length: 36 }).primaryKey(), idHash: varchar('id_hash', { length: 64 }).notNull().unique(), - actorType: mysqlEnum('actor_type', ['user', 'admin']).notNull(), userId: varchar('user_id', { length: 36 }).references(() => users.id), traqId: varchar('traq_id', { length: 255 }), + // Accountant権限フラグ. Granted when the traQ ID is on the env allow-list. A + // session can be a member (traqId) without being admin. (admin-authorization spec) + isAdmin: boolean('is_admin').notNull().default(false), expiresAt: timestamp('expires_at').notNull(), createdAt: timestamp('created_at').notNull().defaultNow(), }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ed15559..44c3907 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,7 +13,7 @@ importers: version: 1.4.1(@types/node@26.0.0) '@nuxt/eslint': specifier: ^1.16.0 - version: 1.16.0(@typescript-eslint/utils@8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(@vue/compiler-sfc@3.5.38)(crossws@0.4.6(srvx@0.11.16))(eslint@9.39.4(jiti@2.7.0))(magicast@0.5.3)(oxc-parser@0.133.0)(typescript@5.9.3)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 1.16.0(@typescript-eslint/utils@8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(@vue/compiler-sfc@3.5.38)(crossws@0.4.6(srvx@0.11.16))(eslint@9.39.4(jiti@2.7.0))(magicast@0.5.3)(oxc-parser@0.133.0)(typescript@5.9.3)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) eslint: specifier: ^9.39.0 version: 9.39.4(jiti@2.7.0) @@ -22,7 +22,7 @@ importers: version: 5.9.3 vitest: specifier: ^2.1.9 - version: 2.1.9(@types/node@26.0.0)(terser@5.48.0) + version: 2.1.9(@types/node@26.0.0)(lightningcss@1.32.0)(terser@5.48.0) apps/web: dependencies: @@ -32,6 +32,9 @@ importers: '@checkin/db': specifier: workspace:* version: link:../../packages/db + '@nuxt/ui': + specifier: ^3.3.7 + version: 3.3.7(@babel/parser@7.29.7)(change-case@5.4.4)(db0@0.3.4(drizzle-orm@0.45.2(mysql2@3.22.5(@types/node@26.0.0)))(mysql2@3.22.5(@types/node@26.0.0)))(embla-carousel@8.6.0)(ioredis@5.11.1)(magicast@0.5.3)(typescript@5.9.3)(valibot@1.4.1(typescript@5.9.3))(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue@3.5.38(typescript@5.9.3))(zod@4.4.3) '@orpc/client': specifier: ^1.14.0 version: 1.14.6 @@ -40,14 +43,20 @@ importers: version: 1.14.6(crossws@0.4.6(srvx@0.11.16))(ws@8.21.0) nuxt: specifier: ^4.4.0 - version: 4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.0.0)(@vue/compiler-sfc@3.5.38)(cac@6.7.14)(db0@0.3.4(drizzle-orm@0.45.2(mysql2@3.22.5(@types/node@26.0.0)))(mysql2@3.22.5(@types/node@26.0.0)))(drizzle-orm@0.45.2(mysql2@3.22.5(@types/node@26.0.0)))(eslint@9.39.4(jiti@2.7.0))(ioredis@5.11.1)(magicast@0.5.3)(mysql2@3.22.5(@types/node@26.0.0))(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.62.0))(rollup@4.62.0)(srvx@0.11.16)(terser@5.48.0)(tsx@4.22.4)(typescript@5.9.3)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue-tsc@3.3.5(typescript@5.9.3))(yaml@2.9.0) + version: 4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.0.0)(@vue/compiler-sfc@3.5.38)(cac@6.7.14)(db0@0.3.4(drizzle-orm@0.45.2(mysql2@3.22.5(@types/node@26.0.0)))(mysql2@3.22.5(@types/node@26.0.0)))(drizzle-orm@0.45.2(mysql2@3.22.5(@types/node@26.0.0)))(eslint@9.39.4(jiti@2.7.0))(ioredis@5.11.1)(lightningcss@1.32.0)(magicast@0.5.3)(mysql2@3.22.5(@types/node@26.0.0))(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.62.0))(rollup@4.62.0)(srvx@0.11.16)(terser@5.48.0)(tsx@4.22.4)(typescript@5.9.3)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue-tsc@3.3.5(typescript@5.9.3))(yaml@2.9.0) + tailwindcss: + specifier: ^4.3.1 + version: 4.3.1 + tslib: + specifier: ^2.8.1 + version: 2.8.1 vue: specifier: ^3.5.0 version: 3.5.38(typescript@5.9.3) devDependencies: '@nuxt/eslint': specifier: ^1.16.0 - version: 1.16.0(@typescript-eslint/utils@8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(@vue/compiler-sfc@3.5.38)(crossws@0.4.6(srvx@0.11.16))(eslint@9.39.4(jiti@2.7.0))(magicast@0.5.3)(oxc-parser@0.133.0)(typescript@5.9.3)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 1.16.0(@typescript-eslint/utils@8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(@vue/compiler-sfc@3.5.38)(crossws@0.4.6(srvx@0.11.16))(eslint@9.39.4(jiti@2.7.0))(magicast@0.5.3)(oxc-parser@0.133.0)(typescript@5.9.3)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) typescript: specifier: ^5.9.0 version: 5.9.3 @@ -101,9 +110,16 @@ importers: packages: + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + '@antfu/utils@8.1.1': + resolution: {integrity: sha512-Mex9nXf9vR6AhcXmMrlz/HVgYYZpVGJ6YlPgwl7UnaFpnshXs6EK/oa5Gpf3CzENMjkvEx2tQtntGnb7UtSTOQ==} + '@apidevtools/json-schema-ref-parser@14.2.1': resolution: {integrity: sha512-HmdFw9CDYqM6B25pqGBpNeLCKvGPlIx1EbLrVL0zPvj50CJQUHyBNBw45Muk0kEIkogo1VZvOKHajdMuAzSxRg==} engines: {node: '>= 20'} @@ -263,6 +279,12 @@ packages: commander: optional: true + '@capsizecss/metrics@3.7.0': + resolution: {integrity: sha512-NHdEMrl/zd2XgiSv2xHRF/FxGc2OTBKjhPzr9SgbHzqmoTVn8BbRK88Dtq0m65idX/RMD7ptyVdbGHFcvlErSw==} + + '@capsizecss/unpack@2.4.0': + resolution: {integrity: sha512-GrSU71meACqcmIUxPYOJvGKF0yryjN/L1aCuE9DViCTJI7bfkjgYDPD1zbNDcINJwSSP6UaBZY9GAbYDO7re0Q==} + '@clack/core@1.4.1': resolution: {integrity: sha512-FILJa1gGKEFTGZAJE9RpVhrjKz3c3h4ar60dSv6cGuDqufQ84YEIS3GAGvZiN+H6yaLbbvTFNejjCC4tXpZEuw==} engines: {node: '>= 20.12.0'} @@ -1131,6 +1153,18 @@ packages: engines: {node: '>=20.19.0'} hasBin: true + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + + '@floating-ui/vue@1.1.11': + resolution: {integrity: sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw==} + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -1151,6 +1185,20 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@iconify/collections@1.0.698': + resolution: {integrity: sha512-k7lDu5QpRk7arMiSaB8fG2zlQpu3B38q2S1GKnqrJQJPh6ZcTdIB9PWcvaQlYVGEzE2zhRJ1qcZmqHQjrh+/ag==} + + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@iconify/utils@2.3.0': + resolution: {integrity: sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA==} + + '@iconify/vue@5.0.1': + resolution: {integrity: sha512-aumwwooJlFJ5H5qYWB6ZTAyM0C8hpfcSVLB9/a3qnH1GGvIJ+FEbpEs4s/HfErYe/M5qZeLjwmESR5fFm3lXEw==} + peerDependencies: + vue: '>=3.0.0' + '@inquirer/ansi@1.0.2': resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} engines: {node: '>=18'} @@ -1285,6 +1333,12 @@ packages: '@types/node': optional: true + '@internationalized/date@3.12.2': + resolution: {integrity: sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==} + + '@internationalized/number@3.6.7': + resolution: {integrity: sha512-3ji1fcrT+FPAK86UqEhB/psHixYo6niWPJtt7+qRaYFynt/BaJG8GhAPimtWUpEiVSTq8ZM8L5psMxGquiB/Vg==} + '@ioredis/commands@1.10.0': resolution: {integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==} @@ -1357,6 +1411,11 @@ packages: '@nuxt/devalue@2.0.2': resolution: {integrity: sha512-GBzP8zOc7CGWyFQS6dv1lQz8VVpz5C2yRszbXufwG/9zhStTIH50EtD87NmWbTMwXDvZLNg8GIpb1UFdH93JCA==} + '@nuxt/devtools-kit@2.7.0': + resolution: {integrity: sha512-MIJdah6CF6YOW2GhfKnb8Sivu6HpcQheqdjOlZqShBr+1DyjtKQbAKSCAyKPaoIzZP4QOo2SmTFV6aN8jBeEIQ==} + peerDependencies: + vite: '>=6.0' + '@nuxt/devtools-kit@3.2.4': resolution: {integrity: sha512-Yxy2Xgmq5hf3dQy983V0xh0OJV2mYwRZz9eVIGc3EaribdFGPDNGMMbYqX9qCty3Pbxn/bCF3J0UyPaNlHVayQ==} peerDependencies: @@ -1402,6 +1461,16 @@ packages: vite-plugin-eslint2: optional: true + '@nuxt/fonts@0.11.4': + resolution: {integrity: sha512-GbLavsC+9FejVwY+KU4/wonJsKhcwOZx/eo4EuV57C4osnF/AtEmev8xqI0DNlebMEhEGZbu1MGwDDDYbeR7Bw==} + + '@nuxt/icon@1.15.0': + resolution: {integrity: sha512-kA0rxqr1B601zNJNcOXera8CyYcxUCEcT7dXEC7rwAz71PRCN5emf7G656eKEQgtqrD4JSj6NQqWDgrmFcf/GQ==} + + '@nuxt/kit@3.21.8': + resolution: {integrity: sha512-kg63DUPY5AHPn+9XM7u8rYcdWHXjzwfUscgRDuiC5YUciQ+xdLRhdwXelYFxEAx2nxJHossliiQXbMm/Fleivw==} + engines: {node: '>=18.12.0'} + '@nuxt/kit@4.4.8': resolution: {integrity: sha512-ZUlZ5iYfyfJFDPluhn6ZxFWcsuxWbLnZBc8w3MAROcQ4lYfZ+qFpALBLSNlpc0zhOa++33EE+5PEbOAdVIY+dw==} engines: {node: '>=18.12.0'} @@ -1433,6 +1502,34 @@ packages: peerDependencies: '@nuxt/kit': '>=3.0.0' + '@nuxt/ui@3.3.7': + resolution: {integrity: sha512-pTUb/Uk3J2XVWDFks7baTmq9hfp+T+nvnXP7/QypBIAZLdkSMgEsFmP7xgX5Gugc/LaThIkAifzfks4wiSGuVA==} + hasBin: true + peerDependencies: + '@inertiajs/vue3': ^2.0.7 + joi: ^17.13.0 + superstruct: ^2.0.0 + typescript: ^5.6.3 + valibot: ^1.0.0 + vue-router: ^4.5.0 + yup: ^1.6.0 + zod: ^3.24.0 || ^4.0.0 + peerDependenciesMeta: + '@inertiajs/vue3': + optional: true + joi: + optional: true + superstruct: + optional: true + valibot: + optional: true + vue-router: + optional: true + yup: + optional: true + zod: + optional: true + '@nuxt/vite-builder@4.4.8': resolution: {integrity: sha512-54M/k6qVY85Qeoe1m/lPZ0SANGJEbI50r5uYgh3XT942ENve3K5Nk6TMYp8i5wGGC4TWvPea+1mlCrp8rjsXag==} engines: {node: ^22.12.0 || ^24.11.0 || >=26.0.0} @@ -1453,6 +1550,9 @@ packages: rollup-plugin-visualizer: optional: true + '@nuxtjs/color-mode@3.5.2': + resolution: {integrity: sha512-cC6RfgZh3guHBMLLjrBB2Uti5eUoGM9KyauOaYS9ETmxNWBMTvpgjvSiSJp1OFljIXPIqVTJ3xtJpSNZiO3ZaA==} + '@orpc/client@1.14.6': resolution: {integrity: sha512-Y03NcTtmEJdxcqkKBkdGxqe1IHVpD9IorshG4PaTnz9dQIW+RYI8anRo7o0IlbBlzBICN+Ubo1rnw6bpkhagCQ==} @@ -2380,6 +2480,124 @@ packages: peerDependencies: eslint: ^9.0.0 || ^10.0.0 + '@swc/helpers@0.5.23': + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} + + '@tailwindcss/node@4.3.1': + resolution: {integrity: sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==} + + '@tailwindcss/oxide-android-arm64@4.3.1': + resolution: {integrity: sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.1': + resolution: {integrity: sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.1': + resolution: {integrity: sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.1': + resolution: {integrity: sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.1': + resolution: {integrity: sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.1': + resolution: {integrity: sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.1': + resolution: {integrity: sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.1': + resolution: {integrity: sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.3.1': + resolution: {integrity: sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.3.1': + resolution: {integrity: sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.1': + resolution: {integrity: sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.1': + resolution: {integrity: sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.1': + resolution: {integrity: sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==} + engines: {node: '>= 20'} + + '@tailwindcss/postcss@4.3.1': + resolution: {integrity: sha512-dNJuNbdEJT/SWRuXTYP1WSamelsz3ztkUsdtWQPjrexysrTpaEPM40P/71knXiXLYEojqPOEGitVLLpPMS5T6A==} + + '@tailwindcss/vite@4.3.1': + resolution: {integrity: sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + + '@tanstack/table-core@8.21.3': + resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} + engines: {node: '>=12'} + + '@tanstack/virtual-core@3.17.1': + resolution: {integrity: sha512-VZyW2Uiml5tmBZwPGrSD3Sz73OxzljQMCmzYHsUTPEuTsERf5xwa+uWb01xEzkz3ZSYTjj8NEb/mKHvgKxyZdA==} + + '@tanstack/vue-table@8.21.3': + resolution: {integrity: sha512-rusRyd77c5tDPloPskctMyPLFEQUeBzxdQ+2Eow4F7gDPlPOB1UnnhzfpdvqZ8ZyX2rRNGmqNnQWm87OI2OQPw==} + engines: {node: '>=12'} + peerDependencies: + vue: '>=3.2' + + '@tanstack/vue-virtual@3.13.29': + resolution: {integrity: sha512-MWb9tNHjpar3sP34b8+3A4I5j9akveoPXIYqqp7/ipyWd49a/kso+1S1LqEmAVR/+g/k1WWTJC4ktvdCGWgXYQ==} + peerDependencies: + vue: ^2.7.0 || ^3.0.0 + '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} @@ -2404,6 +2622,12 @@ packages: '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + '@types/web-bluetooth@0.0.20': + resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==} + + '@types/web-bluetooth@0.0.21': + resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + '@typescript-eslint/eslint-plugin@8.61.1': resolution: {integrity: sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2721,6 +2945,79 @@ packages: '@vue/shared@3.5.38': resolution: {integrity: sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug==} + '@vueuse/core@10.11.1': + resolution: {integrity: sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==} + + '@vueuse/core@12.8.2': + resolution: {integrity: sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==} + + '@vueuse/core@13.9.0': + resolution: {integrity: sha512-ts3regBQyURfCE2BcytLqzm8+MmLlo5Ln/KLoxDVcsZ2gzIwVNnQpQOL/UKV8alUqjSZOlpFZcRNsLRqj+OzyA==} + peerDependencies: + vue: ^3.5.0 + + '@vueuse/integrations@13.9.0': + resolution: {integrity: sha512-SDobKBbPIOe0cVL7QxMzGkuUGHvWTdihi9zOrrWaWUgFKe15cwEcwfWmgrcNzjT6kHnNmWuTajPHoIzUjYNYYQ==} + peerDependencies: + async-validator: ^4 + axios: ^1 + change-case: ^5 + drauu: ^0.4 + focus-trap: ^7 + fuse.js: ^7 + idb-keyval: ^6 + jwt-decode: ^4 + nprogress: ^0.2 + qrcode: ^1.5 + sortablejs: ^1 + universal-cookie: ^7 || ^8 + vue: ^3.5.0 + peerDependenciesMeta: + async-validator: + optional: true + axios: + optional: true + change-case: + optional: true + drauu: + optional: true + focus-trap: + optional: true + fuse.js: + optional: true + idb-keyval: + optional: true + jwt-decode: + optional: true + nprogress: + optional: true + qrcode: + optional: true + sortablejs: + optional: true + universal-cookie: + optional: true + + '@vueuse/metadata@10.11.1': + resolution: {integrity: sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==} + + '@vueuse/metadata@12.8.2': + resolution: {integrity: sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==} + + '@vueuse/metadata@13.9.0': + resolution: {integrity: sha512-1AFRvuiGphfF7yWixZa0KwjYH8ulyjDCC0aFgrGRz8+P4kvDFSdXLVfTk5xAN9wEuD1J6z4/myMoYbnHoX07zg==} + + '@vueuse/shared@10.11.1': + resolution: {integrity: sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==} + + '@vueuse/shared@12.8.2': + resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==} + + '@vueuse/shared@13.9.0': + resolution: {integrity: sha512-e89uuTLMh0U5cZ9iDpEI2senqPGfbPRTHM/0AaQkcxnpqjkZqDYP8rpfm7edOz8s+pOCOROEy1PIveSW8+fL5g==} + peerDependencies: + vue: ^3.5.0 + abbrev@3.0.1: resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==} engines: {node: ^18.17.0 || >=20.5.0} @@ -2793,6 +3090,10 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -2895,6 +3196,9 @@ packages: birpc@4.0.0: resolution: {integrity: sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==} + blob-to-buffer@1.2.9: + resolution: {integrity: sha512-BF033y5fN6OCofD3vgHmNtwZWRcq9NLyyxyILx9hfMy1sXYy4ojFl765hJ2lP0YaN2fuxPaLO2Vzzoxy0FLFFA==} + boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} @@ -2912,6 +3216,9 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + brotli@1.3.3: + resolution: {integrity: sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==} + browserslist@4.28.2: resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -2991,6 +3298,10 @@ packages: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + chokidar@5.0.0: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} @@ -3025,6 +3336,10 @@ packages: resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} engines: {node: '>=20'} + clone@2.1.2: + resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==} + engines: {node: '>=0.8'} + cluster-key-slot@1.1.1: resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==} engines: {node: '>=0.10.0'} @@ -3036,6 +3351,9 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + colortranslator@5.0.0: + resolution: {integrity: sha512-Z3UPUKasUVDFCDYAjP2fmlVRf1jFHJv1izAmPjiOa0OCIw1W7iC8PZ2GsoDa8uZv+mKyWopxxStT9q05+27h7w==} + commander@11.1.0: resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} engines: {node: '>=16'} @@ -3109,6 +3427,9 @@ packages: resolution: {integrity: sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==} engines: {node: '>=18.0'} + cross-fetch@3.2.0: + resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -3257,6 +3578,9 @@ packages: '@modelcontextprotocol/sdk': optional: true + dfa@1.2.0: + resolution: {integrity: sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==} + diff@8.0.4: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} @@ -3394,6 +3718,50 @@ packages: electron-to-chromium@1.5.376: resolution: {integrity: sha512-cUVA7/RvbFTEuw/i3obUwDTRIXojaxkResf+ibByPFxjc6XK3VNtcQXV0NSbAlJ0FMjcJGgftVVB4Qo184EXvA==} + embla-carousel-auto-height@8.6.0: + resolution: {integrity: sha512-/HrJQOEM6aol/oF33gd2QlINcXy3e19fJWvHDuHWp2bpyTa+2dm9tVVJak30m2Qy6QyQ6Fc8DkImtv7pxWOJUQ==} + peerDependencies: + embla-carousel: 8.6.0 + + embla-carousel-auto-scroll@8.6.0: + resolution: {integrity: sha512-WT9fWhNXFpbQ6kP+aS07oF5IHYLZ1Dx4DkwgCY8Hv2ZyYd2KMCPfMV1q/cA3wFGuLO7GMgKiySLX90/pQkcOdQ==} + peerDependencies: + embla-carousel: 8.6.0 + + embla-carousel-autoplay@8.6.0: + resolution: {integrity: sha512-OBu5G3nwaSXkZCo1A6LTaFMZ8EpkYbwIaH+bPqdBnDGQ2fh4+NbzjXjs2SktoPNKCtflfVMc75njaDHOYXcrsA==} + peerDependencies: + embla-carousel: 8.6.0 + + embla-carousel-class-names@8.6.0: + resolution: {integrity: sha512-l1hm1+7GxQ+zwdU2sea/LhD946on7XO2qk3Xq2XWSwBaWfdgchXdK567yzLtYSHn4sWYdiX+x4nnaj+saKnJkw==} + peerDependencies: + embla-carousel: 8.6.0 + + embla-carousel-fade@8.6.0: + resolution: {integrity: sha512-qaYsx5mwCz72ZrjlsXgs1nKejSrW+UhkbOMwLgfRT7w2LtdEB03nPRI06GHuHv5ac2USvbEiX2/nAHctcDwvpg==} + peerDependencies: + embla-carousel: 8.6.0 + + embla-carousel-reactive-utils@8.6.0: + resolution: {integrity: sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==} + peerDependencies: + embla-carousel: 8.6.0 + + embla-carousel-vue@8.6.0: + resolution: {integrity: sha512-v8UO5UsyLocZnu/LbfQA7Dn2QHuZKurJY93VUmZYP//QRWoCWOsionmvLLAlibkET3pGPs7++03VhJKbWD7vhQ==} + peerDependencies: + vue: ^3.2.37 + + embla-carousel-wheel-gestures@8.1.0: + resolution: {integrity: sha512-J68jkYrxbWDmXOm2n2YHl+uMEXzkGSKjWmjaEgL9xVvPb3HqVmg6rJSKfI3sqIDVvm7mkeTy87wtG/5263XqHQ==} + engines: {node: '>=10'} + peerDependencies: + embla-carousel: ^8.0.0 || ~8.0.0-rc03 + + embla-carousel@8.6.0: + resolution: {integrity: sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==} + emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -3407,6 +3775,10 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + enhanced-resolve@5.21.6: + resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==} + engines: {node: '>=10.13.0'} + entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -3720,6 +4092,12 @@ packages: flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + fontaine@0.6.0: + resolution: {integrity: sha512-cfKqzB62GmztJhwJ0YXtzNsmpqKAcFzTqsakJ//5COTzbou90LU7So18U+4D8z+lDXr4uztaAUZBonSoPDcj1w==} + + fontkit@2.0.4: + resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==} + foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} @@ -3808,6 +4186,10 @@ packages: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} + globals@15.15.0: + resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==} + engines: {node: '>=18'} + globals@17.6.0: resolution: {integrity: sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==} engines: {node: '>=18'} @@ -4073,6 +4455,9 @@ packages: knitwork@1.3.0: resolution: {integrity: sha512-4LqMNoONzR43B1W0ek0fhXMsDNW/zxa1NdFAVMY+k28pgZLovR4G3PB5MrpTxCy1QaZCqNoiaKPr5w5qZHfSNw==} + kolorist@1.8.0: + resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} + launch-editor@2.14.1: resolution: {integrity: sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA==} @@ -4084,6 +4469,80 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -4134,6 +4593,9 @@ packages: resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} + magic-regexp@0.10.0: + resolution: {integrity: sha512-Uly1Bu4lO1hwHUW0CQeSWuRtzCMNO00CmXtS8N6fyvB3B979GOEEeAkiTUDsmbYLAbvpUS/Kt5c4ibosAzVyVg==} + magic-regexp@0.11.0: resolution: {integrity: sha512-LG77Z/gVnwz7oaDpD4heX6ryl+lcr4l1B2gnP4MMvt2pGhGC1Dfj7dl1pXpP4ih+VQFLuAadeKVa+lARAzfW+Q==} @@ -4439,6 +4901,9 @@ packages: package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + pako@0.2.9: + resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -4755,6 +5220,10 @@ packages: readdir-glob@1.1.3: resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==} + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + readdirp@5.0.0: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} @@ -4783,6 +5252,11 @@ packages: resolution: {integrity: sha512-NgRBy2Nx/bE+9F27nVHnqcN5HjyLmecqsqx2PJHu3/IEtADD4WuxuXIVExD5PoSDFVrl78dOonfcOe5O+5nbzQ==} hasBin: true + reka-ui@2.6.0: + resolution: {integrity: sha512-NrGMKrABD97l890mFS3TNUzB0BLUfbL3hh0NjcJRIUSUljb288bx3Mzo31nOyUcdiiW0HqFGXJwyCBh9cWgb0w==} + peerDependencies: + vue: '>= 3.2.0' + reserved-identifiers@1.2.0: resolution: {integrity: sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==} engines: {node: '>=18'} @@ -4807,6 +5281,9 @@ packages: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} + restructure@3.0.2: + resolution: {integrity: sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==} + retry@0.12.0: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} @@ -5090,6 +5567,26 @@ packages: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} + tailwind-merge@3.6.0: + resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} + + tailwind-variants@3.2.2: + resolution: {integrity: sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==} + engines: {node: '>=16.x', pnpm: '>=7.x'} + peerDependencies: + tailwind-merge: '>=3.0.0' + tailwindcss: '*' + peerDependenciesMeta: + tailwind-merge: + optional: true + + tailwindcss@4.3.1: + resolution: {integrity: sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + tar-stream@3.2.0: resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==} @@ -5108,6 +5605,9 @@ packages: text-decoder@1.2.7: resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + tiny-inflate@1.0.3: + resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} @@ -5214,6 +5714,12 @@ packages: unhead@2.1.15: resolution: {integrity: sha512-MCt5T90mCWyr3Z6pUCdM9lVRXoMoVBlL7z7U4CYVIiaDiuzad/UCfLuMqz5MeNmpZUgoBCQnrucJimU7EZR+XA==} + unicode-properties@1.4.1: + resolution: {integrity: sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==} + + unicode-trie@2.0.0: + resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==} + unicorn-magic@0.3.0: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} @@ -5222,6 +5728,13 @@ packages: resolution: {integrity: sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==} engines: {node: '>=20'} + unifont@0.4.1: + resolution: {integrity: sha512-zKSY9qO8svWYns+FGKjyVdLvpGPwqmsCjeJLN1xndMiqxHWBAhoWDMYMG960MxeV48clBmG+fDP59dHY1VoZvg==} + + unimport@5.7.0: + resolution: {integrity: sha512-njnL6sp8lEA8QQbZrt+52p/g4X0rw3bnGGmUcJnt1jeG8+iiqO779aGz0PirCtydAIVcuTBRlJ52F0u46z309Q==} + engines: {node: '>=18.12.0'} + unimport@6.3.0: resolution: {integrity: sha512-M+Dxk5W9WRd+8j56W9tp8lGW/dmMc7g5zj7BWQnEjKQhryBstqsi1V0izb0zHwSkEN8cSYV7K75/bykairV2tA==} engines: {node: '>=18.12.0'} @@ -5234,10 +5747,35 @@ packages: rolldown: optional: true + unplugin-auto-import@20.3.0: + resolution: {integrity: sha512-RcSEQiVv7g0mLMMXibYVKk8mpteKxvyffGuDKqZZiFr7Oq3PB1HwgHdK5O7H4AzbhzHoVKG0NnMnsk/1HIVYzQ==} + engines: {node: '>=14'} + peerDependencies: + '@nuxt/kit': ^4.0.0 + '@vueuse/core': '*' + peerDependenciesMeta: + '@nuxt/kit': + optional: true + '@vueuse/core': + optional: true + unplugin-utils@0.3.1: resolution: {integrity: sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==} engines: {node: '>=20.19.0'} + unplugin-vue-components@30.0.0: + resolution: {integrity: sha512-4qVE/lwCgmdPTp6h0qsRN2u642tt4boBQtcpn4wQcWZAsr8TQwq+SPT3NDu/6kBFxzo/sSEK4ioXhOOBrXc3iw==} + engines: {node: '>=14'} + peerDependencies: + '@babel/parser': ^7.15.8 + '@nuxt/kit': ^3.2.2 || ^4.0.0 + vue: 2 || 3 + peerDependenciesMeta: + '@babel/parser': + optional: true + '@nuxt/kit': + optional: true + unplugin@2.3.11: resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} engines: {node: '>=18.12.0'} @@ -5348,6 +5886,12 @@ packages: typescript: optional: true + vaul-vue@0.4.1: + resolution: {integrity: sha512-A6jOWOZX5yvyo1qMn7IveoWN91mJI5L3BUKsIwkg6qrTGgHs1Sb1JF/vyLJgnbN1rH4OOOxFbtqL9A46bOyGUQ==} + peerDependencies: + reka-ui: ^2.0.0 + vue: ^3.3.0 + vite-dev-rpc@2.0.0: resolution: {integrity: sha512-yKwbTwdHKSD2k/aGqyWpPHepo45OQc8lH3/6IfT4ZqeKE26ooKvi4WIEKzqWav8v+9Is8u1k8q54hvOmqASazA==} peerDependencies: @@ -5517,8 +6061,22 @@ packages: vue-bundle-renderer@2.2.0: resolution: {integrity: sha512-sz/0WEdYH1KfaOm0XaBmRZOWgYTEvUDt6yPYaUzl4E52qzgWLlknaPPTTZmp6benaPTlQAI/hN1x3tAzZygycg==} - vue-devtools-stub@0.1.0: - resolution: {integrity: sha512-RutnB7X8c5hjq39NceArgXg28WZtZpGc3+J16ljMiYnFhKvd8hITxSWQSQ5bvldxMDU6gG5mkxl1MTQLXckVSQ==} + vue-component-type-helpers@3.3.5: + resolution: {integrity: sha512-Fe1jyPJoUGpJOYKOri44jduR7My4yYINOMJISuMAbmrs+L5LbIDUc8NTWZYY3EJLK0yPLuCmcd5zoCsE4k2/KA==} + + vue-demi@0.14.10: + resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} + engines: {node: '>=12'} + hasBin: true + peerDependencies: + '@vue/composition-api': ^1.0.0-rc.1 + vue: ^3.0.0-0 || ^2.6.0 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + + vue-devtools-stub@0.1.0: + resolution: {integrity: sha512-RutnB7X8c5hjq39NceArgXg28WZtZpGc3+J16ljMiYnFhKvd8hITxSWQSQ5bvldxMDU6gG5mkxl1MTQLXckVSQ==} vue-eslint-parser@10.4.1: resolution: {integrity: sha512-Gk6gRDj0n/fkRa3C3l0bBheoBckUq/Rs0F/TvMWIS6nzzx67amAViMe9CkNgsP2tXyQONvGiHQESHwFtZ3aYDA==} @@ -5567,6 +6125,10 @@ packages: whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + wheel-gestures@2.2.48: + resolution: {integrity: sha512-f+Gy33Oa5Z14XY9679Zze+7VFhbsQfBFXodnU2x589l4kxGM9L5Y8zETTmcMR5pWOPQyRv4Z0lNax6xCO0NSlA==} + engines: {node: '>=18'} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -5676,11 +6238,15 @@ packages: snapshots: + '@alloc/quick-lru@5.2.0': {} + '@antfu/install-pkg@1.1.0': dependencies: package-manager-detector: 1.6.0 tinyexec: 1.2.4 + '@antfu/utils@8.1.1': {} + '@apidevtools/json-schema-ref-parser@14.2.1(@types/json-schema@7.0.15)': dependencies: '@types/json-schema': 7.0.15 @@ -5880,6 +6446,16 @@ snapshots: cac: 6.7.14 citty: 0.2.2 + '@capsizecss/metrics@3.7.0': {} + + '@capsizecss/unpack@2.4.0': + dependencies: + blob-to-buffer: 1.2.9 + cross-fetch: 3.2.0 + fontkit: 2.0.4 + transitivePeerDependencies: + - encoding + '@clack/core@1.4.1': dependencies: fast-wrap-ansi: 0.2.2 @@ -6413,6 +6989,26 @@ snapshots: - '@types/node' - rxjs + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + + '@floating-ui/utils@0.2.11': {} + + '@floating-ui/vue@1.1.11(vue@3.5.38(typescript@5.9.3))': + dependencies: + '@floating-ui/dom': 1.7.6 + '@floating-ui/utils': 0.2.11 + vue-demi: 0.14.10(vue@3.5.38(typescript@5.9.3)) + transitivePeerDependencies: + - '@vue/composition-api' + - vue + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -6429,6 +7025,30 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@iconify/collections@1.0.698': + dependencies: + '@iconify/types': 2.0.0 + + '@iconify/types@2.0.0': {} + + '@iconify/utils@2.3.0': + dependencies: + '@antfu/install-pkg': 1.1.0 + '@antfu/utils': 8.1.1 + '@iconify/types': 2.0.0 + debug: 4.4.3 + globals: 15.15.0 + kolorist: 1.8.0 + local-pkg: 1.2.1 + mlly: 1.8.2 + transitivePeerDependencies: + - supports-color + + '@iconify/vue@5.0.1(vue@3.5.38(typescript@5.9.3))': + dependencies: + '@iconify/types': 2.0.0 + vue: 3.5.38(typescript@5.9.3) + '@inquirer/ansi@1.0.2': {} '@inquirer/checkbox@4.3.2(@types/node@26.0.0)': @@ -6554,6 +7174,14 @@ snapshots: optionalDependencies: '@types/node': 26.0.0 + '@internationalized/date@3.12.2': + dependencies: + '@swc/helpers': 0.5.23 + + '@internationalized/number@3.6.7': + dependencies: + '@swc/helpers': 0.5.23 + '@ioredis/commands@1.10.0': {} '@isaacs/cliui@8.0.2': @@ -6673,11 +7301,19 @@ snapshots: '@nuxt/devalue@2.0.2': {} - '@nuxt/devtools-kit@3.2.4(magicast@0.5.3)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))': + '@nuxt/devtools-kit@2.7.0(magicast@0.5.3)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))': + dependencies: + '@nuxt/kit': 3.21.8(magicast@0.5.3) + execa: 8.0.1 + vite: 7.3.5(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + transitivePeerDependencies: + - magicast + + '@nuxt/devtools-kit@3.2.4(magicast@0.5.3)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@nuxt/kit': 4.4.8(magicast@0.5.3) execa: 8.0.1 - vite: 7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 7.3.5(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) transitivePeerDependencies: - magicast @@ -6692,9 +7328,9 @@ snapshots: pkg-types: 2.3.1 semver: 7.8.4 - '@nuxt/devtools@3.2.4(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue@3.5.38(typescript@5.9.3))': + '@nuxt/devtools@3.2.4(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue@3.5.38(typescript@5.9.3))': dependencies: - '@nuxt/devtools-kit': 3.2.4(magicast@0.5.3)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) + '@nuxt/devtools-kit': 3.2.4(magicast@0.5.3)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) '@nuxt/devtools-wizard': 3.2.4 '@nuxt/kit': 4.4.8(magicast@0.5.3) '@vue/devtools-core': 8.1.3(vue@3.5.38(typescript@5.9.3)) @@ -6722,9 +7358,9 @@ snapshots: sirv: 3.0.2 structured-clone-es: 2.0.0 tinyglobby: 0.2.17 - vite: 7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) - vite-plugin-inspect: 11.4.1(@nuxt/kit@4.4.8(magicast@0.5.3))(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) - vite-plugin-vue-tracer: 1.4.0(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue@3.5.38(typescript@5.9.3)) + vite: 7.3.5(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + vite-plugin-inspect: 11.4.1(@nuxt/kit@4.4.8(magicast@0.5.3))(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) + vite-plugin-vue-tracer: 1.4.0(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue@3.5.38(typescript@5.9.3)) which: 6.0.1 ws: 8.21.0 transitivePeerDependencies: @@ -6773,10 +7409,10 @@ snapshots: - supports-color - typescript - '@nuxt/eslint@1.16.0(@typescript-eslint/utils@8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(@vue/compiler-sfc@3.5.38)(crossws@0.4.6(srvx@0.11.16))(eslint@9.39.4(jiti@2.7.0))(magicast@0.5.3)(oxc-parser@0.133.0)(typescript@5.9.3)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))': + '@nuxt/eslint@1.16.0(@typescript-eslint/utils@8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(@vue/compiler-sfc@3.5.38)(crossws@0.4.6(srvx@0.11.16))(eslint@9.39.4(jiti@2.7.0))(magicast@0.5.3)(oxc-parser@0.133.0)(typescript@5.9.3)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@eslint/config-inspector': 3.0.4(crossws@0.4.6(srvx@0.11.16))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) - '@nuxt/devtools-kit': 3.2.4(magicast@0.5.3)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) + '@nuxt/devtools-kit': 3.2.4(magicast@0.5.3)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) '@nuxt/eslint-config': 1.16.0(@typescript-eslint/utils@8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(@vue/compiler-sfc@3.5.38)(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) '@nuxt/eslint-plugin': 1.16.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) '@nuxt/kit': 4.4.8(magicast@0.5.3) @@ -6805,6 +7441,100 @@ snapshots: - utf-8-validate - vite + '@nuxt/fonts@0.11.4(db0@0.3.4(drizzle-orm@0.45.2(mysql2@3.22.5(@types/node@26.0.0)))(mysql2@3.22.5(@types/node@26.0.0)))(ioredis@5.11.1)(magicast@0.5.3)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))': + dependencies: + '@nuxt/devtools-kit': 2.7.0(magicast@0.5.3)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) + '@nuxt/kit': 3.21.8(magicast@0.5.3) + consola: 3.4.2 + css-tree: 3.2.1 + defu: 6.1.7 + esbuild: 0.25.12 + fontaine: 0.6.0 + h3: 1.15.11 + jiti: 2.7.0 + magic-regexp: 0.10.0 + magic-string: 0.30.21 + node-fetch-native: 1.6.7 + ohash: 2.0.11 + pathe: 2.0.3 + sirv: 3.0.2 + tinyglobby: 0.2.17 + ufo: 1.6.4 + unifont: 0.4.1 + unplugin: 2.3.11 + unstorage: 1.17.5(db0@0.3.4(drizzle-orm@0.45.2(mysql2@3.22.5(@types/node@26.0.0)))(mysql2@3.22.5(@types/node@26.0.0)))(ioredis@5.11.1) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - db0 + - encoding + - idb-keyval + - ioredis + - magicast + - uploadthing + - vite + + '@nuxt/icon@1.15.0(magicast@0.5.3)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue@3.5.38(typescript@5.9.3))': + dependencies: + '@iconify/collections': 1.0.698 + '@iconify/types': 2.0.0 + '@iconify/utils': 2.3.0 + '@iconify/vue': 5.0.1(vue@3.5.38(typescript@5.9.3)) + '@nuxt/devtools-kit': 2.7.0(magicast@0.5.3)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) + '@nuxt/kit': 3.21.8(magicast@0.5.3) + consola: 3.4.2 + local-pkg: 1.2.1 + mlly: 1.8.2 + ohash: 2.0.11 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 3.10.0 + tinyglobby: 0.2.17 + transitivePeerDependencies: + - magicast + - supports-color + - vite + - vue + + '@nuxt/kit@3.21.8(magicast@0.5.3)': + dependencies: + c12: 3.3.4(magicast@0.5.3) + consola: 3.4.2 + defu: 6.1.7 + destr: 2.0.5 + errx: 0.1.0 + exsolve: 1.0.8 + ignore: 7.0.5 + jiti: 2.7.0 + klona: 2.0.6 + knitwork: 1.3.0 + mlly: 1.8.2 + ohash: 2.0.11 + pathe: 2.0.3 + pkg-types: 2.3.1 + rc9: 3.0.1 + scule: 1.3.0 + semver: 7.8.4 + tinyglobby: 0.2.17 + ufo: 1.6.4 + unctx: 2.5.0 + untyped: 2.0.0 + transitivePeerDependencies: + - magicast + '@nuxt/kit@4.4.8(magicast@0.5.3)': dependencies: c12: 3.3.4(magicast@0.5.3) @@ -6830,7 +7560,7 @@ snapshots: transitivePeerDependencies: - magicast - '@nuxt/nitro-server@4.4.8(1b5c28adc25fe6ce6d0a573a7ed46af3)': + '@nuxt/nitro-server@4.4.8(7fe189c16e98a45928ac5916be367033)': dependencies: '@nuxt/devalue': 2.0.2 '@nuxt/kit': 4.4.8(magicast@0.5.3) @@ -6848,7 +7578,7 @@ snapshots: klona: 2.0.6 mocked-exports: 0.1.1 nitropack: 2.13.4(drizzle-orm@0.45.2(mysql2@3.22.5(@types/node@26.0.0)))(mysql2@3.22.5(@types/node@26.0.0))(oxc-parser@0.133.0)(srvx@0.11.16) - nuxt: 4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.0.0)(@vue/compiler-sfc@3.5.38)(cac@6.7.14)(db0@0.3.4(drizzle-orm@0.45.2(mysql2@3.22.5(@types/node@26.0.0)))(mysql2@3.22.5(@types/node@26.0.0)))(drizzle-orm@0.45.2(mysql2@3.22.5(@types/node@26.0.0)))(eslint@9.39.4(jiti@2.7.0))(ioredis@5.11.1)(magicast@0.5.3)(mysql2@3.22.5(@types/node@26.0.0))(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.62.0))(rollup@4.62.0)(srvx@0.11.16)(terser@5.48.0)(tsx@4.22.4)(typescript@5.9.3)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue-tsc@3.3.5(typescript@5.9.3))(yaml@2.9.0) + nuxt: 4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.0.0)(@vue/compiler-sfc@3.5.38)(cac@6.7.14)(db0@0.3.4(drizzle-orm@0.45.2(mysql2@3.22.5(@types/node@26.0.0)))(mysql2@3.22.5(@types/node@26.0.0)))(drizzle-orm@0.45.2(mysql2@3.22.5(@types/node@26.0.0)))(eslint@9.39.4(jiti@2.7.0))(ioredis@5.11.1)(lightningcss@1.32.0)(magicast@0.5.3)(mysql2@3.22.5(@types/node@26.0.0))(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.62.0))(rollup@4.62.0)(srvx@0.11.16)(terser@5.48.0)(tsx@4.22.4)(typescript@5.9.3)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue-tsc@3.3.5(typescript@5.9.3))(yaml@2.9.0) nypm: 0.6.7 ohash: 2.0.11 pathe: 2.0.3 @@ -6917,12 +7647,100 @@ snapshots: rc9: 3.0.1 std-env: 4.1.0 - '@nuxt/vite-builder@4.4.8(01bbe2f9941e737db0270589d7ae39d6)': + '@nuxt/ui@3.3.7(@babel/parser@7.29.7)(change-case@5.4.4)(db0@0.3.4(drizzle-orm@0.45.2(mysql2@3.22.5(@types/node@26.0.0)))(mysql2@3.22.5(@types/node@26.0.0)))(embla-carousel@8.6.0)(ioredis@5.11.1)(magicast@0.5.3)(typescript@5.9.3)(valibot@1.4.1(typescript@5.9.3))(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue@3.5.38(typescript@5.9.3))(zod@4.4.3)': + dependencies: + '@iconify/vue': 5.0.1(vue@3.5.38(typescript@5.9.3)) + '@internationalized/date': 3.12.2 + '@internationalized/number': 3.6.7 + '@nuxt/fonts': 0.11.4(db0@0.3.4(drizzle-orm@0.45.2(mysql2@3.22.5(@types/node@26.0.0)))(mysql2@3.22.5(@types/node@26.0.0)))(ioredis@5.11.1)(magicast@0.5.3)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) + '@nuxt/icon': 1.15.0(magicast@0.5.3)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue@3.5.38(typescript@5.9.3)) + '@nuxt/kit': 4.4.8(magicast@0.5.3) + '@nuxt/schema': 4.4.8 + '@nuxtjs/color-mode': 3.5.2(magicast@0.5.3) + '@standard-schema/spec': 1.1.0 + '@tailwindcss/postcss': 4.3.1 + '@tailwindcss/vite': 4.3.1(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) + '@tanstack/vue-table': 8.21.3(vue@3.5.38(typescript@5.9.3)) + '@unhead/vue': 2.1.15(vue@3.5.38(typescript@5.9.3)) + '@vueuse/core': 13.9.0(vue@3.5.38(typescript@5.9.3)) + '@vueuse/integrations': 13.9.0(change-case@5.4.4)(fuse.js@7.4.2)(vue@3.5.38(typescript@5.9.3)) + colortranslator: 5.0.0 + consola: 3.4.2 + defu: 6.1.7 + embla-carousel-auto-height: 8.6.0(embla-carousel@8.6.0) + embla-carousel-auto-scroll: 8.6.0(embla-carousel@8.6.0) + embla-carousel-autoplay: 8.6.0(embla-carousel@8.6.0) + embla-carousel-class-names: 8.6.0(embla-carousel@8.6.0) + embla-carousel-fade: 8.6.0(embla-carousel@8.6.0) + embla-carousel-vue: 8.6.0(vue@3.5.38(typescript@5.9.3)) + embla-carousel-wheel-gestures: 8.1.0(embla-carousel@8.6.0) + fuse.js: 7.4.2 + hookable: 5.5.3 + knitwork: 1.3.0 + magic-string: 0.30.21 + mlly: 1.8.2 + ohash: 2.0.11 + pathe: 2.0.3 + reka-ui: 2.6.0(typescript@5.9.3)(vue@3.5.38(typescript@5.9.3)) + scule: 1.3.0 + tailwind-merge: 3.6.0 + tailwind-variants: 3.2.2(tailwind-merge@3.6.0)(tailwindcss@4.3.1) + tailwindcss: 4.3.1 + tinyglobby: 0.2.17 + typescript: 5.9.3 + unplugin: 2.3.11 + unplugin-auto-import: 20.3.0(@nuxt/kit@4.4.8(magicast@0.5.3))(@vueuse/core@13.9.0(vue@3.5.38(typescript@5.9.3))) + unplugin-vue-components: 30.0.0(@babel/parser@7.29.7)(@nuxt/kit@4.4.8(magicast@0.5.3))(vue@3.5.38(typescript@5.9.3)) + vaul-vue: 0.4.1(reka-ui@2.6.0(typescript@5.9.3)(vue@3.5.38(typescript@5.9.3)))(vue@3.5.38(typescript@5.9.3)) + vue-component-type-helpers: 3.3.5 + optionalDependencies: + valibot: 1.4.1(typescript@5.9.3) + zod: 4.4.3 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@babel/parser' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - '@vue/composition-api' + - async-validator + - aws4fetch + - axios + - change-case + - db0 + - drauu + - embla-carousel + - encoding + - focus-trap + - idb-keyval + - ioredis + - jwt-decode + - magicast + - nprogress + - qrcode + - sortablejs + - supports-color + - universal-cookie + - uploadthing + - vite + - vue + + '@nuxt/vite-builder@4.4.8(957579b3386442f5cff9d94aaf5aa519)': dependencies: '@nuxt/kit': 4.4.8(magicast@0.5.3) '@rollup/plugin-replace': 6.0.3(rollup@4.62.0) - '@vitejs/plugin-vue': 6.0.7(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue@3.5.38(typescript@5.9.3)) - '@vitejs/plugin-vue-jsx': 5.1.5(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue@3.5.38(typescript@5.9.3)) + '@vitejs/plugin-vue': 6.0.7(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue@3.5.38(typescript@5.9.3)) + '@vitejs/plugin-vue-jsx': 5.1.5(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue@3.5.38(typescript@5.9.3)) autoprefixer: 10.5.0(postcss@8.5.15) consola: 3.4.2 cssnano: 8.0.2(postcss@8.5.15) @@ -6935,7 +7753,7 @@ snapshots: magic-string: 0.30.21 mlly: 1.8.2 mocked-exports: 0.1.1 - nuxt: 4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.0.0)(@vue/compiler-sfc@3.5.38)(cac@6.7.14)(db0@0.3.4(drizzle-orm@0.45.2(mysql2@3.22.5(@types/node@26.0.0)))(mysql2@3.22.5(@types/node@26.0.0)))(drizzle-orm@0.45.2(mysql2@3.22.5(@types/node@26.0.0)))(eslint@9.39.4(jiti@2.7.0))(ioredis@5.11.1)(magicast@0.5.3)(mysql2@3.22.5(@types/node@26.0.0))(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.62.0))(rollup@4.62.0)(srvx@0.11.16)(terser@5.48.0)(tsx@4.22.4)(typescript@5.9.3)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue-tsc@3.3.5(typescript@5.9.3))(yaml@2.9.0) + nuxt: 4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.0.0)(@vue/compiler-sfc@3.5.38)(cac@6.7.14)(db0@0.3.4(drizzle-orm@0.45.2(mysql2@3.22.5(@types/node@26.0.0)))(mysql2@3.22.5(@types/node@26.0.0)))(drizzle-orm@0.45.2(mysql2@3.22.5(@types/node@26.0.0)))(eslint@9.39.4(jiti@2.7.0))(ioredis@5.11.1)(lightningcss@1.32.0)(magicast@0.5.3)(mysql2@3.22.5(@types/node@26.0.0))(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.62.0))(rollup@4.62.0)(srvx@0.11.16)(terser@5.48.0)(tsx@4.22.4)(typescript@5.9.3)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue-tsc@3.3.5(typescript@5.9.3))(yaml@2.9.0) nypm: 0.6.7 pathe: 2.0.3 pkg-types: 2.3.1 @@ -6944,9 +7762,9 @@ snapshots: std-env: 4.1.0 ufo: 1.6.4 unenv: 2.0.0-rc.24 - vite: 7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) - vite-node: 5.3.0(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) - vite-plugin-checker: 0.14.4(eslint@9.39.4(jiti@2.7.0))(optionator@0.9.4)(typescript@5.9.3)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue-tsc@3.3.5(typescript@5.9.3)) + vite: 7.3.5(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + vite-node: 5.3.0(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + vite-plugin-checker: 0.14.4(eslint@9.39.4(jiti@2.7.0))(optionator@0.9.4)(typescript@5.9.3)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue-tsc@3.3.5(typescript@5.9.3)) vue: 3.5.38(typescript@5.9.3) vue-bundle-renderer: 2.2.0 optionalDependencies: @@ -6975,6 +7793,15 @@ snapshots: - vue-tsc - yaml + '@nuxtjs/color-mode@3.5.2(magicast@0.5.3)': + dependencies: + '@nuxt/kit': 3.21.8(magicast@0.5.3) + pathe: 1.1.2 + pkg-types: 1.3.1 + semver: 7.8.4 + transitivePeerDependencies: + - magicast + '@orpc/client@1.14.6': dependencies: '@orpc/shared': 1.14.6 @@ -7581,6 +8408,100 @@ snapshots: estraverse: 5.3.0 picomatch: 4.0.4 + '@swc/helpers@0.5.23': + dependencies: + tslib: 2.8.1 + + '@tailwindcss/node@4.3.1': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.21.6 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.1 + + '@tailwindcss/oxide-android-arm64@4.3.1': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.1': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.1': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.1': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.1': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.1': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.1': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.1': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.1': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.1': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.1': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.1': + optional: true + + '@tailwindcss/oxide@4.3.1': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.1 + '@tailwindcss/oxide-darwin-arm64': 4.3.1 + '@tailwindcss/oxide-darwin-x64': 4.3.1 + '@tailwindcss/oxide-freebsd-x64': 4.3.1 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.1 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.1 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.1 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.1 + '@tailwindcss/oxide-linux-x64-musl': 4.3.1 + '@tailwindcss/oxide-wasm32-wasi': 4.3.1 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.1 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.1 + + '@tailwindcss/postcss@4.3.1': + dependencies: + '@alloc/quick-lru': 5.2.0 + '@tailwindcss/node': 4.3.1 + '@tailwindcss/oxide': 4.3.1 + postcss: 8.5.15 + tailwindcss: 4.3.1 + + '@tailwindcss/vite@4.3.1(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))': + dependencies: + '@tailwindcss/node': 4.3.1 + '@tailwindcss/oxide': 4.3.1 + tailwindcss: 4.3.1 + vite: 7.3.5(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + + '@tanstack/table-core@8.21.3': {} + + '@tanstack/virtual-core@3.17.1': {} + + '@tanstack/vue-table@8.21.3(vue@3.5.38(typescript@5.9.3))': + dependencies: + '@tanstack/table-core': 8.21.3 + vue: 3.5.38(typescript@5.9.3) + + '@tanstack/vue-virtual@3.13.29(vue@3.5.38(typescript@5.9.3))': + dependencies: + '@tanstack/virtual-core': 3.17.1 + vue: 3.5.38(typescript@5.9.3) + '@tybys/wasm-util@0.10.2': dependencies: tslib: 2.8.1 @@ -7605,6 +8526,10 @@ snapshots: '@types/resolve@1.20.2': {} + '@types/web-bluetooth@0.0.20': {} + + '@types/web-bluetooth@0.0.21': {} + '@typescript-eslint/eslint-plugin@8.61.1(@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -7795,22 +8720,22 @@ snapshots: - rollup - supports-color - '@vitejs/plugin-vue-jsx@5.1.5(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue@3.5.38(typescript@5.9.3))': + '@vitejs/plugin-vue-jsx@5.1.5(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue@3.5.38(typescript@5.9.3))': dependencies: '@babel/core': 7.29.7 '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7) '@rolldown/pluginutils': 1.0.1 '@vue/babel-plugin-jsx': 2.0.1(@babel/core@7.29.7) - vite: 7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 7.3.5(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) vue: 3.5.38(typescript@5.9.3) transitivePeerDependencies: - supports-color - '@vitejs/plugin-vue@6.0.7(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue@3.5.38(typescript@5.9.3))': + '@vitejs/plugin-vue@6.0.7(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue@3.5.38(typescript@5.9.3))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 7.3.5(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) vue: 3.5.38(typescript@5.9.3) '@vitest/expect@2.1.9': @@ -7820,13 +8745,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 1.2.0 - '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@26.0.0)(terser@5.48.0))': + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@26.0.0)(lightningcss@1.32.0)(terser@5.48.0))': dependencies: '@vitest/spy': 2.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 5.4.21(@types/node@26.0.0)(terser@5.48.0) + vite: 5.4.21(@types/node@26.0.0)(lightningcss@1.32.0)(terser@5.48.0) '@vitest/pretty-format@2.1.9': dependencies: @@ -7987,6 +8912,64 @@ snapshots: '@vue/shared@3.5.38': {} + '@vueuse/core@10.11.1(vue@3.5.38(typescript@5.9.3))': + dependencies: + '@types/web-bluetooth': 0.0.20 + '@vueuse/metadata': 10.11.1 + '@vueuse/shared': 10.11.1(vue@3.5.38(typescript@5.9.3)) + vue-demi: 0.14.10(vue@3.5.38(typescript@5.9.3)) + transitivePeerDependencies: + - '@vue/composition-api' + - vue + + '@vueuse/core@12.8.2(typescript@5.9.3)': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 12.8.2 + '@vueuse/shared': 12.8.2(typescript@5.9.3) + vue: 3.5.38(typescript@5.9.3) + transitivePeerDependencies: + - typescript + + '@vueuse/core@13.9.0(vue@3.5.38(typescript@5.9.3))': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 13.9.0 + '@vueuse/shared': 13.9.0(vue@3.5.38(typescript@5.9.3)) + vue: 3.5.38(typescript@5.9.3) + + '@vueuse/integrations@13.9.0(change-case@5.4.4)(fuse.js@7.4.2)(vue@3.5.38(typescript@5.9.3))': + dependencies: + '@vueuse/core': 13.9.0(vue@3.5.38(typescript@5.9.3)) + '@vueuse/shared': 13.9.0(vue@3.5.38(typescript@5.9.3)) + vue: 3.5.38(typescript@5.9.3) + optionalDependencies: + change-case: 5.4.4 + fuse.js: 7.4.2 + + '@vueuse/metadata@10.11.1': {} + + '@vueuse/metadata@12.8.2': {} + + '@vueuse/metadata@13.9.0': {} + + '@vueuse/shared@10.11.1(vue@3.5.38(typescript@5.9.3))': + dependencies: + vue-demi: 0.14.10(vue@3.5.38(typescript@5.9.3)) + transitivePeerDependencies: + - '@vue/composition-api' + - vue + + '@vueuse/shared@12.8.2(typescript@5.9.3)': + dependencies: + vue: 3.5.38(typescript@5.9.3) + transitivePeerDependencies: + - typescript + + '@vueuse/shared@13.9.0(vue@3.5.38(typescript@5.9.3))': + dependencies: + vue: 3.5.38(typescript@5.9.3) + abbrev@3.0.1: {} abort-controller@3.0.0: @@ -8059,6 +9042,10 @@ snapshots: argparse@2.0.1: {} + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + assertion-error@2.0.1: {} ast-kit@2.2.0: @@ -8138,6 +9125,8 @@ snapshots: birpc@4.0.0: {} + blob-to-buffer@1.2.9: {} + boolbase@1.0.0: {} brace-expansion@1.1.15: @@ -8157,6 +9146,10 @@ snapshots: dependencies: fill-range: 7.1.1 + brotli@1.3.3: + dependencies: + base64-js: 1.5.1 + browserslist@4.28.2: dependencies: baseline-browser-mapping: 2.10.38 @@ -8241,6 +9234,10 @@ snapshots: check-error@2.1.3: {} + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + chokidar@5.0.0: dependencies: readdirp: 5.0.0 @@ -8269,6 +9266,8 @@ snapshots: strip-ansi: 7.2.0 wrap-ansi: 9.0.2 + clone@2.1.2: {} + cluster-key-slot@1.1.1: {} color-convert@2.0.1: @@ -8277,6 +9276,8 @@ snapshots: color-name@1.1.4: {} + colortranslator@5.0.0: {} + commander@11.1.0: {} commander@14.0.3: {} @@ -8330,6 +9331,12 @@ snapshots: croner@10.0.1: {} + cross-fetch@3.2.0: + dependencies: + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -8470,6 +9477,8 @@ snapshots: - typescript - utf-8-validate + dfa@1.2.0: {} + diff@8.0.4: {} dom-serializer@2.0.0: @@ -8526,6 +9535,43 @@ snapshots: electron-to-chromium@1.5.376: {} + embla-carousel-auto-height@8.6.0(embla-carousel@8.6.0): + dependencies: + embla-carousel: 8.6.0 + + embla-carousel-auto-scroll@8.6.0(embla-carousel@8.6.0): + dependencies: + embla-carousel: 8.6.0 + + embla-carousel-autoplay@8.6.0(embla-carousel@8.6.0): + dependencies: + embla-carousel: 8.6.0 + + embla-carousel-class-names@8.6.0(embla-carousel@8.6.0): + dependencies: + embla-carousel: 8.6.0 + + embla-carousel-fade@8.6.0(embla-carousel@8.6.0): + dependencies: + embla-carousel: 8.6.0 + + embla-carousel-reactive-utils@8.6.0(embla-carousel@8.6.0): + dependencies: + embla-carousel: 8.6.0 + + embla-carousel-vue@8.6.0(vue@3.5.38(typescript@5.9.3)): + dependencies: + embla-carousel: 8.6.0 + embla-carousel-reactive-utils: 8.6.0(embla-carousel@8.6.0) + vue: 3.5.38(typescript@5.9.3) + + embla-carousel-wheel-gestures@8.1.0(embla-carousel@8.6.0): + dependencies: + embla-carousel: 8.6.0 + wheel-gestures: 2.2.48 + + embla-carousel@8.6.0: {} + emoji-regex@10.6.0: {} emoji-regex@8.0.0: {} @@ -8534,6 +9580,11 @@ snapshots: encodeurl@2.0.0: {} + enhanced-resolve@5.21.6: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + entities@4.5.0: {} entities@7.0.1: {} @@ -9000,6 +10051,31 @@ snapshots: flatted@3.4.2: {} + fontaine@0.6.0: + dependencies: + '@capsizecss/metrics': 3.7.0 + '@capsizecss/unpack': 2.4.0 + css-tree: 3.2.1 + magic-regexp: 0.10.0 + magic-string: 0.30.21 + pathe: 2.0.3 + ufo: 1.6.4 + unplugin: 2.3.11 + transitivePeerDependencies: + - encoding + + fontkit@2.0.4: + dependencies: + '@swc/helpers': 0.5.23 + brotli: 1.3.3 + clone: 2.1.2 + dfa: 1.2.0 + fast-deep-equal: 3.1.3 + restructure: 3.0.2 + tiny-inflate: 1.0.3 + unicode-properties: 1.4.1 + unicode-trie: 2.0.0 + foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 @@ -9085,6 +10161,8 @@ snapshots: globals@14.0.0: {} + globals@15.15.0: {} + globals@17.6.0: {} globby@16.2.0: @@ -9309,6 +10387,8 @@ snapshots: knitwork@1.3.0: {} + kolorist@1.8.0: {} + launch-editor@2.14.1: dependencies: picocolors: 1.1.1 @@ -9323,6 +10403,55 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + lilconfig@3.1.3: {} listhen@1.10.0(srvx@0.11.16): @@ -9385,6 +10514,16 @@ snapshots: lru.min@1.1.4: {} + magic-regexp@0.10.0: + dependencies: + estree-walker: 3.0.3 + magic-string: 0.30.21 + mlly: 1.8.2 + regexp-tree: 0.1.27 + type-level-regexp: 0.1.17 + ufo: 1.6.4 + unplugin: 2.3.11 + magic-regexp@0.11.0: dependencies: magic-string: 0.30.21 @@ -9655,16 +10794,16 @@ snapshots: dependencies: boolbase: 1.0.0 - nuxt@4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.0.0)(@vue/compiler-sfc@3.5.38)(cac@6.7.14)(db0@0.3.4(drizzle-orm@0.45.2(mysql2@3.22.5(@types/node@26.0.0)))(mysql2@3.22.5(@types/node@26.0.0)))(drizzle-orm@0.45.2(mysql2@3.22.5(@types/node@26.0.0)))(eslint@9.39.4(jiti@2.7.0))(ioredis@5.11.1)(magicast@0.5.3)(mysql2@3.22.5(@types/node@26.0.0))(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.62.0))(rollup@4.62.0)(srvx@0.11.16)(terser@5.48.0)(tsx@4.22.4)(typescript@5.9.3)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue-tsc@3.3.5(typescript@5.9.3))(yaml@2.9.0): + nuxt@4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.0.0)(@vue/compiler-sfc@3.5.38)(cac@6.7.14)(db0@0.3.4(drizzle-orm@0.45.2(mysql2@3.22.5(@types/node@26.0.0)))(mysql2@3.22.5(@types/node@26.0.0)))(drizzle-orm@0.45.2(mysql2@3.22.5(@types/node@26.0.0)))(eslint@9.39.4(jiti@2.7.0))(ioredis@5.11.1)(lightningcss@1.32.0)(magicast@0.5.3)(mysql2@3.22.5(@types/node@26.0.0))(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.62.0))(rollup@4.62.0)(srvx@0.11.16)(terser@5.48.0)(tsx@4.22.4)(typescript@5.9.3)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue-tsc@3.3.5(typescript@5.9.3))(yaml@2.9.0): dependencies: '@dxup/nuxt': 0.4.1(magicast@0.5.3)(typescript@5.9.3) '@nuxt/cli': 3.35.2(@nuxt/schema@4.4.8)(cac@6.7.14)(magicast@0.5.3) - '@nuxt/devtools': 3.2.4(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue@3.5.38(typescript@5.9.3)) + '@nuxt/devtools': 3.2.4(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue@3.5.38(typescript@5.9.3)) '@nuxt/kit': 4.4.8(magicast@0.5.3) - '@nuxt/nitro-server': 4.4.8(1b5c28adc25fe6ce6d0a573a7ed46af3) + '@nuxt/nitro-server': 4.4.8(7fe189c16e98a45928ac5916be367033) '@nuxt/schema': 4.4.8 '@nuxt/telemetry': 2.8.0(@nuxt/kit@4.4.8(magicast@0.5.3)) - '@nuxt/vite-builder': 4.4.8(01bbe2f9941e737db0270589d7ae39d6) + '@nuxt/vite-builder': 4.4.8(957579b3386442f5cff9d94aaf5aa519) '@unhead/vue': 2.1.15(vue@3.5.38(typescript@5.9.3)) '@vue/shared': 3.5.38 chokidar: 5.0.0 @@ -9712,7 +10851,7 @@ snapshots: unrouting: 0.1.7 untyped: 2.0.0 vue: 3.5.38(typescript@5.9.3) - vue-router: 5.1.0(@vue/compiler-sfc@3.5.38)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue@3.5.38(typescript@5.9.3)) + vue-router: 5.1.0(@vue/compiler-sfc@3.5.38)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue@3.5.38(typescript@5.9.3)) optionalDependencies: '@parcel/watcher': 2.5.6 '@types/node': 26.0.0 @@ -9974,6 +11113,8 @@ snapshots: package-manager-detector@1.6.0: {} + pako@0.2.9: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -10261,6 +11402,8 @@ snapshots: dependencies: minimatch: 5.1.9 + readdirp@4.1.2: {} + readdirp@5.0.0: {} redis-errors@1.2.0: {} @@ -10284,6 +11427,23 @@ snapshots: dependencies: jsesc: 3.1.0 + reka-ui@2.6.0(typescript@5.9.3)(vue@3.5.38(typescript@5.9.3)): + dependencies: + '@floating-ui/dom': 1.7.6 + '@floating-ui/vue': 1.1.11(vue@3.5.38(typescript@5.9.3)) + '@internationalized/date': 3.12.2 + '@internationalized/number': 3.6.7 + '@tanstack/vue-virtual': 3.13.29(vue@3.5.38(typescript@5.9.3)) + '@vueuse/core': 12.8.2(typescript@5.9.3) + '@vueuse/shared': 12.8.2(typescript@5.9.3) + aria-hidden: 1.2.6 + defu: 6.1.7 + ohash: 2.0.11 + vue: 3.5.38(typescript@5.9.3) + transitivePeerDependencies: + - '@vue/composition-api' + - typescript + reserved-identifiers@1.2.0: {} resolve-from@4.0.0: {} @@ -10304,6 +11464,8 @@ snapshots: onetime: 7.0.0 signal-exit: 4.1.0 + restructure@3.0.2: {} + retry@0.12.0: {} reusify@1.1.0: {} @@ -10600,6 +11762,18 @@ snapshots: tagged-tag@1.0.0: {} + tailwind-merge@3.6.0: {} + + tailwind-variants@3.2.2(tailwind-merge@3.6.0)(tailwindcss@4.3.1): + dependencies: + tailwindcss: 4.3.1 + optionalDependencies: + tailwind-merge: 3.6.0 + + tailwindcss@4.3.1: {} + + tapable@2.3.3: {} + tar-stream@3.2.0: dependencies: b4a: 1.8.1 @@ -10639,6 +11813,8 @@ snapshots: transitivePeerDependencies: - react-native-b4a + tiny-inflate@1.0.3: {} + tiny-invariant@1.3.3: {} tinybench@2.9.0: {} @@ -10679,8 +11855,7 @@ snapshots: dependencies: typescript: 5.9.3 - tslib@2.8.1: - optional: true + tslib@2.8.1: {} tsx@4.22.4: dependencies: @@ -10726,10 +11901,42 @@ snapshots: dependencies: hookable: 6.1.1 + unicode-properties@1.4.1: + dependencies: + base64-js: 1.5.1 + unicode-trie: 2.0.0 + + unicode-trie@2.0.0: + dependencies: + pako: 0.2.9 + tiny-inflate: 1.0.3 + unicorn-magic@0.3.0: {} unicorn-magic@0.4.0: {} + unifont@0.4.1: + dependencies: + css-tree: 3.2.1 + ohash: 2.0.11 + + unimport@5.7.0: + dependencies: + acorn: 8.17.0 + escape-string-regexp: 5.0.0 + estree-walker: 3.0.3 + local-pkg: 1.2.1 + magic-string: 0.30.21 + mlly: 1.8.2 + pathe: 2.0.3 + picomatch: 4.0.4 + pkg-types: 2.3.1 + scule: 1.3.0 + strip-literal: 3.1.0 + tinyglobby: 0.2.17 + unplugin: 2.3.11 + unplugin-utils: 0.3.1 + unimport@6.3.0(oxc-parser@0.133.0): dependencies: acorn: 8.17.0 @@ -10749,11 +11956,40 @@ snapshots: optionalDependencies: oxc-parser: 0.133.0 + unplugin-auto-import@20.3.0(@nuxt/kit@4.4.8(magicast@0.5.3))(@vueuse/core@13.9.0(vue@3.5.38(typescript@5.9.3))): + dependencies: + local-pkg: 1.2.1 + magic-string: 0.30.21 + picomatch: 4.0.4 + unimport: 5.7.0 + unplugin: 2.3.11 + unplugin-utils: 0.3.1 + optionalDependencies: + '@nuxt/kit': 4.4.8(magicast@0.5.3) + '@vueuse/core': 13.9.0(vue@3.5.38(typescript@5.9.3)) + unplugin-utils@0.3.1: dependencies: pathe: 2.0.3 picomatch: 4.0.4 + unplugin-vue-components@30.0.0(@babel/parser@7.29.7)(@nuxt/kit@4.4.8(magicast@0.5.3))(vue@3.5.38(typescript@5.9.3)): + dependencies: + chokidar: 4.0.3 + debug: 4.4.3 + local-pkg: 1.2.1 + magic-string: 0.30.21 + mlly: 1.8.2 + tinyglobby: 0.2.17 + unplugin: 2.3.11 + unplugin-utils: 0.3.1 + vue: 3.5.38(typescript@5.9.3) + optionalDependencies: + '@babel/parser': 7.29.7 + '@nuxt/kit': 4.4.8(magicast@0.5.3) + transitivePeerDependencies: + - supports-color + unplugin@2.3.11: dependencies: '@jridgewell/remapping': 2.3.5 @@ -10854,23 +12090,31 @@ snapshots: optionalDependencies: typescript: 5.9.3 - vite-dev-rpc@2.0.0(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)): + vaul-vue@0.4.1(reka-ui@2.6.0(typescript@5.9.3)(vue@3.5.38(typescript@5.9.3)))(vue@3.5.38(typescript@5.9.3)): + dependencies: + '@vueuse/core': 10.11.1(vue@3.5.38(typescript@5.9.3)) + reka-ui: 2.6.0(typescript@5.9.3)(vue@3.5.38(typescript@5.9.3)) + vue: 3.5.38(typescript@5.9.3) + transitivePeerDependencies: + - '@vue/composition-api' + + vite-dev-rpc@2.0.0(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: birpc: 4.0.0 - vite: 7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) - vite-hot-client: 2.2.0(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) + vite: 7.3.5(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + vite-hot-client: 2.2.0(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) - vite-hot-client@2.2.0(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)): + vite-hot-client@2.2.0(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: - vite: 7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 7.3.5(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) - vite-node@2.1.9(@types/node@26.0.0)(terser@5.48.0): + vite-node@2.1.9(@types/node@26.0.0)(lightningcss@1.32.0)(terser@5.48.0): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 1.1.2 - vite: 5.4.21(@types/node@26.0.0)(terser@5.48.0) + vite: 5.4.21(@types/node@26.0.0)(lightningcss@1.32.0)(terser@5.48.0) transitivePeerDependencies: - '@types/node' - less @@ -10882,13 +12126,13 @@ snapshots: - supports-color - terser - vite-node@5.3.0(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0): + vite-node@5.3.0(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0): dependencies: cac: 6.7.14 es-module-lexer: 2.1.0 obug: 2.1.3 pathe: 2.0.3 - vite: 7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 7.3.5(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) transitivePeerDependencies: - '@types/node' - jiti @@ -10902,7 +12146,7 @@ snapshots: - tsx - yaml - vite-plugin-checker@0.14.4(eslint@9.39.4(jiti@2.7.0))(optionator@0.9.4)(typescript@5.9.3)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue-tsc@3.3.5(typescript@5.9.3)): + vite-plugin-checker@0.14.4(eslint@9.39.4(jiti@2.7.0))(optionator@0.9.4)(typescript@5.9.3)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue-tsc@3.3.5(typescript@5.9.3)): dependencies: '@babel/code-frame': 7.29.7 chokidar: 5.0.0 @@ -10911,14 +12155,14 @@ snapshots: picomatch: 4.0.4 proper-lockfile: 4.1.2 tiny-invariant: 1.3.3 - vite: 7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 7.3.5(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) optionalDependencies: eslint: 9.39.4(jiti@2.7.0) optionator: 0.9.4 typescript: 5.9.3 vue-tsc: 3.3.5(typescript@5.9.3) - vite-plugin-inspect@11.4.1(@nuxt/kit@4.4.8(magicast@0.5.3))(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)): + vite-plugin-inspect@11.4.1(@nuxt/kit@4.4.8(magicast@0.5.3))(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: ansis: 4.3.1 error-stack-parser-es: 1.0.5 @@ -10928,22 +12172,22 @@ snapshots: perfect-debounce: 2.1.0 sirv: 3.0.2 unplugin-utils: 0.3.1 - vite: 7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) - vite-dev-rpc: 2.0.0(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) + vite: 7.3.5(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + vite-dev-rpc: 2.0.0(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) optionalDependencies: '@nuxt/kit': 4.4.8(magicast@0.5.3) - vite-plugin-vue-tracer@1.4.0(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue@3.5.38(typescript@5.9.3)): + vite-plugin-vue-tracer@1.4.0(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue@3.5.38(typescript@5.9.3)): dependencies: estree-walker: 3.0.3 exsolve: 1.0.8 magic-string: 0.30.21 pathe: 2.0.3 source-map-js: 1.2.1 - vite: 7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 7.3.5(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) vue: 3.5.38(typescript@5.9.3) - vite@5.4.21(@types/node@26.0.0)(terser@5.48.0): + vite@5.4.21(@types/node@26.0.0)(lightningcss@1.32.0)(terser@5.48.0): dependencies: esbuild: 0.21.5 postcss: 8.5.15 @@ -10951,9 +12195,10 @@ snapshots: optionalDependencies: '@types/node': 26.0.0 fsevents: 2.3.3 + lightningcss: 1.32.0 terser: 5.48.0 - vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0): + vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0): dependencies: esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.4) @@ -10965,14 +12210,15 @@ snapshots: '@types/node': 26.0.0 fsevents: 2.3.3 jiti: 2.7.0 + lightningcss: 1.32.0 terser: 5.48.0 tsx: 4.22.4 yaml: 2.9.0 - vitest@2.1.9(@types/node@26.0.0)(terser@5.48.0): + vitest@2.1.9(@types/node@26.0.0)(lightningcss@1.32.0)(terser@5.48.0): dependencies: '@vitest/expect': 2.1.9 - '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@26.0.0)(terser@5.48.0)) + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@26.0.0)(lightningcss@1.32.0)(terser@5.48.0)) '@vitest/pretty-format': 2.1.9 '@vitest/runner': 2.1.9 '@vitest/snapshot': 2.1.9 @@ -10988,8 +12234,8 @@ snapshots: tinyexec: 0.3.2 tinypool: 1.1.1 tinyrainbow: 1.2.0 - vite: 5.4.21(@types/node@26.0.0)(terser@5.48.0) - vite-node: 2.1.9(@types/node@26.0.0)(terser@5.48.0) + vite: 5.4.21(@types/node@26.0.0)(lightningcss@1.32.0)(terser@5.48.0) + vite-node: 2.1.9(@types/node@26.0.0)(lightningcss@1.32.0)(terser@5.48.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 26.0.0 @@ -11010,6 +12256,12 @@ snapshots: dependencies: ufo: 1.6.4 + vue-component-type-helpers@3.3.5: {} + + vue-demi@0.14.10(vue@3.5.38(typescript@5.9.3)): + dependencies: + vue: 3.5.38(typescript@5.9.3) + vue-devtools-stub@0.1.0: {} vue-eslint-parser@10.4.1(eslint@9.39.4(jiti@2.7.0)): @@ -11024,7 +12276,7 @@ snapshots: transitivePeerDependencies: - supports-color - vue-router@5.1.0(@vue/compiler-sfc@3.5.38)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue@3.5.38(typescript@5.9.3)): + vue-router@5.1.0(@vue/compiler-sfc@3.5.38)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue@3.5.38(typescript@5.9.3)): dependencies: '@babel/generator': 8.0.0 '@vue-macros/common': 3.1.2(vue@3.5.38(typescript@5.9.3)) @@ -11046,7 +12298,7 @@ snapshots: yaml: 2.9.0 optionalDependencies: '@vue/compiler-sfc': 3.5.38 - vite: 7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 7.3.5(@types/node@26.0.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) vue-tsc@3.3.5(typescript@5.9.3): dependencies: @@ -11073,6 +12325,8 @@ snapshots: tr46: 0.0.3 webidl-conversions: 3.0.1 + wheel-gestures@2.2.48: {} + which@2.0.2: dependencies: isexe: 2.0.0 From 83dfc656e3b6eb31a61e02b7bbf0ed4c9383738f Mon Sep 17 00:00:00 2001 From: kaitoyama Date: Sun, 21 Jun 2026 07:04:36 +0900 Subject: [PATCH 06/30] fix: Jomon payout adapter to real API (payee by traQ ID) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix-jomon-payout (OpenSpec): correct the Jomon adapter to the real traPtitech/Jomon API and resolve payees via users.traq_id. - List approved via GET /api/applications (v1 ?current_state=accepted, v2 ?status=approved); drop the fictional /transfer-requests. - Payee = traQ ID: v1 repaid_to_user.trap_id; v2 ApplicationTarget.target (User UUID) resolved through GET /api/users → User.name. Strict zod parsing (fail-loud, no field defaulting). currency hardcoded jpy. - jomon_ref: v2 = target.id; v1 = `${appId}:${trapId}`. Skip already repaid_at / paid_at items. - Payee resolution now getUserByTraqId (was deriveMailHash(email)); unresolved traQ ID → pending/要対応, no transfer. - Write-back: v1 PUT .../states/repaid/{trapId}; v2 unsupported (JomonWriteBackUnsupportedError) → kept paid, jomon_written_back_at NULL, retried, never re-transfers (Jomon v2 needs a per-payee endpoint). - Batch resilience (Codex): list-fetch error returns summary.listError instead of aborting; v1 per-application detail failure is logged+skipped. Notes: real Jomon has only traQ OAuth/cookie auth (no Bearer receiver) and no v2 write-back — live v1/v2 needs Jomon-side additions; default driver stays stub. Codex-reviewed (0 critical/high). Delta synced (payout-execution MODIFIED) and archived. Gates green: lint, typecheck, build, test (111). Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 20 +- .../.openspec.yaml | 2 + .../2026-06-21-fix-jomon-payout/design.md | 63 ++++ .../2026-06-21-fix-jomon-payout/proposal.md | 38 ++ .../specs/payout-execution/spec.md | 58 +++ .../2026-06-21-fix-jomon-payout/tasks.md | 23 ++ openspec/specs/payout-execution/spec.md | 42 ++- packages/api/src/jomon/http.ts | 346 ++++++++++++------ packages/api/src/jomon/jomon.test.ts | 207 +++++++++-- packages/api/src/jomon/stub.ts | 9 +- packages/api/src/jomon/types.ts | 16 +- packages/api/src/payouts/execute.test.ts | 131 +++++-- packages/api/src/payouts/execute.ts | 103 ++++-- packages/api/src/router.ts | 3 +- 14 files changed, 828 insertions(+), 233 deletions(-) create mode 100644 openspec/changes/archive/2026-06-21-fix-jomon-payout/.openspec.yaml create mode 100644 openspec/changes/archive/2026-06-21-fix-jomon-payout/design.md create mode 100644 openspec/changes/archive/2026-06-21-fix-jomon-payout/proposal.md create mode 100644 openspec/changes/archive/2026-06-21-fix-jomon-payout/specs/payout-execution/spec.md create mode 100644 openspec/changes/archive/2026-06-21-fix-jomon-payout/tasks.md diff --git a/.env.example b/.env.example index 423423f..616c3bf 100644 --- a/.env.example +++ b/.env.example @@ -63,16 +63,28 @@ INVOICE_DAYS_UNTIL_DUE="7" # adapter is lazy, so non-payout requests work without it). STRIPE_CONNECT_WEBHOOK_SECRET="" -# --- Payout execution / Jomon integration (OpenSpec: add-payout-execution) --- +# --- Payout execution / Jomon integration (OpenSpec: add-payout-execution, +# corrected in fix-jomon-payout) --- # Jomon API base URL and Bearer service token (Checkin -> Jomon, one-way). Only -# needed for the live drivers (v1/v2); leave empty for stub (the default). +# the live drivers (v1/v2) use these; leave empty for the stub (the default). +# NOTE: live Jomon currently has ONLY traQ OAuth/cookie auth and NO Bearer +# receiver, so the token is forward-looking — a real connection requires a +# Jomon-side addition before v1/v2 can be used end-to-end. JOMON_API_BASE_URL="" JOMON_API_TOKEN="" -# Jomon driver: "stub" (in-memory, dev/tests), "v1" or "v2" (live HTTP). The -# stub is functionally complete and key-free, so payout dev works without Jomon. +# Jomon driver: "stub" (in-memory, dev/tests; the DEFAULT), "v1" or "v2" (live +# HTTP against the real traPtitech/Jomon API). The stub is functionally complete +# and key-free, so payout dev works without Jomon. The live drivers read approved +# requests from GET /api/applications (v1 ?current_state=accepted / +# v2 ?status=approved) and resolve the payee by traQ ID (v1 trap_id; v2 resolves +# the target UUID via GET /api/users). Write-back: v1 marks repaid via +# PUT /api/applications/{id}/states/repaid/{trapId}; v2 has NO write-back API yet +# (paid_at is read-only) so write-back is UNSUPPORTED and awaits a Jomon addition +# (the payout still settles as paid locally and the write-back is retried later). JOMON_API_VERSION="stub" # Default payout currency (smallest unit; jpy has no minor unit so whole yen). +# Jomon carries no currency, so live requests are always jpy regardless. PAYOUT_CURRENCY="jpy" diff --git a/openspec/changes/archive/2026-06-21-fix-jomon-payout/.openspec.yaml b/openspec/changes/archive/2026-06-21-fix-jomon-payout/.openspec.yaml new file mode 100644 index 0000000..18edba1 --- /dev/null +++ b/openspec/changes/archive/2026-06-21-fix-jomon-payout/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-20 diff --git a/openspec/changes/archive/2026-06-21-fix-jomon-payout/design.md b/openspec/changes/archive/2026-06-21-fix-jomon-payout/design.md new file mode 100644 index 0000000..6c6ad53 --- /dev/null +++ b/openspec/changes/archive/2026-06-21-fix-jomon-payout/design.md @@ -0,0 +1,63 @@ +## Context + +[[add-payout-execution]] の Jomon アダプタは仮実装。実 `traPtitech/Jomon` API 調査の結果に合わせ、[[add-traq-member-auth]] の `users.traq_id`/`getUserByTraqId` を使って payee を解決する。payout-execution の MODIFIED。 + +## Goals / Non-Goals + +**Goals:** +- Jomon アダプタを実 API(`/api/applications`、payee=traQ ID、通貨 jpy、v1/v2 形状)に修正。 +- 本人特定を `getUserByTraqId` に変更。 +- v2 書き戻し未対応を明示(失敗+警告、paid 保持、Jomon 追加待ち)。 + +**Non-Goals:** +- Jomon リポジトリ側の Bearer 受け口・v2 書き戻し API 実装。UI。実接続 E2E(要 Jomon 調整)。 + +## Decisions + +### D1. DTO(`packages/api/src/jomon/types.ts`) + +`JomonTransferRequest = { jomonRef: string, payeeTraqId: string, amount: number, currency: string }`(旧 `payeeEmail` を `payeeTraqId` に置換)。`currency` は常に `'jpy'`。 + +### D2. v1 クライアント(`JomonV1Client`) + +- 一覧: `GET /api/applications?current_state=accepted` → `CompactApplication[]`。compact には per-payee 情報が無いため、各 `application_id` について `GET /api/applications/{id}` → `DetailedApplication.repayment_logs[]`(`repaid_to_user.trap_id`, `repaid_at`)を取得。未払い(`repaid_at` 無し)の payee のみ対象。 +- 正規化: 各 (application, trap_id) → `{ jomonRef: '${applicationId}:${trapId}', payeeTraqId: trapId, amount, currency: 'jpy' }`。amount は v1 では application 単位(`ApplicationLog.amount`)。複数 payee に分割が要る場合の扱いは TODO(当面は単一 payee 想定でそのまま、複数時は要確認)。 +- 書き戻し: `PUT /api/applications/{applicationId}/states/repaid/{trapId}` body `{ repaid_at: 'YYYY-MM-DD' }`。`jomonRef` から `applicationId`/`trapId` を復元。 + +### D3. v2 クライアント(`JomonV2Client`) + +- 一覧: `GET /api/applications?status=approved` → `Application[]` の `targets`(`ApplicationTarget{ id, amount, target(uuid), paid_at }`)。`paid_at` が無い target のみ対象。 +- payee 解決: `GET /api/users` を一度取得し `id(uuid)→name` のマップを作り、`target` を traQ ユーザー名に解決。 +- 正規化: 各 target → `{ jomonRef: target.id, payeeTraqId: userName, amount: target.amount, currency: 'jpy' }`。 +- 書き戻し: **未対応**。`writeBackResult` は明示エラー(`JomonWriteBackUnsupportedError` 等)を投げる。orchestration はこれを捕捉し警告ログ、payout の `paid` は保持、`jomon_written_back_at` は未設定のまま(将来 Jomon が追加したら成功するようになる)。 + +### D4. 厳格バリデーション(`jomon/http.ts`) + +zod スキーマで v1/v2 のレスポンスを検証。payee(trap_id / 解決後 name)非空、jomonRef 非空、amount 正の整数を要求。欠落・不正は throw(既定値で埋めない=誤送金防止)。実フィールド名には `// TODO: confirm against live Jomon v1/v2` を残す。 + +### D5. 本人特定(`packages/api/src/payouts/execute.ts`) + +`deriveMailHash(payeeEmail)` を廃し、`getUserByTraqId(db, req.payeeTraqId)` で解決。未解決は `payouts.userId=null`・`pending`(要対応)。userId 不変(既存ロジック維持)。 + +### D6. 認証注記 + +`JomonHttpClient` は `Authorization: Bearer ${JOMON_API_TOKEN}` を送るが、Jomon に Bearer 受け口が無いため実接続は Jomon 側追加待ち。`.env.example` の Jomon コメントを実態に更新(Bearer 未実装/v2 書き戻し未対応/default stub)。 + +## Risks / Trade-offs + +- **実 Jomon スキーマの細部未確認** → Mitigation: zod 厳格化+TODO 明示。fail-loud で誤送金回避。stub default。 +- **v1 の amount が application 単位**(payee 分割が要る場合)→ Mitigation: 当面単一 payee 前提、複数時は TODO。実接続前に要確認。 +- **v2 書き戻し不可** → Mitigation: 明示エラー+警告、paid 保持、再試行余地。Jomon 追加で自動解消。 +- **Bearer 受け口が Jomon 未実装** → Mitigation: 実接続は要調整(design §9 と整合)。stub で機械は完結。 + +## Migration Plan + +1. `jomon/types.ts`(payeeTraqId)→ `jomon/stub.ts` → `jomon/http.ts`(v1/v2 実 API・厳格化・write-back)→ `payouts/execute.ts`(getUserByTraqId)。 +2. ユニット更新(payee 解決 traQ ID、v1/v2 パース、v2 write-back 未対応、stub フロー)。 +3. `.env.example` コメント更新。 +4. 実接続 E2E は Jomon 側(Bearer 受け口・v2 書き戻し)整備後。 + +## Open Questions + +- v1 で 1 application に複数 payee(`repaid_to_id` 複数)の場合の amount 分配(実接続前に Jomon 仕様で確定)。 +- v2 の `GET /api/users` は全件取得。規模次第でページング/キャッシュ(当面は都度取得)。 diff --git a/openspec/changes/archive/2026-06-21-fix-jomon-payout/proposal.md b/openspec/changes/archive/2026-06-21-fix-jomon-payout/proposal.md new file mode 100644 index 0000000..f0b3255 --- /dev/null +++ b/openspec/changes/archive/2026-06-21-fix-jomon-payout/proposal.md @@ -0,0 +1,38 @@ +## Why + +[[add-payout-execution]] の Jomon アダプタは仮実装(`/transfer-requests`、payee=メール)で、実 `traPtitech/Jomon` API と不一致だった。実 API 調査で確定した形に合わせ、[[add-traq-member-auth]] で用意した `users.traq_id` を使って payee を本人解決できるようにする。 + +実 Jomon の要点(調査結果): +- 振込依頼=`Application`+`ApplicationTarget`。承認済み取得は **`GET /api/applications`**(v1 `?current_state=accepted` / v2 `?status=approved`)。`/transfer-requests` は存在しない。 +- payee 識別子=**traQ ID**。v1 は `repaid_to_user.trap_id`(文字列)、v2 は `ApplicationTarget.target`(User UUID)→ `GET /api/users` の `User.name` で traQ ユーザー名に解決。 +- 通貨なし(JPY 固定)。 +- 書き戻し: v1 は `PUT /api/applications/{id}/states/repaid/{trapId}` body `{repaid_at}`。**v2 は per-payee の paid 設定 API が無い**(`paid_at` 読み取り専用)→ Jomon v2 側に追加が必要。 +- 認証: v1/v2 とも traQ OAuth/cookie のみで、**Bearer サービストークン受け口は未実装**(要 Jomon 調整)。 + +## What Changes + +- **Jomon アダプタを実 API へ修正**(payout-execution の MODIFIED): + - エンドポイントを `/api/applications`(v1 `current_state=accepted` / v2 `status=approved`)に。 + - `JomonTransferRequest` の payee を **`payeeTraqId`**(traQ ID 文字列)に変更(旧 `payeeEmail` を置換)。v1 は `trap_id`、v2 は `target` UUID→`/api/users`→`name`。 + - `jomon_ref`: v2 は `ApplicationTarget.id`、v1 は `applicationId + trap_id` の合成。 + - 通貨は `jpy` 固定(Jomon に通貨なし)。 + - 書き戻し: v1 は `PUT .../states/repaid/{trapId}`。**v2 は未対応として明示エラー+警告ログ**(Jomon 側追加待ち。payout の `paid` 状態は保持し、`jomon_written_back_at` は未設定のまま再試行余地を残す)。 +- **本人特定を traQ ID 経由に**(MODIFIED): payee の解決を `deriveMailHash(email)` ではなく **`getUserByTraqId(payeeTraqId)`** で行う。未連結(`users.traq_id` 無し)は送金せず要対応。 +- **認証の注記**: Checkin→Jomon は Bearer(`JOMON_API_TOKEN`)を送る前提のままだが、**Jomon 側に Bearer 受け口が無い**ため実接続は要 Jomon 調整。default は `stub`。 + +明確に**スコープ外**: Jomon リポジトリ側の Bearer 受け口・v2 書き戻し API の実装、UI。 + +## Capabilities + +### New Capabilities + + +### Modified Capabilities +- `payout-execution`: Jomon アダプタの実 API 化(`/api/applications`、payee=traQ ID、v1/v2 形状、通貨 jpy、v2 書き戻し未対応の明示)、本人特定を `users.traq_id`(`getUserByTraqId`)経由に変更。 + +## Impact + +- **API(`packages/api`)**: `jomon/types.ts`(`JomonTransferRequest.payeeTraqId`)、`jomon/http.ts`(`/api/applications`、v1/v2 のパス・フィールド・厳格バリデーション、v2 の `/api/users` 解決、v1/v2 write-back、v2 未対応エラー)、`jomon/stub.ts`(payeeTraqId)。`payouts/execute.ts` の本人特定を `getUserByTraqId` に。 +- **DB**: 変更なし(`users.traq_id` は [[add-traq-member-auth]] で追加済み、`payouts` も既存)。 +- **設定**: 変更なし(`JOMON_*` は既存)。`.env.example` の Jomon コメントを実態(Bearer 受け口は Jomon 未実装/v2 書き戻し未対応)に更新。 +- **テスト**: payee 解決(traQ ID)・v1/v2 パース(厳格)・v2 書き戻し未対応・stub フローのユニット更新。実接続は Jomon 調整後。 diff --git a/openspec/changes/archive/2026-06-21-fix-jomon-payout/specs/payout-execution/spec.md b/openspec/changes/archive/2026-06-21-fix-jomon-payout/specs/payout-execution/spec.md new file mode 100644 index 0000000..aa25a79 --- /dev/null +++ b/openspec/changes/archive/2026-06-21-fix-jomon-payout/specs/payout-execution/spec.md @@ -0,0 +1,58 @@ +## MODIFIED Requirements + +### Requirement: Jomon アダプタ(pull・v1/v2・stub) + +システムは、承認済み振込依頼の取得と結果の書き戻しを `JomonClient` アダプタ越しに行う SHALL。実装は環境設定で `stub` / `v1` / `v2` を選択でき、ドメインは具体実装に依存しては SHALL NOT。連携は pull とする。実 Jomon API に従い: +- 承認済みの取得は **`GET /api/applications`**(v1 は `current_state=accepted`、v2 は `status=approved`)。 +- payee は **traQ ID**。v1 は `repaid_to_user.trap_id`(文字列)、v2 は `ApplicationTarget.target`(User UUID)を `GET /api/users` の `User.name` で traQ ユーザー名に解決 MUST。 +- 通貨は持たないため `jpy` 固定とする。 +- 結果の書き戻しは v1 が `PUT /api/applications/{id}/states/repaid/{trapId}`。**v2 は per-payee の書き戻し API が無い**ため、未対応として明示的に失敗(警告ログ)し、Jomon v2 側の追加を待つ MUST(payout のローカル `paid` 状態は保持する)。 +- Checkin→Jomon は Bearer サービストークン(env、片方向)で送る前提とする。ただし現状の Jomon に Bearer 受け口は無く、実接続は Jomon 側の追加が前提(要調整)。 + +#### Scenario: ドライバを切り替えられる + +- **WHEN** `JOMON_API_VERSION` を `stub` / `v1` / `v2` のいずれかに設定する +- **THEN** 対応する `JomonClient` 実装が使われ、ドメインコードは変更不要 + +#### Scenario: 承認済みを /api/applications から取得 + +- **WHEN** v1/v2 で承認済み振込依頼を取得する +- **THEN** `GET /api/applications`(v1 `current_state=accepted` / v2 `status=approved`)から取得し、payee=traQ ID・amount・通貨 jpy を持つ依頼に正規化される + +#### Scenario: v2 の書き戻しは未対応として扱う + +- **WHEN** v2 ドライバで結果を書き戻そうとする +- **THEN** 未対応として明示的に失敗(警告ログ)し、payout のローカル `paid` 状態は保持される(Jomon v2 側追加待ち) + +### Requirement: 本人特定(対応表 mail_hash)と userId 不変 + +システムは、各振込依頼の受取人を **traQ ID(`users.traq_id`)** で特定 SHALL。Jomon は受取人を traQ ID で渡すため、`getUserByTraqId` で本人行に解決する(`users.traq_id` は認証済み traQ ログインで連結済みのもの)。特定できない(未連結の traQ ID)依頼は送金へ進めず、要対応として記録する MUST。一度 `user_id` が確定した payout は、後続の取込で別人に解決されても**再リンクしては SHALL NOT**(要対応として扱う。誤送金防止)。 + +#### Scenario: 連結済み traQ ID は処理を進める + +- **WHEN** 振込依頼の payee traQ ID が `users.traq_id` で本人行に解決でき、まだ user 未設定 +- **THEN** `user_id` を設定し、onboarding 判定/送金へ進む + +#### Scenario: 未連結 traQ ID は送金しない + +- **WHEN** payee traQ ID がどの本人行にも連結されていない(`users.traq_id` 無し) +- **THEN** 送金は行わず、要対応として記録される + +#### Scenario: 確定済み userId は変えない + +- **WHEN** 既に `user_id` のある payout が、後続取込で別人に解決される +- **THEN** 再リンクせず要対応として扱い、送金しない + +### Requirement: 結果を Jomon に書き戻す(再試行可能・再送金しない) + +システムは、送金結果(送金済み / 失敗)を `JomonClient` を通じて Jomon に書き戻す SHALL。書き戻し成否を記録(`jomon_written_back_at`)し、送金成功済みだが書き戻し未了の payout は、再実行時に**書き戻しのみ再試行**して送金は再実行しては SHALL NOT。v1 は `PUT .../states/repaid/{trapId}` を用いる。**v2 は書き戻し API が無いため未対応として失敗(警告ログ)**し、`jomon_written_back_at` は未設定のまま将来の Jomon 追加に備える(送金の `paid` は確定済みとして保持)。 + +#### Scenario: v1 は送金済みを書き戻す + +- **WHEN** v1 で payout が `paid` になる +- **THEN** `PUT .../states/repaid/{trapId}` で「送金済み(repaid_at)」が書き戻され、書き戻し済みが記録される + +#### Scenario: 書き戻し失敗は再試行され、再送金はしない + +- **WHEN** 送金は成功したが Jomon 書き戻しが失敗(v2 未対応含む)し、後で再実行する +- **THEN** 送金は再実行されず、書き戻しのみ再試行される(v2 は対応追加まで失敗し続けるが二重送金は起きない) diff --git a/openspec/changes/archive/2026-06-21-fix-jomon-payout/tasks.md b/openspec/changes/archive/2026-06-21-fix-jomon-payout/tasks.md new file mode 100644 index 0000000..3da41b4 --- /dev/null +++ b/openspec/changes/archive/2026-06-21-fix-jomon-payout/tasks.md @@ -0,0 +1,23 @@ +## 1. DTO・stub + +- [x] 1.1 `jomon/types.ts`: `JomonTransferRequest` の `payeeEmail` を `payeeTraqId` に置換(currency は jpy 前提) +- [x] 1.2 `jomon/stub.ts`: payeeTraqId に追従(dev/テスト用データ) + +## 2. v1/v2 実 API(jomon/http.ts、厳格バリデーション) + +- [x] 2.1 共通: `GET /api/applications`、`Authorization: Bearer`。zod でレスポンス厳格検証(payee/jomonRef 非空、amount 正整数、不正は throw)。実フィールドに `// TODO: confirm against live Jomon` +- [x] 2.2 v1: 一覧 `?current_state=accepted` → 各 `GET /api/applications/{id}` の `repayment_logs[].repaid_to_user.trap_id`(未払いのみ)→ `{jomonRef:'${appId}:${trapId}', payeeTraqId:trapId, amount, currency:'jpy'}`。write-back `PUT /api/applications/{appId}/states/repaid/{trapId}` `{repaid_at}` +- [x] 2.3 v2: 一覧 `?status=approved` → `targets`(paid_at 無しのみ)、`GET /api/users` で `target`(uuid)→`name` 解決 → `{jomonRef:target.id, payeeTraqId:name, amount:target.amount, currency:'jpy'}`。write-back は未対応エラー(`JomonWriteBackUnsupportedError`) + +## 3. 本人特定の変更(payouts/execute.ts) + +- [x] 3.1 payee 解決を `deriveMailHash(payeeEmail)` から `getUserByTraqId(payeeTraqId)` に変更。未解決は userId=null・pending(要対応) +- [x] 3.2 v2 write-back 未対応エラーを orchestration で捕捉 → 警告ログ、`paid` 保持、`jomon_written_back_at` 未設定のまま(再試行余地)。per-item 分離は維持 + +## 4. 設定・検証 + +- [x] 4.1 `.env.example` の Jomon コメントを実態に更新(Bearer 受け口は Jomon 未実装/v2 書き戻し未対応/default stub) +- [x] 4.2 ユニット: payee 解決(traQ ID 解決/未解決)、v1/v2 厳格パース(正常/不正)、v2 write-back 未対応、StubJomonClient フロー +- [x] 4.3 `pnpm lint` / `pnpm typecheck` / `pnpm build` / `pnpm test` をグリーンに +- [x] 4.5 バッチabort耐性: `processApprovedPayouts` の一覧取得を try/catch で包み失敗時は `listError` 付きサマリを返す(throw しない)。v1 は per-application の詳細取得を try/catch で分離し、失敗アプリは warn ログ+スキップして残りを処理(テスト追加) +- [ ] 4.4 (Jomon の Bearer 受け口+v2 書き戻し追加後)実接続 E2E — **BLOCKED**: 実 Jomon に Bearer 受け口が無く、v2 の per-payee 書き戻し API も未実装のため実接続不可。両方が Jomon 側に追加され次第、`JOMON_API_VERSION=v1/v2` で実施する。 diff --git a/openspec/specs/payout-execution/spec.md b/openspec/specs/payout-execution/spec.md index 61557bc..88f52c7 100644 --- a/openspec/specs/payout-execution/spec.md +++ b/openspec/specs/payout-execution/spec.md @@ -8,17 +8,27 @@ Jomon で承認済みの振込依頼を取り込み、[[connect-onboarding]] で ### Requirement: Jomon アダプタ(pull・v1/v2・stub) -システムは、承認済み振込依頼の取得と結果の書き戻しを `JomonClient` アダプタ越しに行う SHALL。実装は環境設定で `stub` / `v1` / `v2` を選択でき、ドメインは具体実装に依存しては SHALL NOT。Checkin→Jomon の認証は Bearer サービストークン(env、片方向)とする MUST。連携は pull とする。 +システムは、承認済み振込依頼の取得と結果の書き戻しを `JomonClient` アダプタ越しに行う SHALL。実装は環境設定で `stub` / `v1` / `v2` を選択でき、ドメインは具体実装に依存しては SHALL NOT。連携は pull とする。実 Jomon API に従い: +- 承認済みの取得は **`GET /api/applications`**(v1 は `current_state=accepted`、v2 は `status=approved`)。 +- payee は **traQ ID**。v1 は `repaid_to_user.trap_id`(文字列)、v2 は `ApplicationTarget.target`(User UUID)を `GET /api/users` の `User.name` で traQ ユーザー名に解決 MUST。 +- 通貨は持たないため `jpy` 固定とする。 +- 結果の書き戻しは v1 が `PUT /api/applications/{id}/states/repaid/{trapId}`。**v2 は per-payee の書き戻し API が無い**ため、未対応として明示的に失敗(警告ログ)し、Jomon v2 側の追加を待つ MUST(payout のローカル `paid` 状態は保持する)。 +- Checkin→Jomon は Bearer サービストークン(env、片方向)で送る前提とする。ただし現状の Jomon に Bearer 受け口は無く、実接続は Jomon 側の追加が前提(要調整)。 #### Scenario: ドライバを切り替えられる - **WHEN** `JOMON_API_VERSION` を `stub` / `v1` / `v2` のいずれかに設定する - **THEN** 対応する `JomonClient` 実装が使われ、ドメインコードは変更不要 -#### Scenario: 取得と書き戻しの両操作を持つ +#### Scenario: 承認済みを /api/applications から取得 -- **WHEN** 払い戻しフローが Jomon と連携する -- **THEN** 「承認済み振込依頼の取得」と「結果(送金済み/失敗)の書き戻し」が `JomonClient` 経由で行える +- **WHEN** v1/v2 で承認済み振込依頼を取得する +- **THEN** `GET /api/applications`(v1 `current_state=accepted` / v2 `status=approved`)から取得し、payee=traQ ID・amount・通貨 jpy を持つ依頼に正規化される + +#### Scenario: v2 の書き戻しは未対応として扱う + +- **WHEN** v2 ドライバで結果を書き戻そうとする +- **THEN** 未対応として明示的に失敗(警告ログ)し、payout のローカル `paid` 状態は保持される(Jomon v2 側追加待ち) ### Requirement: Jomon レスポンスの厳格バリデーション(fail-safe) @@ -38,18 +48,18 @@ Jomon で承認済みの振込依頼を取り込み、[[connect-onboarding]] で - **WHEN** 同じ `jomon_ref` の依頼を 2 回取り込む - **THEN** `payouts` 行は 1 つのままで、状態は引き継がれる -### Requirement: 本人特定(対応表 mail_hash)と userId 不変 +### Requirement: 本人特定(traQ ID)と userId 不変 -システムは、各振込依頼の受取人を対応表(`mail_hash`)で特定 SHALL。特定できない依頼は送金へ進めず要対応として記録する MUST。一度 `user_id` が確定した payout は、後続の取込で別人に解決されても**再リンクしては SHALL NOT**(要対応として扱う。誤送金防止)。 +システムは、各振込依頼の受取人を **traQ ID(`users.traq_id`)** で特定 SHALL。Jomon は受取人を traQ ID で渡すため、`getUserByTraqId` で本人行に解決する(`users.traq_id` は認証済み traQ ログインで連結済みのもの)。特定できない(未連結の traQ ID)依頼は送金へ進めず、要対応として記録する MUST。一度 `user_id` が確定した payout は、後続の取込で別人に解決されても**再リンクしては SHALL NOT**(要対応として扱う。誤送金防止)。 -#### Scenario: 特定できたら処理を進める +#### Scenario: 連結済み traQ ID は処理を進める -- **WHEN** 振込依頼の受取人が `mail_hash` で `users` 行に解決でき、まだ user 未設定 +- **WHEN** 振込依頼の payee traQ ID が `users.traq_id` で本人行に解決でき、まだ user 未設定 - **THEN** `user_id` を設定し、onboarding 判定/送金へ進む -#### Scenario: 特定できなければ送金しない +#### Scenario: 未連結 traQ ID は送金しない -- **WHEN** 受取人を `mail_hash` で特定できない +- **WHEN** payee traQ ID がどの本人行にも連結されていない(`users.traq_id` 無し) - **THEN** 送金は行わず、要対応として記録される #### Scenario: 確定済み userId は変えない @@ -96,17 +106,17 @@ Jomon で承認済みの振込依頼を取り込み、[[connect-onboarding]] で ### Requirement: 結果を Jomon に書き戻す(再試行可能・再送金しない) -システムは、送金結果(送金済み / 失敗)を `JomonClient` を通じて Jomon に書き戻す SHALL。書き戻し成否を記録(例: `jomon_written_back_at`)し、送金成功済みだが書き戻し未了の payout は、再実行時に**書き戻しのみ再試行**して送金は再実行しては SHALL NOT。 +システムは、送金結果(送金済み / 失敗)を `JomonClient` を通じて Jomon に書き戻す SHALL。書き戻し成否を記録(`jomon_written_back_at`)し、送金成功済みだが書き戻し未了の payout は、再実行時に**書き戻しのみ再試行**して送金は再実行しては SHALL NOT。v1 は `PUT .../states/repaid/{trapId}` を用いる。**v2 は書き戻し API が無いため未対応として失敗(警告ログ)**し、`jomon_written_back_at` は未設定のまま将来の Jomon 追加に備える(送金の `paid` は確定済みとして保持)。 -#### Scenario: 送金済みを書き戻す +#### Scenario: v1 は送金済みを書き戻す -- **WHEN** payout が `paid` になる -- **THEN** Jomon に「送金済み」が書き戻され、書き戻し済みが記録される +- **WHEN** v1 で payout が `paid` になる +- **THEN** `PUT .../states/repaid/{trapId}` で「送金済み(repaid_at)」が書き戻され、書き戻し済みが記録される #### Scenario: 書き戻し失敗は再試行され、再送金はしない -- **WHEN** 送金は成功したが Jomon 書き戻しが失敗し、後で再実行する -- **THEN** 送金は再実行されず、書き戻しのみ再試行される +- **WHEN** 送金は成功したが Jomon 書き戻しが失敗(v2 未対応含む)し、後で再実行する +- **THEN** 送金は再実行されず、書き戻しのみ再試行される(v2 は対応追加まで失敗し続けるが二重送金は起きない) ### Requirement: バッチの個別分離と failed の再試行ポリシー diff --git a/packages/api/src/jomon/http.ts b/packages/api/src/jomon/http.ts index b01fd9c..cb982f9 100644 --- a/packages/api/src/jomon/http.ts +++ b/packages/api/src/jomon/http.ts @@ -2,63 +2,78 @@ import { z } from 'zod' import type { JomonClient, JomonTransferRequest, JomonWriteBackResult } from './types' /** - * Strict zod schema for ONE raw Jomon transfer request (fail-safe parsing). + * Error thrown when a Jomon driver cannot write a settled result back. * - * We REQUIRE every field that a payout depends on and THROW on anything missing, - * empty, zero, negative, or the wrong shape — rather than silently defaulting. - * A wrong field mapping (e.g. the payee identifier under an unexpected key) must - * fail loudly here so it can never quietly pay the wrong person or a zero amount. + * v2 has NO per-payee write-back API (`paid_at` is read-only), so its + * `writeBackResult` throws this. The orchestration catches it, logs a warning, + * KEEPS the local payout `paid`, and leaves `jomon_written_back_at` NULL so the + * write-back is retried once Jomon v2 adds the endpoint — without re-transferring. + * (design D3; spec §v2 の書き戻しは未対応として扱う) + */ +export class JomonWriteBackUnsupportedError extends Error { + constructor(message: string) { + super(message) + this.name = 'JomonWriteBackUnsupportedError' + } +} + +/** Currency Jomon never carries — hardcoded since amounts are always whole yen. */ +const JOMON_CURRENCY = 'jpy' + +/** + * STRICT, fail-safe assembly of one normalized {@link JomonTransferRequest}. * - * TODO: confirm exact v1/v2 field names against live Jomon (design §9). The - * accepted aliases below are best-effort guesses; the payee identifier in - * particular (isct email vs traQ id vs internal id) is unconfirmed. When the - * live schema is known, pin these to the real keys (drop the aliases). + * We REQUIRE every payout-critical field and THROW on anything empty, zero, + * negative, or the wrong shape — never silently defaulting. A wrong field mapping + * (e.g. the payee under an unexpected key) MUST fail loudly here so it can never + * quietly pay the wrong person or a zero amount. (design D4) + * + * `jomonRef`/`payeeTraqId` non-empty, `amount` a positive integer; currency is + * fixed to 'jpy' by the driver, not parsed. */ -const rawTransferRequestSchema = z - .object({ - // jomonRef: the opaque source/idempotency key. Required, non-empty. - jomonRef: z.string().trim().min(1).optional(), - id: z.union([z.string(), z.number()]).optional(), - // payee identifier: required, non-empty. - payeeEmail: z.string().trim().min(1).optional(), - email: z.string().trim().min(1).optional(), - // amount: required, positive integer (smallest currency unit). - amount: z.number().int().positive(), - // currency: required, non-empty ISO code. - currency: z.string().trim().min(1), - }) - .transform((obj, ctx): JomonTransferRequest => { - const jomonRef = obj.jomonRef ?? (obj.id !== undefined ? String(obj.id).trim() : '') - if (!jomonRef) { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'missing jomonRef/id' }) - return z.NEVER - } - const payeeEmail = obj.payeeEmail ?? obj.email ?? '' - if (!payeeEmail) { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'missing payee identifier (payeeEmail/email)' }) - return z.NEVER - } - return { jomonRef, payeeEmail, amount: obj.amount, currency: obj.currency } - }) +const transferRequestSchema = z.object({ + jomonRef: z.string().trim().min(1, 'missing jomonRef'), + payeeTraqId: z.string().trim().min(1, 'missing payeeTraqId'), + amount: z.number().int().positive('amount must be a positive integer'), +}) + +function toTransferRequest(parts: { + jomonRef: string + payeeTraqId: string + amount: number +}): JomonTransferRequest { + const parsed = transferRequestSchema.safeParse(parts) + if (!parsed.success) { + throw new Error(`Jomon transfer request failed validation: ${parsed.error.message}`) + } + return { ...parsed.data, currency: JOMON_CURRENCY } +} -/** A list response: a bare array or `{ data: [...] }`, of raw transfer requests. */ -const listResponseSchema = z.union([ +/** Unwrap a list response: a bare array or a `{ data: [...] }` envelope. */ +const listEnvelopeSchema = z.union([ z.array(z.unknown()), z.object({ data: z.array(z.unknown()) }).transform(o => o.data), ]) +function unwrapList(path: string, body: unknown): unknown[] { + const parsed = listEnvelopeSchema.safeParse(body) + if (!parsed.success) { + throw new Error(`Jomon ${path} returned an unexpected response shape: ${parsed.error.message}`) + } + return parsed.data +} + /** * Shared HTTP plumbing for the live Jomon drivers (`v1` / `v2`). * - * Auth is a one-way Bearer service token (`JOMON_API_TOKEN`); the base URL is - * `JOMON_API_BASE_URL`. The exact Jomon request/response schemas are NOT yet - * confirmed, so this class implements the transport and JSON plumbing and maps - * to/from {@link JomonTransferRequest} with STRICT zod validation that throws on - * any missing/invalid/unknown-shape field (never silently defaults). + * Auth is a one-way Bearer service token (`JOMON_API_TOKEN`) against + * `JOMON_API_BASE_URL`. NOTE: live Jomon currently has only traQ OAuth/cookie + * auth and NO Bearer receiver, so this header is forward-looking — a real + * connection requires a Jomon-side addition (design D6). The transport plus + * STRICT zod validation are shared; the v1/v2 list/normalize/write-back shapes + * diverge in the subclasses. * - * The v1/v2 difference is currently only the endpoint paths; field mapping is - * shared until the live schemas diverge. (design §9 — verify against live Jomon, - * requires Jomon coordination.) + * TODO: confirm field names/paths against live Jomon v1/v2 (design §9). */ export abstract class JomonHttpClient implements JomonClient { constructor( @@ -73,60 +88,22 @@ export abstract class JomonHttpClient implements JomonClient { } } - /** Path of the "list approved transfer requests" endpoint (version-specific). */ - protected abstract listPath(): string - - /** Path of the "write back result" endpoint for a given ref (version-specific). */ - protected abstract writeBackPath(jomonRef: string): string + abstract listApprovedTransferRequests(): Promise + abstract writeBackResult(jomonRef: string, result: JomonWriteBackResult): Promise - async listApprovedTransferRequests(): Promise { - const res = await this.request('GET', this.listPath()) - const body = (await res.json()) as unknown - // TODO: verify field names/paths against live Jomon v1/v2 (design §9). - // Strict, fail-safe parsing: an unexpected envelope shape throws here rather - // than silently yielding an empty list (which would skip real payouts). - const parsedList = listResponseSchema.safeParse(body) - if (!parsedList.success) { - throw new Error( - `Jomon ${this.listPath()} returned an unexpected response shape: ${parsedList.error.message}`, - ) - } - return parsedList.data.map(item => this.toTransferRequest(item)) - } - - async writeBackResult(jomonRef: string, result: JomonWriteBackResult): Promise { - // TODO: verify the write-back path, method, and payload shape against live - // Jomon v1/v2 (design §9). Best-effort: POST the settled result as JSON. - await this.request('POST', this.writeBackPath(jomonRef), { - status: result.status, - stripe_transfer_id: result.stripeTransferId, - message: result.message, - }) - } - - /** - * Map a raw Jomon item to the Jomon-type-free DTO via STRICT zod parsing. - * - * THROWS on any missing/invalid field (empty payee, missing ref, non-positive - * amount, missing currency, wrong shape) so a wrong-field mapping fails loudly - * instead of silently paying the wrong person or a zero amount. - * - * TODO: confirm exact v1/v2 field names against live Jomon (design §9). - */ - protected toTransferRequest(raw: unknown): JomonTransferRequest { - const parsed = rawTransferRequestSchema.safeParse(raw) - if (!parsed.success) { - throw new Error(`Jomon transfer request failed validation: ${parsed.error.message}`) - } - return parsed.data + /** Issue an authenticated request against the Jomon API and return parsed JSON. */ + protected async getJson(path: string): Promise { + const res = await this.request('GET', path) + return res.json() as unknown } /** Issue an authenticated JSON request against the Jomon API. */ - private async request(method: string, path: string, body?: unknown): Promise { + protected async request(method: string, path: string, body?: unknown): Promise { const url = `${this.baseUrl.replace(/\/$/, '')}${path}` const res = await fetch(url, { method, headers: { + // Forward-looking: live Jomon has no Bearer receiver yet (design D6). authorization: `Bearer ${this.token}`, ...(body !== undefined ? { 'content-type': 'application/json' } : {}), }, @@ -139,26 +116,189 @@ export abstract class JomonHttpClient implements JomonClient { } } -/** Live Jomon v1 driver. Endpoint paths are best-effort (design §9). */ +// --- v1 ------------------------------------------------------------------- + +/** + * Strict v1 schemas. The list (`CompactApplication`) lacks per-payee data, so + * each application is re-fetched as `DetailedApplication` for its + * `repayment_logs[]`. `repaid_at` present ⇒ already repaid ⇒ skip. + * + * TODO: confirm field names/paths against live Jomon v1 (design §9). + */ +const v1CompactApplicationSchema = z.object({ + // CompactApplication.application_id — used only to fetch the detailed record. + application_id: z.string().trim().min(1), +}) + +const v1RepaymentLogSchema = z.object({ + repaid_to_user: z.object({ trap_id: z.string().trim().min(1) }), + amount: z.number().int().positive(), + repaid_at: z.string().nullish(), +}) + +const v1DetailedApplicationSchema = z.object({ + application_id: z.string().trim().min(1), + repayment_logs: z.array(v1RepaymentLogSchema), +}) + +/** Live Jomon v1 driver. (design D2 — confirm against live Jomon, §9.) */ export class JomonV1Client extends JomonHttpClient { - // TODO: verify v1 endpoint paths against live Jomon (design §9). - protected listPath(): string { - return '/api/v1/transfer-requests?status=approved' + async listApprovedTransferRequests(): Promise { + // 1. List accepted applications (compact, no per-payee data). + const listPath = '/api/applications?current_state=accepted' + const rawList = unwrapList(listPath, await this.getJson(listPath)) + + const out: JomonTransferRequest[] = [] + for (const rawCompact of rawList) { + const compact = v1CompactApplicationSchema.safeParse(rawCompact) + if (!compact.success) { + throw new Error(`Jomon v1 application list failed validation: ${compact.error.message}`) + } + const appId = compact.data.application_id + + // Per-application isolation: one bad detail fetch/parse must NOT abort + // pulling the rest. On error, warn with the applicationId and SKIP this + // application, continuing with the others — a skipped app moves no money, + // it just isn't processed this run. The zod schema stays STRICT within an + // application: a malformed app becomes a logged skip, never a silent wrong + // payment. (Codex hardening: §batch-abort, per-application level) + try { + // 2. Fetch the detailed application for its repayment_logs[]. + const detailPath = `/api/applications/${encodeURIComponent(appId)}` + const detail = v1DetailedApplicationSchema.safeParse(await this.getJson(detailPath)) + if (!detail.success) { + throw new Error(`Jomon v1 application ${appId} failed validation: ${detail.error.message}`) + } + + // 3. One DTO per still-unpaid (no repaid_at) repayment log. + for (const log of detail.data.repayment_logs) { + if (log.repaid_at) { + continue + } + const trapId = log.repaid_to_user.trap_id + out.push(toTransferRequest({ + jomonRef: `${appId}:${trapId}`, + payeeTraqId: trapId, + amount: log.amount, + })) + } + } + catch (err) { + const reason = err instanceof Error ? err.message : String(err) + console.warn(`[jomon] v1 skipping application ${appId}; detail fetch/parse failed: ${reason}`) + continue + } + } + return out } - protected writeBackPath(jomonRef: string): string { - return `/api/v1/transfer-requests/${encodeURIComponent(jomonRef)}/result` + /** + * v1 write-back: mark a payee repaid on its application. + * `PUT /api/applications/{applicationId}/states/repaid/{trapId}` `{ repaid_at }`. + * The `applicationId`/`trapId` are recovered from `jomonRef` (`${appId}:${trapId}`). + * Only a `paid` result is written back (a `failed` result has nothing to mark). + * + * TODO: confirm path/body against live Jomon v1 (design §9). + */ + async writeBackResult(jomonRef: string, result: JomonWriteBackResult): Promise { + if (result.status !== 'paid') { + // Nothing to mark repaid on a failed payout; v1 has no "failed" state to set. + return + } + const sep = jomonRef.indexOf(':') + if (sep <= 0 || sep >= jomonRef.length - 1) { + throw new Error(`Jomon v1 jomonRef is not '\${appId}:\${trapId}': ${jomonRef}`) + } + const appId = jomonRef.slice(0, sep) + const trapId = jomonRef.slice(sep + 1) + const repaidAt = new Date().toISOString().slice(0, 10) // YYYY-MM-DD + await this.request( + 'PUT', + `/api/applications/${encodeURIComponent(appId)}/states/repaid/${encodeURIComponent(trapId)}`, + { repaid_at: repaidAt }, + ) } } -/** Live Jomon v2 driver. Endpoint paths are best-effort (design §9). */ +// --- v2 ------------------------------------------------------------------- + +/** + * Strict v2 schemas. Approved applications carry `targets` (`ApplicationTarget`); + * `paid_at` present ⇒ already paid ⇒ skip. `target` is a User UUID resolved to a + * traQ username via `GET /api/users`. + * + * TODO: confirm field names/paths against live Jomon v2 (design §9). + */ +const v2ApplicationTargetSchema = z.object({ + id: z.string().trim().min(1), + target: z.string().trim().min(1), + amount: z.number().int().positive(), + paid_at: z.string().nullish(), +}) + +const v2ApplicationSchema = z.object({ + targets: z.array(v2ApplicationTargetSchema), +}) + +const v2UserSchema = z.object({ + id: z.string().trim().min(1), + name: z.string().trim().min(1), +}) + +/** Live Jomon v2 driver. (design D3 — confirm against live Jomon, §9.) */ export class JomonV2Client extends JomonHttpClient { - // TODO: verify v2 endpoint paths against live Jomon (design §9). - protected listPath(): string { - return '/api/v2/transfer-requests?status=approved' + async listApprovedTransferRequests(): Promise { + // 1. Build a uuid -> traQ username map from the users directory. + const usersPath = '/api/users' + const rawUsers = unwrapList(usersPath, await this.getJson(usersPath)) + const uuidToName = new Map() + for (const rawUser of rawUsers) { + const user = v2UserSchema.safeParse(rawUser) + if (!user.success) { + throw new Error(`Jomon v2 user failed validation: ${user.error.message}`) + } + uuidToName.set(user.data.id, user.data.name) + } + + // 2. List approved applications and normalize each unpaid target. + const listPath = '/api/applications?status=approved' + const rawList = unwrapList(listPath, await this.getJson(listPath)) + + const out: JomonTransferRequest[] = [] + for (const rawApp of rawList) { + const app = v2ApplicationSchema.safeParse(rawApp) + if (!app.success) { + throw new Error(`Jomon v2 application failed validation: ${app.error.message}`) + } + for (const target of app.data.targets) { + if (target.paid_at) { + continue + } + const traqId = uuidToName.get(target.target) + if (!traqId) { + throw new Error(`Jomon v2 target ${target.id} references unknown user uuid: ${target.target}`) + } + out.push(toTransferRequest({ + jomonRef: target.id, + payeeTraqId: traqId, + amount: target.amount, + })) + } + } + return out } - protected writeBackPath(jomonRef: string): string { - return `/api/v2/transfer-requests/${encodeURIComponent(jomonRef)}/result` + /** + * v2 has NO per-payee write-back API (`paid_at` is read-only), so this is + * UNSUPPORTED and throws {@link JomonWriteBackUnsupportedError}. The + * orchestration catches it, keeps the payout `paid`, and leaves + * `jomon_written_back_at` NULL for retry once Jomon v2 adds the endpoint. + * (design D3) + */ + async writeBackResult(_jomonRef: string, _result: JomonWriteBackResult): Promise { + throw new JomonWriteBackUnsupportedError( + 'Jomon v2 has no per-payee write-back API (paid_at is read-only); ' + + 'awaiting a Jomon v2 addition (design D3).', + ) } } diff --git a/packages/api/src/jomon/jomon.test.ts b/packages/api/src/jomon/jomon.test.ts index ca44946..4733bee 100644 --- a/packages/api/src/jomon/jomon.test.ts +++ b/packages/api/src/jomon/jomon.test.ts @@ -1,12 +1,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { createJomonClient } from './factory' -import { JomonV1Client } from './http' +import { JomonV1Client, JomonV2Client, JomonWriteBackUnsupportedError } from './http' import { StubJomonClient } from './stub' import type { JomonTransferRequest } from './types' const seed: JomonTransferRequest[] = [ - { jomonRef: 'jmn_1', payeeEmail: 'alice@m.isct.ac.jp', amount: 4000, currency: 'jpy' }, - { jomonRef: 'jmn_2', payeeEmail: 'bob@m.isct.ac.jp', amount: 2000, currency: 'jpy' }, + { jomonRef: 'jmn_1', payeeTraqId: 'alice', amount: 4000, currency: 'jpy' }, + { jomonRef: 'jmn_2', payeeTraqId: 'bob', amount: 2000, currency: 'jpy' }, ] describe('createJomonClient', () => { @@ -57,61 +57,198 @@ describe('StubJomonClient', () => { }) }) -describe('JomonHttpClient strict response validation (fail-safe)', () => { - afterEach(() => { - vi.restoreAllMocks() - }) - - /** Stub global fetch with one JSON `ok` response body. */ - function mockFetch(body: unknown): void { - vi.spyOn(globalThis, 'fetch').mockResolvedValue( +/** + * Queue several JSON `ok` responses for sequential `fetch` calls (v1 lists then + * fetches each application detail; v2 fetches users then applications). + */ +function mockFetchSequence(bodies: unknown[]): void { + const spy = vi.spyOn(globalThis, 'fetch') + for (const body of bodies) { + spy.mockResolvedValueOnce( new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } }), ) } +} + +describe('JomonV1Client (/api/applications, strict, fail-safe)', () => { + afterEach(() => { + vi.restoreAllMocks() + }) const client = new JomonV1Client('https://jomon.test', 'token') - it('parses a valid array response into DTOs', async () => { - mockFetch([ - { jomonRef: 'jmn_a', payeeEmail: 'a@m.isct.ac.jp', amount: 4000, currency: 'jpy' }, - // `id`/`email` aliases also accepted (best-effort until live field names confirmed). - { id: 7, email: 'b@m.isct.ac.jp', amount: 2000, currency: 'jpy' }, + it('lists accepted applications and normalizes unpaid repayment logs by trap_id', async () => { + mockFetchSequence([ + // 1. list ?current_state=accepted (compact, no per-payee data) + [{ application_id: 'app_1' }], + // 2. GET /api/applications/app_1 (detailed) + { + application_id: 'app_1', + repayment_logs: [ + { repaid_to_user: { trap_id: 'alice' }, amount: 4000, repaid_at: null }, + // already repaid ⇒ skipped + { repaid_to_user: { trap_id: 'bob' }, amount: 2000, repaid_at: '2026-01-01' }, + ], + }, ]) const out = await client.listApprovedTransferRequests() expect(out).toEqual([ - { jomonRef: 'jmn_a', payeeEmail: 'a@m.isct.ac.jp', amount: 4000, currency: 'jpy' }, - { jomonRef: '7', payeeEmail: 'b@m.isct.ac.jp', amount: 2000, currency: 'jpy' }, + { jomonRef: 'app_1:alice', payeeTraqId: 'alice', amount: 4000, currency: 'jpy' }, ]) }) - it('parses a `{ data: [...] }` envelope', async () => { - mockFetch({ data: [{ jomonRef: 'jmn_c', payeeEmail: 'c@m.isct.ac.jp', amount: 100, currency: 'jpy' }] }) + it('SKIPS a malformed application detail and still processes the others', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + mockFetchSequence([ + // 1. list ?current_state=accepted (two accepted applications) + [{ application_id: 'app_bad' }, { application_id: 'app_good' }], + // 2. GET /api/applications/app_bad — malformed (missing repayment_logs) ⇒ skipped + { application_id: 'app_bad' }, + // 3. GET /api/applications/app_good — valid ⇒ processed + { + application_id: 'app_good', + repayment_logs: [ + { repaid_to_user: { trap_id: 'carol' }, amount: 3000, repaid_at: null }, + ], + }, + ]) const out = await client.listApprovedTransferRequests() - expect(out.map(r => r.jomonRef)).toEqual(['jmn_c']) + // The bad application is skipped (logged), the good one still comes through. + expect(out).toEqual([ + { jomonRef: 'app_good:carol', payeeTraqId: 'carol', amount: 3000, currency: 'jpy' }, + ]) + expect(warn).toHaveBeenCalledWith(expect.stringMatching(/app_bad/)) + warn.mockRestore() }) - it('THROWS on a missing payee identifier (never silently defaults)', async () => { - mockFetch([{ jomonRef: 'jmn_d', amount: 4000, currency: 'jpy' }]) - await expect(client.listApprovedTransferRequests()).rejects.toThrow(/validation/i) + it('SKIPS an application whose detail fetch errors and still processes the others', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const spy = vi.spyOn(globalThis, 'fetch') + // 1. list ?current_state=accepted (two accepted applications) + spy.mockResolvedValueOnce( + new Response(JSON.stringify([{ application_id: 'app_err' }, { application_id: 'app_ok' }]), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ) + // 2. GET /api/applications/app_err — HTTP error ⇒ skipped + spy.mockResolvedValueOnce(new Response('boom', { status: 500 })) + // 3. GET /api/applications/app_ok — valid ⇒ processed + spy.mockResolvedValueOnce( + new Response( + JSON.stringify({ + application_id: 'app_ok', + repayment_logs: [{ repaid_to_user: { trap_id: 'dave' }, amount: 1500, repaid_at: null }], + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ) + const out = await client.listApprovedTransferRequests() + expect(out).toEqual([ + { jomonRef: 'app_ok:dave', payeeTraqId: 'dave', amount: 1500, currency: 'jpy' }, + ]) + expect(warn).toHaveBeenCalledWith(expect.stringMatching(/app_err/)) + warn.mockRestore() }) - it('THROWS on a zero / non-positive amount', async () => { - mockFetch([{ jomonRef: 'jmn_e', payeeEmail: 'e@m.isct.ac.jp', amount: 0, currency: 'jpy' }]) - await expect(client.listApprovedTransferRequests()).rejects.toThrow(/validation/i) + it('SKIPS (never silently defaults the payee) an application with a missing trap_id', async () => { + // Strict-within-app: a missing trap_id still fails zod validation, but the + // per-application isolation turns that into a logged skip (no wrong payment), + // not an aborting throw. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + mockFetchSequence([ + [{ application_id: 'app_2' }], + { application_id: 'app_2', repayment_logs: [{ repaid_to_user: {}, amount: 4000 }] }, + ]) + const out = await client.listApprovedTransferRequests() + expect(out).toEqual([]) + expect(warn).toHaveBeenCalledWith(expect.stringMatching(/app_2/)) + warn.mockRestore() }) - it('THROWS on a missing jomonRef / id', async () => { - mockFetch([{ payeeEmail: 'f@m.isct.ac.jp', amount: 4000, currency: 'jpy' }]) - await expect(client.listApprovedTransferRequests()).rejects.toThrow(/validation/i) + it('SKIPS an application with a zero / non-positive amount', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + mockFetchSequence([ + [{ application_id: 'app_3' }], + { application_id: 'app_3', repayment_logs: [{ repaid_to_user: { trap_id: 'eve' }, amount: 0 }] }, + ]) + const out = await client.listApprovedTransferRequests() + expect(out).toEqual([]) + expect(warn).toHaveBeenCalledWith(expect.stringMatching(/app_3/)) + warn.mockRestore() }) - it('THROWS on a missing currency', async () => { - mockFetch([{ jomonRef: 'jmn_g', payeeEmail: 'g@m.isct.ac.jp', amount: 4000 }]) + it('THROWS on an unexpected list response shape', async () => { + mockFetchSequence([{ unexpected: true }]) + await expect(client.listApprovedTransferRequests()).rejects.toThrow(/unexpected response shape/i) + }) + + it('writes back a paid result via PUT .../states/repaid/{trapId}', async () => { + const spy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('', { status: 200 })) + await client.writeBackResult('app_1:alice', { status: 'paid', stripeTransferId: 'tr_x' }) + expect(spy).toHaveBeenCalledTimes(1) + const [url, init] = spy.mock.calls[0] as [string, RequestInit] + expect(url).toBe('https://jomon.test/api/applications/app_1/states/repaid/alice') + expect(init.method).toBe('PUT') + expect(JSON.parse(init.body as string)).toMatchObject({ repaid_at: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/) }) + }) + + it('does not call Jomon for a failed write-back (nothing to mark repaid)', async () => { + const spy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('', { status: 200 })) + await client.writeBackResult('app_1:alice', { status: 'failed', message: 'declined' }) + expect(spy).not.toHaveBeenCalled() + }) +}) + +describe('JomonV2Client (/api/applications, uuid->name, strict)', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + const client = new JomonV2Client('https://jomon.test', 'token') + + it('resolves target uuid via /api/users and normalizes unpaid targets', async () => { + mockFetchSequence([ + // 1. GET /api/users + [ + { id: 'uuid-alice', name: 'alice' }, + { id: 'uuid-bob', name: 'bob' }, + ], + // 2. GET /api/applications?status=approved + [ + { + targets: [ + { id: 'tgt_1', target: 'uuid-alice', amount: 4000, paid_at: null }, + // already paid ⇒ skipped + { id: 'tgt_2', target: 'uuid-bob', amount: 2000, paid_at: '2026-01-01' }, + ], + }, + ], + ]) + const out = await client.listApprovedTransferRequests() + expect(out).toEqual([ + { jomonRef: 'tgt_1', payeeTraqId: 'alice', amount: 4000, currency: 'jpy' }, + ]) + }) + + it('THROWS when a target references an unknown user uuid', async () => { + mockFetchSequence([ + [{ id: 'uuid-alice', name: 'alice' }], + [{ targets: [{ id: 'tgt_x', target: 'uuid-ghost', amount: 4000, paid_at: null }] }], + ]) + await expect(client.listApprovedTransferRequests()).rejects.toThrow(/unknown user uuid/i) + }) + + it('THROWS on a zero / non-positive target amount', async () => { + mockFetchSequence([ + [{ id: 'uuid-alice', name: 'alice' }], + [{ targets: [{ id: 'tgt_z', target: 'uuid-alice', amount: 0, paid_at: null }] }], + ]) await expect(client.listApprovedTransferRequests()).rejects.toThrow(/validation/i) }) - it('THROWS on an unknown top-level response shape', async () => { - mockFetch({ unexpected: true }) - await expect(client.listApprovedTransferRequests()).rejects.toThrow(/unexpected response shape/i) + it('write-back is UNSUPPORTED (throws JomonWriteBackUnsupportedError)', async () => { + await expect(client.writeBackResult('tgt_1', { status: 'paid', stripeTransferId: 'tr_x' })) + .rejects.toBeInstanceOf(JomonWriteBackUnsupportedError) }) }) diff --git a/packages/api/src/jomon/stub.ts b/packages/api/src/jomon/stub.ts index 31c5090..4f0b491 100644 --- a/packages/api/src/jomon/stub.ts +++ b/packages/api/src/jomon/stub.ts @@ -9,10 +9,11 @@ export interface StubWriteBack { /** * In-memory Jomon driver for dev and tests (functionally complete, no network). * - * Approved requests are served from a seeded in-memory list; `writeBackResult` - * appends to an in-memory log (and removes the request from the approved list, - * mirroring Jomon "settling" it so a re-pull doesn't return it again). This lets - * the whole payout machine run end-to-end without live Jomon. (design D1) + * Approved requests (payee = `payeeTraqId`) are served from a seeded in-memory + * list; `writeBackResult` appends to an in-memory log (and removes the request + * from the approved list, mirroring Jomon "settling" it so a re-pull doesn't + * return it again). This lets the whole payout machine run end-to-end without + * live Jomon. (design D1) */ export class StubJomonClient implements JomonClient { private approved: JomonTransferRequest[] diff --git a/packages/api/src/jomon/types.ts b/packages/api/src/jomon/types.ts index 9a0195c..c703447 100644 --- a/packages/api/src/jomon/types.ts +++ b/packages/api/src/jomon/types.ts @@ -13,19 +13,21 @@ /** * A single approved transfer request, normalized to a Jomon-type-free DTO. * - * The payee is identified by `payeeEmail` (isct email), which the domain - * resolves to a `users` row via `deriveMailHash`. The real recipient field is - * unconfirmed against live Jomon and is mapped per driver (design §9, requires - * Jomon coordination). + * The payee is identified by `payeeTraqId` (traQ ID), which the domain resolves + * to a `users` row via `getUserByTraqId` (the `users.traq_id` linked at traQ + * login). Jomon carries the payee as a traQ ID: v1 exposes it directly + * (`repaid_to_user.trap_id`); v2 carries a User UUID (`ApplicationTarget.target`) + * which the v2 driver resolves to a traQ username via `GET /api/users`. Currency + * is always `'jpy'` (Jomon has no currency concept). (design D1) */ export interface JomonTransferRequest { /** Opaque Jomon request id — the unique source/idempotency key. */ jomonRef: string - /** Payee's isct email; resolved to `mail_hash` for person identification. */ - payeeEmail: string + /** Payee's traQ ID; resolved to a `users` row for person identification. */ + payeeTraqId: string /** Amount in the currency's smallest unit (jpy = whole yen). */ amount: number - /** ISO currency code, e.g. 'jpy'. */ + /** ISO currency code; always `'jpy'` for Jomon. */ currency: string } diff --git a/packages/api/src/payouts/execute.test.ts b/packages/api/src/payouts/execute.test.ts index 604400c..c53339d 100644 --- a/packages/api/src/payouts/execute.test.ts +++ b/packages/api/src/payouts/execute.test.ts @@ -1,12 +1,13 @@ import { randomUUID } from 'node:crypto' import { eq } from 'drizzle-orm' -import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { createDatabase, schema, type Database } from '@checkin/db' import { deriveMailHash } from '../auth/crypto' import { setPayoutOnboardingStatus } from '../auth/identity' import type { StripeClient } from '../stripe/client' import { StubJomonClient } from '../jomon/stub' -import type { JomonTransferRequest } from '../jomon/types' +import { JomonWriteBackUnsupportedError } from '../jomon/http' +import type { JomonTransferRequest, JomonWriteBackResult } from '../jomon/types' import { executePayout, processApprovedPayouts, type PayoutExecuteConfig } from './execute' import { getPayoutByJomonRef, upsertPayoutByJomonRef } from './store' @@ -15,10 +16,13 @@ import { getPayoutByJomonRef, upsertPayoutByJomonRef } from './store' * MariaDB pointed at by DATABASE_URL (see the change's tasks: MariaDB is up). * If the database is unreachable they self-skip so `pnpm test` stays green * without infrastructure. Stripe/Jomon are faked; no live calls are made. + * + * Payee resolution is now by traQ ID (`users.traq_id`) via `getUserByTraqId` + * (fix-jomon-payout D5), so requests carry `payeeTraqId` and payees are seeded + * with a linked `traq_id`. */ const config: PayoutExecuteConfig = { - mailHashSecret: 'test-secret', appOrigin: 'http://localhost:3000', defaultCurrency: 'jpy', } @@ -59,7 +63,7 @@ beforeAll(async () => { } }) -/** Unique suffix so repeated runs don't collide on jomon_ref / mail_hash. */ +/** Unique suffix so repeated runs don't collide on jomon_ref / traq_id. */ const tag = randomUUID().slice(0, 8) const refs: string[] = [] const userIds: string[] = [] @@ -76,12 +80,13 @@ afterAll(async () => { } }) -/** Insert a payee user with a known mail_hash; returns its email + id. */ -async function makePayee(localPart: string, onboarding: 'none' | 'done'): Promise<{ email: string, id: string }> { - const email = `${localPart}-${tag}@m.isct.ac.jp` +/** Insert a payee user with a known traq_id; returns its traqId + id. */ +async function makePayee(localPart: string, onboarding: 'none' | 'done'): Promise<{ traqId: string, id: string }> { + const traqId = `${localPart}-${tag}` const id = randomUUID() - const mailHash = deriveMailHash(email, config.mailHashSecret) - await db.insert(schema.users).values({ id, mailHash }) + // mail_hash is NOT NULL/unique; derive a unique one (no longer used for resolution). + const mailHash = deriveMailHash(`${traqId}@m.isct.ac.jp`, 'test-secret') + await db.insert(schema.users).values({ id, mailHash, traqId }) if (onboarding === 'done') { // `done` requires a linked connected account for the transfer path. await db.update(schema.users) @@ -90,12 +95,12 @@ async function makePayee(localPart: string, onboarding: 'none' | 'done'): Promis await setPayoutOnboardingStatus(db, id, 'done') } userIds.push(id) - return { email, id } + return { traqId, id } } -function req(jomonRef: string, payeeEmail: string, amount = 4000): JomonTransferRequest { +function req(jomonRef: string, payeeTraqId: string, amount = 4000): JomonTransferRequest { refs.push(jomonRef) - return { jomonRef, payeeEmail, amount, currency: 'jpy' } + return { jomonRef, payeeTraqId, amount, currency: 'jpy' } } describe('upsertPayoutByJomonRef (idempotent ingestion)', () => { @@ -126,11 +131,11 @@ describe('upsertPayoutByJomonRef (idempotent ingestion)', () => { }) describe('processApprovedPayouts (orchestration)', () => { - it('leaves an unidentifiable payee as pending and does not pay out', async () => { + it('leaves an unidentifiable (unlinked traQ ID) payee as pending and does not pay out', async () => { if (!available) { return } - const jomon = new StubJomonClient([req(`jmn-unres-${tag}`, `nobody-${tag}@m.isct.ac.jp`)]) + const jomon = new StubJomonClient([req(`jmn-unres-${tag}`, `nobody-${tag}`)]) const summary = await processApprovedPayouts( { db, stripe: fakeStripe(), jomon }, config, @@ -145,12 +150,12 @@ describe('processApprovedPayouts (orchestration)', () => { expect(jomon.writeBacks).toHaveLength(0) }) - it('parks a resolved-but-not-onboarded payee as onboarding_waiting', async () => { + it('resolves a linked traQ ID and parks a not-onboarded payee as onboarding_waiting', async () => { if (!available) { return } const payee = await makePayee('waiting', 'none') - const jomon = new StubJomonClient([req(`jmn-wait-${tag}`, payee.email)]) + const jomon = new StubJomonClient([req(`jmn-wait-${tag}`, payee.traqId)]) const summary = await processApprovedPayouts( { db, stripe: fakeStripe(), jomon }, config, @@ -169,7 +174,7 @@ describe('processApprovedPayouts (orchestration)', () => { } const payee = await makePayee('paid', 'done') const ref = `jmn-paid-${tag}` - const jomon = new StubJomonClient([req(ref, payee.email)]) + const jomon = new StubJomonClient([req(ref, payee.traqId)]) const summary = await processApprovedPayouts( { db, stripe: fakeStripe({ transferOk: true }), jomon }, config, @@ -193,7 +198,7 @@ describe('processApprovedPayouts (orchestration)', () => { // Pre-mark this ref paid AND already-written-back, then re-execute via the // stub. A fully-settled paid row short-circuits with no transfer and no // new write-back. (jomonWrittenBackAt set ⇒ write-back not re-attempted.) - const jomon = new StubJomonClient([req(ref, payee.email)]) + const jomon = new StubJomonClient([req(ref, payee.traqId)]) await upsertPayoutByJomonRef(db, { jomonRef: ref, amount: 4000, currency: 'jpy' }) await db.update(schema.payouts) .set({ status: 'paid', stripeTransferId: 'tr_existing', userId: payee.id, jomonWrittenBackAt: new Date() }) @@ -214,7 +219,7 @@ describe('processApprovedPayouts (orchestration)', () => { } const payee = await makePayee('fail', 'done') const ref = `jmn-fail-${tag}` - const jomon = new StubJomonClient([req(ref, payee.email)]) + const jomon = new StubJomonClient([req(ref, payee.traqId)]) const summary = await processApprovedPayouts( { db, stripe: fakeStripe({ transferOk: false }), jomon }, config, @@ -234,7 +239,7 @@ describe('processApprovedPayouts (orchestration)', () => { } const payee = await makePayee('wbts', 'done') const ref = `jmn-wbts-${tag}` - const jomon = new StubJomonClient([req(ref, payee.email)]) + const jomon = new StubJomonClient([req(ref, payee.traqId)]) await processApprovedPayouts({ db, stripe: fakeStripe({ transferOk: true }), jomon }, config) const row = await getPayoutByJomonRef(db, ref) @@ -253,7 +258,7 @@ describe('processApprovedPayouts (orchestration)', () => { .set({ status: 'failed', userId: payee.id }) .where(eq(schema.payouts.jomonRef, ref)) - const jomon = new StubJomonClient([req(ref, payee.email)]) + const jomon = new StubJomonClient([req(ref, payee.traqId)]) const summary = await processApprovedPayouts( { db, stripe: fakeStripe({ transferOk: true }), jomon }, config, @@ -278,7 +283,7 @@ describe('processApprovedPayouts (orchestration)', () => { .set({ status: 'failed', userId: payee.id }) .where(eq(schema.payouts.jomonRef, ref)) - const jomon = new StubJomonClient([req(ref, payee.email)]) + const jomon = new StubJomonClient([req(ref, payee.traqId)]) const result = await executePayout( { db, stripe: fakeStripe({ transferOk: true }), jomon }, config, @@ -306,7 +311,7 @@ describe('processApprovedPayouts (orchestration)', () => { .set({ userId: original.id }) .where(eq(schema.payouts.jomonRef, ref)) - const jomon = new StubJomonClient([req(ref, other.email)]) + const jomon = new StubJomonClient([req(ref, other.traqId)]) const summary = await processApprovedPayouts( { db, stripe: fakeStripe({ transferOk: true }), jomon }, config, @@ -332,7 +337,7 @@ describe('processApprovedPayouts (orchestration)', () => { .set({ status: 'paid', stripeTransferId: 'tr_done', userId: payee.id }) .where(eq(schema.payouts.jomonRef, ref)) - const jomon = new StubJomonClient([req(ref, payee.email)]) + const jomon = new StubJomonClient([req(ref, payee.traqId)]) // A transfer here would throw (transferOk: false) — proving no re-transfer. const result = await executePayout( { db, stripe: fakeStripe({ transferOk: false }), jomon }, @@ -361,7 +366,7 @@ describe('processApprovedPayouts (orchestration)', () => { .set({ status: 'paid', stripeTransferId: 'tr_done', userId: payee.id, jomonWrittenBackAt: new Date() }) .where(eq(schema.payouts.jomonRef, ref)) - const jomon = new StubJomonClient([req(ref, payee.email)]) + const jomon = new StubJomonClient([req(ref, payee.traqId)]) const result = await executePayout( { db, stripe: fakeStripe({ transferOk: false }), jomon }, config, @@ -372,7 +377,63 @@ describe('processApprovedPayouts (orchestration)', () => { expect(jomon.writeBacks).toHaveLength(0) }) - it('isolates a per-item error so the rest of the batch still runs', async () => { + it('keeps the payout paid when v2 write-back is unsupported, leaving it for retry', async () => { + if (!available) { + return + } + const payee = await makePayee('v2wb', 'done') + const ref = `jmn-v2wb-${tag}` + // A stub whose write-back is UNSUPPORTED, like the real v2 driver. + class UnsupportedWriteBackJomon extends StubJomonClient { + override async writeBackResult(): Promise { + throw new JomonWriteBackUnsupportedError('v2 write-back unsupported') + } + } + const jomon = new UnsupportedWriteBackJomon([req(ref, payee.traqId)]) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const summary = await processApprovedPayouts( + { db, stripe: fakeStripe({ transferOk: true }), jomon }, + config, + ) + // The payout itself succeeded (transfer ran) and was NOT isolated as errored. + expect(summary.paid).toBe(1) + expect(summary.errored).toBe(0) + expect(warn).toHaveBeenCalled() + warn.mockRestore() + + const row = await getPayoutByJomonRef(db, ref) + // paid is preserved; write-back stays unrecorded so it retries later. + expect(row?.status).toBe('paid') + expect(row?.jomonWrittenBackAt).toBeNull() + }) + + it('returns a summary with listError (not a throw) when the approved-requests fetch fails', async () => { + if (!available) { + return + } + // A stub whose list pull throws — mirrors a Jomon list/HTTP/zod failure. + class ListFailsJomon extends StubJomonClient { + override async listApprovedTransferRequests(): Promise { + throw new Error('jomon list boom') + } + } + const jomon = new ListFailsJomon() + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const summary = await processApprovedPayouts( + { db, stripe: fakeStripe(), jomon }, + config, + ) + // Reported, not thrown: empty body + a top-level error indicator. + expect(summary.listError).toMatch(/jomon list boom/) + expect(summary.ingested).toBe(0) + expect(summary.errored).toBe(0) + expect(errorSpy).toHaveBeenCalled() + errorSpy.mockRestore() + }) + + it('isolates a write-back failure: payout stays paid and is retried (not errored, not re-transferred)', async () => { if (!available) { return } @@ -381,28 +442,32 @@ describe('processApprovedPayouts (orchestration)', () => { const goodRef = `jmn-iso-good-${tag}` const badRef = `jmn-iso-bad-${tag}` - // A stub whose write-back throws ONLY for the bad ref, so its item errors. + // A stub whose write-back throws ONLY for the bad ref. class FlakyJomon extends StubJomonClient { - override async writeBackResult(jomonRef: string, result: { status: 'paid' | 'failed', stripeTransferId?: string, message?: string }): Promise { + override async writeBackResult(jomonRef: string, result: JomonWriteBackResult): Promise { if (jomonRef === badRef) { throw new Error('jomon write-back boom') } return super.writeBackResult(jomonRef, result) } } - const jomon = new FlakyJomon([req(badRef, badPayee.email), req(goodRef, goodPayee.email)]) + const jomon = new FlakyJomon([req(badRef, badPayee.traqId), req(goodRef, goodPayee.traqId)]) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const summary = await processApprovedPayouts( { db, stripe: fakeStripe({ transferOk: true }), jomon }, config, ) - // The good item still paid out despite the bad item throwing. - expect(summary.paid).toBe(1) - expect(summary.errored).toBe(1) - expect(summary.errors).toContain(badRef) + // Both items paid out; the write-back failure is isolated inside the step + // (logged warning), so it is NOT counted as an errored item. + expect(summary.paid).toBe(2) + expect(summary.errored).toBe(0) + expect(warn).toHaveBeenCalled() + warn.mockRestore() const goodRow = await getPayoutByJomonRef(db, goodRef) expect(goodRow?.status).toBe('paid') + expect(goodRow?.jomonWrittenBackAt).toBeInstanceOf(Date) // The bad item transferred (paid) but its write-back failed; left for retry // with jomonWrittenBackAt still NULL (so a later run retries write-back only). const badRow = await getPayoutByJomonRef(db, badRef) diff --git a/packages/api/src/payouts/execute.ts b/packages/api/src/payouts/execute.ts index 04343a5..6443084 100644 --- a/packages/api/src/payouts/execute.ts +++ b/packages/api/src/payouts/execute.ts @@ -1,10 +1,10 @@ import type { Database } from '@checkin/db' -import { deriveMailHash } from '../auth/crypto' -import { getUserById, getUserByMailHash } from '../auth/identity' +import { getUserById, getUserByTraqId } from '../auth/identity' import type { StripeClient } from '../stripe/client' import { createAccountOnboardingLink, getOrCreateConnectedAccount } from '../stripe/connect' import { createTransfer } from '../stripe/transfers' -import type { JomonClient, JomonTransferRequest } from '../jomon/types' +import { JomonWriteBackUnsupportedError } from '../jomon/http' +import type { JomonClient, JomonTransferRequest, JomonWriteBackResult } from '../jomon/types' import { nextPayoutStatus, type PayoutStatus } from './status' import { claimPayoutForExecution, @@ -33,8 +33,6 @@ export interface PayoutDeps { /** Config the orchestration needs (resolved by the host). */ export interface PayoutExecuteConfig { - /** HMAC secret to derive `mail_hash` from a payee email. */ - mailHashSecret: string /** App origin for onboarding refresh/return URLs. */ appOrigin: string /** Default payout currency when a request omits one (e.g. 'jpy'). */ @@ -43,7 +41,7 @@ export interface PayoutExecuteConfig { /** * The outcome of advancing a single payout one step. - * - `unresolved`: payee not identified by mail_hash — not paid out, 要対応. + * - `unresolved`: payee not identified by traQ ID — not paid out, 要対応. * - `onboarding_waiting`: onboarding not done — link issued, parked. * - `paid`: transfer succeeded. * - `failed`: transfer attempted but failed. @@ -89,6 +87,12 @@ export interface ProcessApprovedSummary { errored: number /** `jomon_ref`s of items that threw, for accountant follow-up. */ errors: string[] + /** + * Set when the approved-requests fetch itself failed: the run is reported with + * an empty body and this top-level error indicator instead of throwing, so a + * Jomon list/HTTP/zod failure never aborts the whole run with nothing recorded. + */ + listError?: string } /** @@ -102,8 +106,6 @@ export async function processApprovedPayouts( deps: PayoutDeps, config: PayoutExecuteConfig, ): Promise { - const requests = await deps.jomon.listApprovedTransferRequests() - const summary: ProcessApprovedSummary = { ingested: 0, paid: 0, @@ -117,6 +119,22 @@ export async function processApprovedPayouts( errors: [], } + // Batch-fetch isolation: the approved-requests pull happens BEFORE any per-item + // work, so a list/HTTP/zod failure here would otherwise abort the whole run with + // nothing recorded. Catch it, log, and RETURN the (empty) summary with a + // top-level `listError` so callers/tests see the fetch failed rather than a + // throw. No money has moved at this point. (Codex hardening: §batch-abort) + let requests: JomonTransferRequest[] + try { + requests = await deps.jomon.listApprovedTransferRequests() + } + catch (err) { + const reason = err instanceof Error ? err.message : String(err) + console.error(`[payout] failed to list approved transfer requests; nothing processed this run: ${reason}`) + summary.listError = reason + return summary + } + for (const req of requests) { // Per-item error isolation: one bad item (Jomon write-back, DB hiccup, // malformed request, etc.) must NOT abort the rest of the batch. Record it @@ -169,7 +187,7 @@ export async function executePayout( // Admin single-item path: `execute` MAY retry a `failed` row. (Codex hardening) return advancePayout(deps, config, { jomonRef: existing.jomonRef, - payeeEmail: '', + payeeTraqId: '', amount: existing.amount, currency: existing.currency, }, { allowFailedRetry: true }) @@ -238,17 +256,17 @@ async function advancePayout( return { jomonRef: req.jomonRef, outcome: 'skipped_failed', status: 'failed' } } - // 2. Identify the payee via the correspondence table (mail_hash). We prefer the - // freshly-pulled email; an empty one (resume without a live request) falls + // 2. Identify the payee by their linked traQ ID (`users.traq_id`). We prefer the + // freshly-pulled traQ ID; an empty one (resume without a live request) falls // back to the already-linked userId. Unresolved ⇒ leave userId null, no payout. - const user = await resolvePayee(deps, config, req, row) + const user = await resolvePayee(deps, req, row) if (!user) { return { jomonRef: req.jomonRef, outcome: 'unresolved', status: row.status } } // userId is IMMUTABLE once set. If a later run resolves a DIFFERENT user from - // the email, do NOT relink — that signals a mail mapping change / mis-route, so - // flag for manual review and never transfer. Only set userId when still null. + // the traQ ID, do NOT relink — that signals a traQ mapping change / mis-route, + // so flag for manual review and never transfer. Only set userId when still null. // (Codex hardening: §userId immutability) if (row.userId === null) { await setPayoutUserId(deps.db, req.jomonRef, user.id) @@ -331,15 +349,16 @@ async function advancePayout( await setPayoutStatus(deps.db, req.jomonRef, status, transferId) // 5. Write the settled result back to Jomon, then record the write-back so a - // later `paid` re-run does not repeat it. A failure here throws (caught and - // isolated by the batch loop) and leaves jomonWrittenBackAt NULL, so the - // write-back is retried on the next pass WITHOUT re-transferring. - await deps.jomon.writeBackResult(req.jomonRef, { + // later `paid` re-run does not repeat it. A write-back failure (including v2 + // being unsupported) is isolated: we log a warning, KEEP the payout in its + // settled state, and leave jomonWrittenBackAt NULL so the write-back is + // retried on the next pass WITHOUT re-transferring. (design D3 / spec + // §書き戻し失敗は再試行され、再送金はしない) + await tryWriteBack(deps, req.jomonRef, { status: transferOk ? 'paid' : 'failed', stripeTransferId: transferId, message: failureMessage, }) - await setPayoutJomonWrittenBackAt(deps.db, req.jomonRef) return { jomonRef: req.jomonRef, @@ -350,29 +369,55 @@ async function advancePayout( /** * Re-attempt ONLY the Jomon write-back for an already-`paid` payout whose - * write-back has not yet been recorded, then mark it done. Never re-transfers. - * (Codex hardening: §retryable write-back avoids re-transfer) + * write-back has not yet been recorded. Never re-transfers. A failure (incl. v2 + * unsupported) is isolated by {@link tryWriteBack}: it stays unrecorded for a + * future retry. (Codex hardening: §retryable write-back avoids re-transfer) */ async function reattemptWriteBack(deps: PayoutDeps, row: PayoutRow): Promise { - await deps.jomon.writeBackResult(row.jomonRef, { + await tryWriteBack(deps, row.jomonRef, { status: 'paid', stripeTransferId: row.stripeTransferId ?? undefined, }) - await setPayoutJomonWrittenBackAt(deps.db, row.jomonRef) } -/** Resolve the payee for a request: by fresh email, else by the linked userId. */ +/** + * Write a settled result back to Jomon and record `jomon_written_back_at` ON + * SUCCESS ONLY. A write-back failure — a v2 {@link JomonWriteBackUnsupportedError} + * or any transport/validation error — is caught, logged as a warning, and + * swallowed: the payout's `paid`/`failed` state is preserved and the timestamp + * stays NULL, so a later run retries the write-back ONLY (never re-transfers). + * v2 keeps failing until Jomon adds the endpoint, but no double payout occurs. + * (design D3 / spec §v2 の書き戻しは未対応として扱う) + */ +async function tryWriteBack( + deps: PayoutDeps, + jomonRef: string, + result: JomonWriteBackResult, +): Promise { + try { + await deps.jomon.writeBackResult(jomonRef, result) + await setPayoutJomonWrittenBackAt(deps.db, jomonRef) + } + catch (err) { + const reason = err instanceof Error ? err.message : String(err) + const unsupported = err instanceof JomonWriteBackUnsupportedError + console.warn( + `[payout] Jomon write-back ${unsupported ? 'unsupported' : 'failed'} for ${jomonRef}; ` + + `payout stays settled, will retry write-back later: ${reason}`, + ) + } +} + +/** Resolve the payee for a request: by fresh traQ ID, else by the linked userId. */ async function resolvePayee( deps: PayoutDeps, - config: PayoutExecuteConfig, req: JomonTransferRequest, row: PayoutRow, ) { - if (req.payeeEmail) { - const mailHash = deriveMailHash(req.payeeEmail, config.mailHashSecret) - return getUserByMailHash(deps.db, mailHash) + if (req.payeeTraqId) { + return getUserByTraqId(deps.db, req.payeeTraqId) } - // No email (resume of an existing row): use the already-linked payee if any. + // No traQ ID (resume of an existing row): use the already-linked payee if any. if (row.userId) { return getUserById(deps.db, row.userId) } diff --git a/packages/api/src/router.ts b/packages/api/src/router.ts index c354190..d91ba04 100644 --- a/packages/api/src/router.ts +++ b/packages/api/src/router.ts @@ -410,11 +410,10 @@ export const appRouter = { /** Build the payout orchestration config from the request Context. */ function payoutExecuteConfig(context: { - config: { mailHashSecret: string, appOrigin: string } + config: { appOrigin: string } jomonConfig: { payoutCurrency: string } }): PayoutExecuteConfig { return { - mailHashSecret: context.config.mailHashSecret, appOrigin: context.config.appOrigin, defaultCurrency: context.jomonConfig.payoutCurrency, } From 38119aadefb0ab7d9219347e911d1e37266dcf5d Mon Sep 17 00:00:00 2001 From: kaitoyama Date: Sun, 21 Jun 2026 07:18:35 +0900 Subject: [PATCH 07/30] =?UTF-8?q?feat:=20member-facing=20UI=20(/,=20/verif?= =?UTF-8?q?y-email,=20/membership)=20per=20=C2=A75.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit add-member-ui (OpenSpec): Nuxt 4 + @nuxt/ui member UI over the dual-identity auth. - Layout/header with auth.me state (member/admin/traqId) + CSRF-aware logout; @nuxt/ui + Tailwind setup, app.config theme, UApp wrapper. - / : state-based entry (anonymous → pay / login; logged-in → status). - /verify-email : isct email → auth.requestEmailVerification, redirect preserved (sanitized), domain/error feedback, no double-submit. - /membership : design §5.1 branching off auth.me — anonymous → 新規入部/再入部 (verify-email) or 現役 (traQ /login); member && !hasUser → verify-email to link; hasUser → invoice form → membership.issueInvoice → hostedInvoiceUrl. 区分→feeType mapping; special ¥2,000 hidden (accountant-only); errors mapped, submit guarded. Codex-reviewed (only a stale design note fixed). Smoke-tested: pages render, magic link emitted (LogMailer), confirm → hasUser, invoice error path handled. Delta synced (member-ui ADDED) and archived. Gates green: lint, typecheck, build, test (111). Real Stripe issuance E2E pending test-mode keys. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/app/layouts/default.vue | 5 +- apps/web/app/pages/index.vue | 54 ++++++++- apps/web/app/pages/membership.vue | 104 ++++++++++++++---- apps/web/app/pages/verify-email.vue | 2 + .../2026-06-21-add-member-ui}/.openspec.yaml | 0 .../2026-06-21-add-member-ui}/design.md | 19 ++-- .../2026-06-21-add-member-ui}/proposal.md | 9 +- .../specs/member-ui/spec.md | 26 ++++- .../2026-06-21-add-member-ui}/tasks.md | 23 ++-- openspec/specs/member-ui/spec.md | 94 ++++++++++++++++ 10 files changed, 278 insertions(+), 58 deletions(-) rename openspec/changes/{add-member-ui => archive/2026-06-21-add-member-ui}/.openspec.yaml (100%) rename openspec/changes/{add-member-ui => archive/2026-06-21-add-member-ui}/design.md (70%) rename openspec/changes/{add-member-ui => archive/2026-06-21-add-member-ui}/proposal.md (76%) rename openspec/changes/{add-member-ui => archive/2026-06-21-add-member-ui}/specs/member-ui/spec.md (65%) rename openspec/changes/{add-member-ui => archive/2026-06-21-add-member-ui}/tasks.md (52%) create mode 100644 openspec/specs/member-ui/spec.md diff --git a/apps/web/app/layouts/default.vue b/apps/web/app/layouts/default.vue index 004a3e6..4b75d97 100644 --- a/apps/web/app/layouts/default.vue +++ b/apps/web/app/layouts/default.vue @@ -1,10 +1,10 @@ diff --git a/apps/web/app/pages/verify-email.vue b/apps/web/app/pages/verify-email.vue index dcd16dc..6442f04 100644 --- a/apps/web/app/pages/verify-email.vue +++ b/apps/web/app/pages/verify-email.vue @@ -1,6 +1,8 @@ + + diff --git a/apps/web/app/pages/payouts.vue b/apps/web/app/pages/payouts.vue new file mode 100644 index 0000000..d014307 --- /dev/null +++ b/apps/web/app/pages/payouts.vue @@ -0,0 +1,505 @@ + + + diff --git a/openspec/changes/archive/2026-06-21-add-accountant-ui/.openspec.yaml b/openspec/changes/archive/2026-06-21-add-accountant-ui/.openspec.yaml new file mode 100644 index 0000000..6c351ac --- /dev/null +++ b/openspec/changes/archive/2026-06-21-add-accountant-ui/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-21 diff --git a/openspec/changes/archive/2026-06-21-add-accountant-ui/design.md b/openspec/changes/archive/2026-06-21-add-accountant-ui/design.md new file mode 100644 index 0000000..8f0d063 --- /dev/null +++ b/openspec/changes/archive/2026-06-21-add-accountant-ui/design.md @@ -0,0 +1,82 @@ +## Context + +バックエンドの会計向け機能([[add-payment-listing]] / [[add-connect-onboarding]] / [[add-payout-execution]])は揃ったが、会計が触れる UI が無い。[[add-member-ui]] の基盤(`@nuxt/ui`・`useAuthMe`・`useCsrf`・共通レイアウト)を流用し、design.md §8 の会計向けページ(`/payments`・払い戻し管理)を実装する。 + +前提([[add-traq-member-auth]] 反映後): traQ ログイン=会員セッション、会計は許可リストのサブセット(`auth.me.admin`)。`auth.me` は `{ authenticated, member, admin, hasUser, traqId }`。認可の source of truth はサーバ側 `adminProc`(oRPC middleware が入力検証より前に認可)であり、UI のガードは導線・体験のため。 + +既存サーバ機能(UI から型安全に使う、すべて `adminProc`): +- `payments.listInvoices({ status?, limit?, startingAfter? })` → `{ items: PaymentRow[], hasMore, nextCursor }`。status: `draft`/`open`/`paid`/`uncollectible`/`void`。 +- `payments.listCheckoutSessions({ status?, limit?, startingAfter? })` → 同上。status: `open`/`complete`/`expired`。 +- `PaymentRow`: `{ id, amount, currency, createdAt(ISO), customer{id,name?}, paymentStatus, paymentId, product{priceId?,description?}, dashboardUrl }`。 +- `payouts.list({ status? })` → `{ items: PayoutRow[] }`。`PayoutRow`: `{ id, jomonRef, userId, amount, currency, status, stripeTransferId, jomonWrittenBackAt }`。status: `pending`/`onboarding_waiting`/`processing`/`paid`/`failed`。 +- `payouts.processApproved()` → 件数サマリ(取込・前進の結果)。`assertCsrf`。 +- `payouts.execute({ jomonRef })` → ステップ結果。`assertCsrf`。 +- `payouts.createOnboardingLink({ userId })` → `{ url }`。`assertCsrf`。 +- `payouts.onboardingStatus({ userId })` → `{ status, hasConnectedAccount }`。読み取り(CSRF 不要)。 +- CSRF: 既存 oRPC クライアントプラグインが mutation に `x-csrf-token` を自動付与(cookie 由来、未取得なら `GET /csrf`)。 + +## Goals / Non-Goals + +**Goals:** +- `/payments`(請求書/決済セッションのタブ・status フィルタ・カーソルページネーション・Dashboard リンク)。 +- `/payouts`(払い戻し一覧・status フィルタ・Jomon 取込・行ごとの execute/onboarding リンク発行/onboarding 状態確認)。 +- 会計のみアクセス(非会計はアクセス不可表示)、会計導線はヘッダー/トップに `admin` のときだけ。 + +**Non-Goals:** +- `/admins`(会計は env 許可リスト管理=旧 DB 管理者テーブル deprecated、管理 UI 不要)。 +- 特別請求書発行(`issueSpecialInvoice`、対象 user 検索 UI が無く別 change)。 +- `/payouts/onboarding/{refresh,return}` の戻り先ページ。決済状況のリアルタイム反映。 + +## Decisions + +### D1. 会計ガードと導線(`admin` 判定) + +- 共通: `useAuthMe()` で `admin = me.admin`。`/payments`・`/payouts` は `admin` が偽なら本体を描画せず、`UAlert`(要会計ログイン)+会計ログイン導線(``、フルページ Nitro ルート)を出す。 +- ヘッダー(`layouts/default.vue`)とトップ(`index.vue`)に、`admin` のときだけ `/payments`・`/payouts` への `UButton`/`NuxtLink` を追加。 +- サーバが真の認可(`adminProc`)。UI ガードはあくまで体験用で、非会計が直接叩いても `adminProc` が拒否する。 + +### D2. データ取得(`useAsyncData` + 明示再取得) + +- `/payments`: タブ(`invoices`/`checkout-sessions`)と status をローカル `ref`。`useAsyncData` のキーは固定(例 `payments-invoices`)にし、フィルタ/ページ変更時は引数を ref から読んで `refresh()` で再取得。あるいは関数で都度 `$orpc...` を呼んで `items`/`hasMore`/`nextCursor` を `ref` に格納する素朴な実装でもよい(型は `AppRouter` 由来)。 +- カーソルページネーション: `startingAfter` を `ref`。フィルタ変更で `undefined` にリセット。「次へ」で `nextCursor` をセットして再取得。簡潔さ優先で「ページ送り(前のページに戻らない)」または「もっと読む(追記)」のどちらかにする。実装は **「次へ/前へ」のスタックなしの単純送り**でなく、**append 型「もっと読む」**を採用(戻る操作不要、実装単純)。先頭に戻るにはフィルタ再適用。 +- `/payouts`: status を `ref`、`payouts.list` を呼んで `items` を `ref`。操作後は `list` を再取得。 + +### D3. 表示整形 + +- 金額: `PaymentRow.amount`/`PayoutRow.amount` は最小単位(JPY は円そのもの)。`currency` 付きで `Intl.NumberFormat('ja-JP', { style:'currency', currency })` で整形(JPY は小数なし)。共通 util(`app/utils` か composable)に切り出してもよい。 +- 日時: `createdAt`(ISO)を `Intl.DateTimeFormat('ja-JP')` で整形。 +- 表は `UTable`(@nuxt/ui)または素の ``+Tailwind。`UTable` の `columns` を型に合わせて定義。 +- Dashboard リンク: `dashboardUrl` を `target="_blank"` の `UButton`/`a`。 + +### D4. 払い戻し操作(CSRF mutation) + +- Jomon 取込: `UButton` → `processApproved()` → 返ったサマリを `UAlert`/トーストで表示 → `list` 再取得。実行中 disabled。 +- 行 execute: `paid` 以外の行に `UButton` → `execute({ jomonRef })` → 結果表示 → `list` 再取得。行ごとに pending 状態を持ち二重操作防止。 +- onboarding リンク: userId のある行に `UButton` → `createOnboardingLink({ userId })` → 返った `url` を表示(コピーできるよう `UInput` readonly か `UAlert` 内にリンク)。会計が本人へ転送する想定(メール平文を持たないため自動送信はしない)。 +- onboarding 状態: userId のある行に `UButton` → `onboardingStatus({ userId })` → `status`+`hasConnectedAccount` を行内/モーダルに表示。 +- 失敗時はエラー表示(`ORPCError` の code を見て会計向けに簡潔に)。 + +### D5. 型安全な oRPC 呼び出し + +- 既存 `$orpc`(`useNuxtApp().$orpc`、型 `AppRouter`)を使用。サーバ実装は import しない(型のみ)。status の enum 値は文字列リテラルで持ち、`USelect` の選択肢にする。 + +## Risks / Trade-offs + +- **実データ未投入(Stripe キー/Jomon 接続なし)** → `listInvoices` 等は空 or エラー。UI は空一覧表示と、操作失敗時の汎用エラーを出す。分岐・タブ・フィルタ・ガードは鍵なしでも確認可能(一覧は空、`payouts.list` は DB 由来なので取込前は空)。 +- **onboarding URL の取り扱い** → メール平文を持たない方針上、自動送信せず会計に URL を提示して手動転送。design §5.3 と整合(会計が転送)。URL は機微なので画面表示のみ(ログに出さない)。 +- **append 型ページネーションでフィルタ状態と取得済みリストの整合** → フィルタ変更時に必ずリストとカーソルをリセット。 +- **@nuxt/ui の `UTable` API** → 導入済みだが列定義の形を確認。複雑なら素の table にフォールバック(lint/typecheck/build をグリーンに保つ)。 + +## Migration Plan + +1. (任意)整形 util(金額・日時)を追加。 +2. `layouts/default.vue`・`index.vue` に会計導線(`admin` のみ)を追加。 +3. `app/pages/payments.vue`: タブ+status フィルタ+append ページネーション+Dashboard リンク、会計ガード。 +4. `app/pages/payouts.vue`: 一覧+status フィルタ+取込/execute/onboarding 操作、会計ガード。 +5. `pnpm lint`/`typecheck`/`build` グリーン。ローカル起動で会計ログイン→各ページの描画・タブ/フィルタ・操作ボタン(実データは空でも導線確認)を確認。 + +## Open Questions + +- ページネーションは append 型「もっと読む」を採用(前ページへ戻る要件は無い)。必要になれば後続でページ送りに拡張。 +- onboarding URL のコピー UI は readonly `UInput`+コピーボタン想定(@nuxt/ui の利用可能コンポーネントに合わせる)。 +- `/payments` の customer 名・商品説明は Stripe 由来で空のことがある(その場合は id/プレースホルダ表示)。 diff --git a/openspec/changes/archive/2026-06-21-add-accountant-ui/proposal.md b/openspec/changes/archive/2026-06-21-add-accountant-ui/proposal.md new file mode 100644 index 0000000..41fb043 --- /dev/null +++ b/openspec/changes/archive/2026-06-21-add-accountant-ui/proposal.md @@ -0,0 +1,32 @@ +## Why + +[[add-member-ui]] で利用者向け UI は揃ったが、**会計(管理)が触れる画面が無い**。集金の入出金確認も、Jomon 承認済み払い戻しの実行・onboarding 管理も、現状 oRPC を直接叩く以外に手段がない。バックエンド(`payments.*` / `payouts.*`、いずれも `adminProc` で会計セッション必須)は実装済みなので、残るは UI のみ。design.md §8 のうち会計向けページ(`/payments`・払い戻し管理)を対象にする。 + +## What Changes + +- **`/payments`(入出金一覧、会計のみ)**: design §5.2 の 2 系統を**タブ**で出す。 + - 請求書由来(`payments.listInvoices`、status: draft/open/paid/uncollectible/void)と決済セッション由来(`payments.listCheckoutSessions`、status: open/complete/expired)。 + - 各タブに status フィルタ+**カーソルページネーション**(`nextCursor`/`hasMore`、「次へ」「もっと読む」)。 + - 各行: id・金額・通貨・日時・customer・支払い状況・支払い id・商品、そして **Stripe Dashboard リンク**(`dashboardUrl`、test/live aware)。 +- **`/payouts`(払い戻し管理、会計のみ)**: design §5.3 の実行・onboarding を会計が操作できるようにする。 + - **Jomon 取込・前進**: `payouts.processApproved` を呼ぶボタン(承認済み振込依頼を取込み各 1 ステップ前進、件数サマリ表示)。 + - **一覧**: `payouts.list`(status フィルタ: pending/onboarding_waiting/processing/paid/failed)。各行に jomonRef・userId・金額・通貨・status・transfer id・Jomon 書き戻し済みか。 + - **各行の操作**: `payouts.execute`(jomonRef 指定で前進/`failed` 再試行)。userId がある行は `payouts.createOnboardingLink`(hosted onboarding URL を発行し会計へ表示=本人へ転送)と `payouts.onboardingStatus`(status+connected account の有無)。 +- **共通**: [[add-member-ui]] のレイアウト/ヘッダー・`useAuthMe`・`useCsrf`・`@nuxt/ui` を流用。会計セッション(`auth.me` の `admin`)でなければ**アクセス不可表示**(導線も会計のみ出す)。状態変更系(processApproved/execute/createOnboardingLink)は oRPC クライアントプラグインが付与する `x-csrf-token`(`assertCsrf` 対応)でそのまま動く。 + +明確に**スコープ外**(後続): `/admins`(会計は env 許可リスト管理で旧 DB 管理者テーブルは deprecated=管理 UI 不要)、`issueSpecialInvoice`(特別請求書発行は対象 user 検索 UI が無く別 change)、`/payouts/onboarding/{refresh,return}` の戻り先ページ、決済状況のリアルタイム反映。 + +## Capabilities + +### New Capabilities +- `accountant-ui`: 会計(管理)向け Web UI。`/payments`(請求書/決済セッションのタブ・status フィルタ・カーソルページネーション・Dashboard リンク)と `/payouts`(Jomon 取込実行・status フィルタ一覧・行ごとの execute/onboarding リンク発行・onboarding 状態確認)。サーバ状態は既存 oRPC(`payments.listInvoices`/`listCheckoutSessions`、`payouts.list`/`processApproved`/`execute`/`createOnboardingLink`/`onboardingStatus`、`auth.me`)越しに扱う。会計セッション(`auth.me.admin`)必須。 + +### Modified Capabilities + + +## Impact + +- **Web(`apps/web`)**: `app/pages/payments.vue`・`app/pages/payouts.vue` を追加。会計導線を `app/layouts/default.vue` ヘッダーと `app/pages/index.vue` に追加(`admin` のときのみ表示)。必要なら共通フォーマッタ composable(金額・日時)を追加。 +- **API/DB**: 変更なし(既存 `adminProc` oRPC を利用)。CSRF は既存 oRPC クライアントプラグインが自動付与。認可はサーバ側 `adminProc` が source of truth(UI のガードは導線・体験のため)。 +- **依存**: 追加なし(`@nuxt/ui` は導入済み)。 +- **検証**: ページのレンダリング・分岐・タブ/フィルタ/ページネーション・各操作ボタンをローカル起動で確認。実データ(Stripe 一覧・Jomon 取込・transfer)は Stripe test キー/Jomon 接続投入後に E2E(未投入では空一覧/設定エラーを UI で表示)。 diff --git a/openspec/changes/archive/2026-06-21-add-accountant-ui/specs/accountant-ui/spec.md b/openspec/changes/archive/2026-06-21-add-accountant-ui/specs/accountant-ui/spec.md new file mode 100644 index 0000000..020ab13 --- /dev/null +++ b/openspec/changes/archive/2026-06-21-add-accountant-ui/specs/accountant-ui/spec.md @@ -0,0 +1,101 @@ +## ADDED Requirements + +### Requirement: 会計のみアクセスとアクセス不可表示 + +会計(管理)向けページ(`/payments`・`/payouts`)は、`auth.me` の `admin` が真のセッションのみに機能を提供 SHALL。`admin` でないセッション(未ログイン・会員のみ・利用者のみ)では会計機能を描画せず、**アクセス不可(要会計ログイン)**である旨と会計ログイン導線を表示する MUST。会計導線(ヘッダー/トップの会計ページへのリンク)は `admin` のときのみ表示する MUST。サーバ側 `adminProc` が認可の source of truth であり、UI のガードは導線・体験のためであることを前提とする。 + +#### Scenario: 未ログインで会計ページを開く + +- **WHEN** 未ログインで `/payments` または `/payouts` を開く +- **THEN** 会計データは表示されず、要会計ログインの案内と会計ログイン導線が表示される + +#### Scenario: 会計でないログインで会計ページを開く + +- **WHEN** 会員のみ/利用者のみ(`admin` が偽)のセッションで `/payments` または `/payouts` を開く +- **THEN** 会計データは表示されず、アクセス不可(要会計)である旨が表示される + +#### Scenario: 会計導線は会計のみに出す + +- **WHEN** `admin` が真でログインしている +- **THEN** ヘッダーまたはトップに `/payments`・`/payouts` への導線が表示される(`admin` でなければ表示されない) + +### Requirement: 入出金一覧(`/payments`) + +`/payments` は会計のみに対し、design §5.2 の 2 系統を**タブ**で提示 SHALL。請求書由来は `payments.listInvoices`、決済セッション由来は `payments.listCheckoutSessions` を呼び、各行を表(id・金額・通貨・日時・customer・支払い状況・支払い id・商品)で表示する MUST。各行は対応する **Stripe Dashboard リンク**(`dashboardUrl`)を持つ MUST。 + +#### Scenario: 請求書タブの一覧 + +- **WHEN** 会計が `/payments` の請求書タブを開く +- **THEN** `payments.listInvoices` が呼ばれ、返った行が表(id・金額・日時・customer・支払い状況・Dashboard リンク等)で表示される + +#### Scenario: 決済セッションタブの一覧 + +- **WHEN** 会計が決済セッションタブに切り替える +- **THEN** `payments.listCheckoutSessions` が呼ばれ、その行が表示される + +#### Scenario: Dashboard リンク + +- **WHEN** 一覧の行の Dashboard リンクを選ぶ +- **THEN** その行の `dashboardUrl`(test/live を反映)が新しいタブで開く + +### Requirement: 一覧のフィルタとカーソルページネーション + +`/payments` の各タブは status フィルタとカーソルページネーションを提供 SHALL。請求書は `draft`/`open`/`paid`/`uncollectible`/`void`、決済セッションは `open`/`complete`/`expired` で絞り込める MUST。`hasMore` が真のときのみ次ページ導線を出し、`nextCursor` を `startingAfter` として次ページを取得する MUST。フィルタ変更時はカーソルをリセットする MUST。 + +#### Scenario: status で絞り込む + +- **WHEN** 会計が請求書タブで status を `paid` に変更する +- **THEN** `status: 'paid'` で再取得され、カーソルはリセットされて先頭ページから表示される + +#### Scenario: 次ページを読む + +- **WHEN** `hasMore` が真の一覧で次ページ導線を選ぶ +- **THEN** `startingAfter: nextCursor` で次ページが取得・表示される + +#### Scenario: 末尾で次ページ導線を出さない + +- **WHEN** `hasMore` が偽 +- **THEN** 次ページ導線は表示されない + +### Requirement: 払い戻し管理の一覧と取込(`/payouts`) + +`/payouts` は会計のみに対し、払い戻し一覧と Jomon 取込・前進を提供 SHALL。`payouts.list`(status フィルタ: `pending`/`onboarding_waiting`/`processing`/`paid`/`failed`)の各行(jomonRef・userId・金額・通貨・status・transfer id・Jomon 書き戻し済みか)を表示する MUST。Jomon 取込ボタンは `payouts.processApproved` を呼び、返った件数サマリを表示し、一覧を再取得する MUST。 + +#### Scenario: 払い戻し一覧の表示 + +- **WHEN** 会計が `/payouts` を開く +- **THEN** `payouts.list` が呼ばれ、各払い戻し行(jomonRef・金額・status 等)が表示される + +#### Scenario: status で絞り込む + +- **WHEN** 会計が status を `onboarding_waiting` に変更する +- **THEN** `status: 'onboarding_waiting'` で再取得され、その状態の払い戻しのみ表示される + +#### Scenario: Jomon 取込・前進 + +- **WHEN** 会計が Jomon 取込ボタンを押す +- **THEN** `payouts.processApproved` が呼ばれ、件数サマリが表示され、一覧が再取得される + +### Requirement: 払い戻しの実行と onboarding 管理 + +`/payouts` は各行に対し、実行(前進/再試行)と onboarding 操作を提供 SHALL。`paid` でない行には `payouts.execute({ jomonRef })`(前進、`failed` の再試行を含む)を呼ぶ操作を出す MUST。userId が解決済みの行には `payouts.createOnboardingLink({ userId })` で hosted onboarding URL を発行して会計に表示(会計が本人へ転送)し、`payouts.onboardingStatus({ userId })` で onboarding 状態と connected account の有無を確認できる MUST。状態変更操作後は一覧を再取得し、実行中は二重操作を防ぐ MUST。 + +#### Scenario: 行の実行・再試行 + +- **WHEN** 会計が `paid` でない行の実行操作を押す +- **THEN** `payouts.execute({ jomonRef })` が呼ばれ、結果(ステップ結果)が反映され一覧が再取得される + +#### Scenario: onboarding リンクの発行 + +- **WHEN** 会計が userId のある行で onboarding リンク発行を押す +- **THEN** `payouts.createOnboardingLink({ userId })` が呼ばれ、返った URL が会計へ表示される(本人へ転送できる) + +#### Scenario: onboarding 状態の確認 + +- **WHEN** 会計が userId のある行で onboarding 状態確認を押す +- **THEN** `payouts.onboardingStatus({ userId })` が呼ばれ、status と connected account の有無が表示される + +#### Scenario: 実行中の二重操作防止 + +- **WHEN** ある行の操作が実行中 +- **THEN** 同じ操作は二重に発火せず、完了後に一覧が再取得される diff --git a/openspec/changes/archive/2026-06-21-add-accountant-ui/tasks.md b/openspec/changes/archive/2026-06-21-add-accountant-ui/tasks.md new file mode 100644 index 0000000..fa33b1a --- /dev/null +++ b/openspec/changes/archive/2026-06-21-add-accountant-ui/tasks.md @@ -0,0 +1,32 @@ +## 1. 共通基盤(整形・導線・ガード) + +- [x] 1.1 整形 util(金額 `formatAmount(amount, currency)`=`Intl.NumberFormat`、日時 `formatDateTime(iso)`=`Intl.DateTimeFormat`)を `app/utils` か composable に追加 +- [x] 1.2 `app/layouts/default.vue` ヘッダーに会計導線(`/payments`・`/payouts`)を `admin` のときのみ表示 +- [x] 1.3 `app/pages/index.vue` に会計導線(`admin` のときのみ)を追加 +- [x] 1.4 会計ガード(非 `admin` は本体非表示+要会計ログイン案内+会計ログイン導線)の共通方針を `/payments`・`/payouts` 両方で適用 + +## 2. `/payments`(入出金一覧) + +- [x] 2.1 `app/pages/payments.vue`: タブ(請求書/決済セッション)。`admin` ガード +- [x] 2.2 請求書タブ: `payments.listInvoices({ status?, startingAfter? })` を呼び、行(id・金額・通貨・日時・customer・支払い状況・支払い id・商品)を表で表示。Dashboard リンク(`dashboardUrl`、`target="_blank"`) +- [x] 2.3 決済セッションタブ: `payments.listCheckoutSessions(...)` を同様に表示 +- [x] 2.4 status フィルタ(請求書: draft/open/paid/uncollectible/void、決済: open/complete/expired)。変更時はリスト+カーソルをリセットして再取得 +- [x] 2.5 append 型ページネーション: `hasMore` が真のときだけ「もっと読む」、`nextCursor`→`startingAfter` で追記取得 + +## 3. `/payouts`(払い戻し管理) + +- [x] 3.1 `app/pages/payouts.vue`: 一覧(`payouts.list({ status? })`、行: jomonRef・userId・金額・通貨・status・transfer id・Jomon 書き戻し済みか)。`admin` ガード +- [x] 3.2 status フィルタ(pending/onboarding_waiting/processing/paid/failed)。変更で再取得 +- [x] 3.3 Jomon 取込ボタン: `payouts.processApproved()` → 件数サマリ表示 → 一覧再取得。実行中 disabled +- [x] 3.4 行 execute: `paid` 以外に `payouts.execute({ jomonRef })` → 結果表示 → 再取得。行ごと pending で二重操作防止 +- [x] 3.5 onboarding リンク発行: userId のある行に `payouts.createOnboardingLink({ userId })` → 返った URL を会計に表示(コピー可、自動送信しない) +- [x] 3.6 onboarding 状態確認: userId のある行に `payouts.onboardingStatus({ userId })` → status+connected account の有無を表示 + +## 4. 検証 + +- [x] 4.1 `pnpm lint` / `pnpm typecheck` / `pnpm build` をグリーンにする +- [x] 4.2 ローカル起動+Playwright で確認: 非会計(未ログイン)で `/payments`・`/payouts` がアクセス不可表示(「会計ログインが必要です」)+トップに会計導線が出ないこと。**会計セッションを偽造(`sessions` に `is_admin=1` 行を挿入し `__Host-checkin_session` cookie を注入)+サンプル payouts を seed** して会計パスも検証: 両ページ描画、`/payments` のタブ・status フィルタ・通貨列・Stripe 鍵無し時の一覧エラー表示、`/payouts` の 4 行表示・status バッジ・条件付き操作ボタン(`paid` は実行ボタン無し/`failed` は「再試行」/userId 無し行は onboarding ボタン無し)・status フィルタ・onboarding 状態のインライン表示。CSRF mutation(processApproved/execute/createOnboardingLink)は headless+http で Secure CSRF cookie が保持されず 403 になるが、curl で cookie+`x-csrf-token` を付ければ 200(`processApproved`→`ingested:0` の stub サマリ、`onboardingStatus`→seed 値)を確認=実装は正。**Playwright が実バグを 1 件検出・修正**: `USelect` の「すべて」選択肢に空文字 `value:''` を使っており @nuxt/ui がハイドレーション時に throw → 両ページが 500。`ALL='all'` センチネル(API へは `undefined`)に変更して解消(lint/typecheck/build/test 緑) +- [ ] 4.3 (Stripe test キー投入後)`/payments` で実 Invoice/Checkout の一覧・フィルタ・Dashboard リンク・ページネーションを確認 + - BLOCKED: Stripe test キー未投入。鍵なしでは一覧は空またはエラー。`NUXT_STRIPE_SECRET_KEY` 投入後に実データで確認 +- [ ] 4.4 (Jomon 接続投入後)`/payouts` で取込→一覧→execute→onboarding の E2E を確認 + - BLOCKED: Jomon 実接続(Bearer 受け口)未確定。default driver は stub。接続投入後に実データで確認 diff --git a/openspec/specs/accountant-ui/spec.md b/openspec/specs/accountant-ui/spec.md new file mode 100644 index 0000000..b8d5470 --- /dev/null +++ b/openspec/specs/accountant-ui/spec.md @@ -0,0 +1,107 @@ +# accountant-ui + +## Purpose + +会計(管理)向けの Web UI(`@nuxt/ui`)。`/payments`(入出金一覧: 請求書/決済セッションのタブ・status フィルタ・カーソルページネーション・Stripe Dashboard リンク)と `/payouts`(払い戻し管理: Jomon 取込実行・status フィルタ一覧・行ごとの execute/onboarding リンク発行・onboarding 状態確認)。サーバ状態は既存 oRPC(`payments.listInvoices`/`listCheckoutSessions`、`payouts.list`/`processApproved`/`execute`/`createOnboardingLink`/`onboardingStatus`、`auth.me`)越しに扱う。すべて `adminProc`(会計セッション必須)で、UI の会計ガードは導線・体験のため(認可の source of truth はサーバ)。 + +## Requirements + +### Requirement: 会計のみアクセスとアクセス不可表示 + +会計(管理)向けページ(`/payments`・`/payouts`)は、`auth.me` の `admin` が真のセッションのみに機能を提供 SHALL。`admin` でないセッション(未ログイン・会員のみ・利用者のみ)では会計機能を描画せず、**アクセス不可(要会計ログイン)**である旨と会計ログイン導線を表示する MUST。会計導線(ヘッダー/トップの会計ページへのリンク)は `admin` のときのみ表示する MUST。サーバ側 `adminProc` が認可の source of truth であり、UI のガードは導線・体験のためであることを前提とする。 + +#### Scenario: 未ログインで会計ページを開く + +- **WHEN** 未ログインで `/payments` または `/payouts` を開く +- **THEN** 会計データは表示されず、要会計ログインの案内と会計ログイン導線が表示される + +#### Scenario: 会計でないログインで会計ページを開く + +- **WHEN** 会員のみ/利用者のみ(`admin` が偽)のセッションで `/payments` または `/payouts` を開く +- **THEN** 会計データは表示されず、アクセス不可(要会計)である旨が表示される + +#### Scenario: 会計導線は会計のみに出す + +- **WHEN** `admin` が真でログインしている +- **THEN** ヘッダーまたはトップに `/payments`・`/payouts` への導線が表示される(`admin` でなければ表示されない) + +### Requirement: 入出金一覧(`/payments`) + +`/payments` は会計のみに対し、design §5.2 の 2 系統を**タブ**で提示 SHALL。請求書由来は `payments.listInvoices`、決済セッション由来は `payments.listCheckoutSessions` を呼び、各行を表(id・金額・通貨・日時・customer・支払い状況・支払い id・商品)で表示する MUST。各行は対応する **Stripe Dashboard リンク**(`dashboardUrl`)を持つ MUST。 + +#### Scenario: 請求書タブの一覧 + +- **WHEN** 会計が `/payments` の請求書タブを開く +- **THEN** `payments.listInvoices` が呼ばれ、返った行が表(id・金額・日時・customer・支払い状況・Dashboard リンク等)で表示される + +#### Scenario: 決済セッションタブの一覧 + +- **WHEN** 会計が決済セッションタブに切り替える +- **THEN** `payments.listCheckoutSessions` が呼ばれ、その行が表示される + +#### Scenario: Dashboard リンク + +- **WHEN** 一覧の行の Dashboard リンクを選ぶ +- **THEN** その行の `dashboardUrl`(test/live を反映)が新しいタブで開く + +### Requirement: 一覧のフィルタとカーソルページネーション + +`/payments` の各タブは status フィルタとカーソルページネーションを提供 SHALL。請求書は `draft`/`open`/`paid`/`uncollectible`/`void`、決済セッションは `open`/`complete`/`expired` で絞り込める MUST。`hasMore` が真のときのみ次ページ導線を出し、`nextCursor` を `startingAfter` として次ページを取得する MUST。フィルタ変更時はカーソルをリセットする MUST。 + +#### Scenario: status で絞り込む + +- **WHEN** 会計が請求書タブで status を `paid` に変更する +- **THEN** `status: 'paid'` で再取得され、カーソルはリセットされて先頭ページから表示される + +#### Scenario: 次ページを読む + +- **WHEN** `hasMore` が真の一覧で次ページ導線を選ぶ +- **THEN** `startingAfter: nextCursor` で次ページが取得・表示される + +#### Scenario: 末尾で次ページ導線を出さない + +- **WHEN** `hasMore` が偽 +- **THEN** 次ページ導線は表示されない + +### Requirement: 払い戻し管理の一覧と取込(`/payouts`) + +`/payouts` は会計のみに対し、払い戻し一覧と Jomon 取込・前進を提供 SHALL。`payouts.list`(status フィルタ: `pending`/`onboarding_waiting`/`processing`/`paid`/`failed`)の各行(jomonRef・userId・金額・通貨・status・transfer id・Jomon 書き戻し済みか)を表示する MUST。Jomon 取込ボタンは `payouts.processApproved` を呼び、返った件数サマリを表示し、一覧を再取得する MUST。 + +#### Scenario: 払い戻し一覧の表示 + +- **WHEN** 会計が `/payouts` を開く +- **THEN** `payouts.list` が呼ばれ、各払い戻し行(jomonRef・金額・status 等)が表示される + +#### Scenario: status で絞り込む + +- **WHEN** 会計が status を `onboarding_waiting` に変更する +- **THEN** `status: 'onboarding_waiting'` で再取得され、その状態の払い戻しのみ表示される + +#### Scenario: Jomon 取込・前進 + +- **WHEN** 会計が Jomon 取込ボタンを押す +- **THEN** `payouts.processApproved` が呼ばれ、件数サマリが表示され、一覧が再取得される + +### Requirement: 払い戻しの実行と onboarding 管理 + +`/payouts` は各行に対し、実行(前進/再試行)と onboarding 操作を提供 SHALL。`paid` でない行には `payouts.execute({ jomonRef })`(前進、`failed` の再試行を含む)を呼ぶ操作を出す MUST。userId が解決済みの行には `payouts.createOnboardingLink({ userId })` で hosted onboarding URL を発行して会計に表示(会計が本人へ転送)し、`payouts.onboardingStatus({ userId })` で onboarding 状態と connected account の有無を確認できる MUST。状態変更操作後は一覧を再取得し、実行中は二重操作を防ぐ MUST。 + +#### Scenario: 行の実行・再試行 + +- **WHEN** 会計が `paid` でない行の実行操作を押す +- **THEN** `payouts.execute({ jomonRef })` が呼ばれ、結果(ステップ結果)が反映され一覧が再取得される + +#### Scenario: onboarding リンクの発行 + +- **WHEN** 会計が userId のある行で onboarding リンク発行を押す +- **THEN** `payouts.createOnboardingLink({ userId })` が呼ばれ、返った URL が会計へ表示される(本人へ転送できる) + +#### Scenario: onboarding 状態の確認 + +- **WHEN** 会計が userId のある行で onboarding 状態確認を押す +- **THEN** `payouts.onboardingStatus({ userId })` が呼ばれ、status と connected account の有無が表示される + +#### Scenario: 実行中の二重操作防止 + +- **WHEN** ある行の操作が実行中 +- **THEN** 同じ操作は二重に発火せず、完了後に一覧が再取得される From 901537e5b2c16cca71f697a6dfbbe11707f10b51 Mon Sep 17 00:00:00 2001 From: kaitoyama Date: Tue, 23 Jun 2026 13:41:32 +0900 Subject: [PATCH 10/30] feat: dev-only login route for local testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /dev/login mints a session WITHOUT traQ OAuth / email verification so the UI flows can be exercised locally (?as=admin|member|user|both, ?email= override for a fresh customer, ?redirect=). Hard-gated as an auth bypass that must NEVER reach production: requires import.meta.dev (baked to false in the production build → unconditional 404, verified against the built server) AND CHECKIN_DEV_LOGIN=1. Documents the flag in .env.example with a danger note. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01ChAsb7VghL4Mw3oJLrzku9 --- .env.example | 10 ++++ apps/web/server/routes/dev/login.get.ts | 63 +++++++++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 apps/web/server/routes/dev/login.get.ts diff --git a/.env.example b/.env.example index 616c3bf..432777b 100644 --- a/.env.example +++ b/.env.example @@ -88,3 +88,13 @@ JOMON_API_VERSION="stub" # Default payout currency (smallest unit; jpy has no minor unit so whole yen). # Jomon carries no currency, so live requests are always jpy regardless. PAYOUT_CURRENCY="jpy" + +# --- DEV ONLY tooling --- + +# Enables GET /dev/login, which mints a session WITHOUT traQ OAuth / email +# verification for local UI testing (?as=admin|member|user|both). DANGEROUS: +# it is an auth bypass. Double-gated — the route also requires `import.meta.dev`, +# which is baked to `false` in the production build, so a built/production server +# returns 404 even if this is set. NEVER set this in production. Only takes effect +# under `pnpm dev`. Leave empty for the real traQ OAuth flow. +CHECKIN_DEV_LOGIN="" diff --git a/apps/web/server/routes/dev/login.get.ts b/apps/web/server/routes/dev/login.get.ts new file mode 100644 index 0000000..0886ed7 --- /dev/null +++ b/apps/web/server/routes/dev/login.get.ts @@ -0,0 +1,63 @@ +import { createSession, deriveMailHash, getOrCreateUserByMailHash, linkTraqId, sanitizeRedirect } from '@checkin/api' + +/** + * GET /dev/login — DEV-ONLY session minting for local UI testing. + * + * **Hard-gated**: returns 404 unless `import.meta.dev` is true AND + * `CHECKIN_DEV_LOGIN === '1'`. This route bypasses traQ OAuth and isct email + * verification, so it must NEVER be reachable in production. `import.meta.dev` + * is replaced with a literal `false` at build time, so in the production bundle + * the guard short-circuits to a 404 regardless of any runtime env (more reliable + * than a NODE_ENV check, which a node-server run may leave unset). The explicit + * `CHECKIN_DEV_LOGIN` flag is the dev opt-in so it stays off unless asked for. + * + * Query: + * ?as=admin (default) — accountant session (isAdmin); drives /payments・/payouts + * =member — traQ member only, no linked user; /membership prompts to link + * =user — isct user only (hasUser); can issue invoices + * =both — traQ member + linked user (isAdmin off) + * ?redirect=/path — same-site landing page after login (default /) + * + * The dev user/member identities reuse fixed dev keys so repeated logins land on + * the same person row (idempotent get-or-create). + */ +export default defineEventHandler(async (event) => { + if (!import.meta.dev || process.env.CHECKIN_DEV_LOGIN !== '1') { + throw createError({ statusCode: 404, statusMessage: 'Not Found' }) + } + + const config = resolveAuthConfig() + const db = useDatabase() + const query = getQuery(event) + const as = typeof query.as === 'string' ? query.as : 'admin' + const redirect = sanitizeRedirect(typeof query.redirect === 'string' ? query.redirect : '/') + + let identity: { traqId: string | null, isAdmin: boolean, userId: string | null } + + if (as === 'admin') { + identity = { traqId: 'devadmin', isAdmin: true, userId: null } + } + else if (as === 'member') { + identity = { traqId: 'devmember', isAdmin: false, userId: null } + } + else if (as === 'user' || as === 'both') { + // Mint (or reuse) an isct user row keyed by a dev email's mail_hash. The + // email can be overridden (?email=) for a fresh customer/key space in tests. + const email = typeof query.email === 'string' && query.email ? query.email : 'dev-user@m.isct.ac.jp' + const mailHash = deriveMailHash(email, config.mailHashSecret) + const user = await getOrCreateUserByMailHash(db, mailHash) + const traqId = as === 'both' ? 'devmember' : null + if (traqId) { + // Best-effort link; 'exists'/'conflict' are fine for repeated dev logins. + await linkTraqId(db, user.id, traqId) + } + identity = { traqId, isAdmin: false, userId: user.id } + } + else { + throw createError({ statusCode: 400, statusMessage: 'invalid ?as (admin|member|user|both)' }) + } + + const token = await createSession(db, identity, config.sessionTtlSec) + setSessionCookie(event, token, config.sessionTtlSec) + return sendRedirect(event, redirect) +}) From 45ee534977f2c767eeadb4cf17939438954b5137 Mon Sep 17 00:00:00 2001 From: kaitoyama Date: Tue, 23 Jun 2026 13:42:04 +0900 Subject: [PATCH 11/30] feat: membership issuance ledger (prevent duplicate payment) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Members could pay twice for the same membership coverage — the only guard was Stripe's ~24h idempotency window. Adds a persistent half-period ledger keyed UNIQUE(user_id, activity_year, half) as the duplicate-payment guard. Model (user-confirmed rules): ¥4,000 = 通期 (both half-slots), ¥2,000 = one half. Standard self-serve (entry/recovery 前期→full, 後期→kouki; 継続→full, covers NEXT year). Special accountant ¥2,000 picks zenki|kouki. issueSpecialInvoice gains coverage + activityYear inputs. Issuance is gated by the ledger: paid→reject, open exact-match→reuse the existing invoice URL, partial overlap→reject, free→issue. invoice.paid flips the charge's slots to paid (idempotent, 台帳外 = 200 no-op). Money-safety (two Codex review rounds): draft-first ordering — create a NON-payable draft, reserve the slot WITH its id, THEN finalize. A payable invoice therefore never exists without a slot guarding it. Draft creation is non-idempotent (concurrent issuers get distinct ids so the reservation loser voids its OWN draft). finalizeAndSend throws ONLY while still a draft (email send is best-effort, concurrent-finalize tolerant), so the void+release path can never strip a payable invoice's guard. Verified E2E with real Stripe + stripe listen: issue → reuse-while-unpaid → pay → re-issue rejected; 通期 paid → 後期 special rejected. 9 ledger tests; gates green (lint/typecheck/build/test 120). OpenSpec: new capability issuance-ledger, membership-billing/payment-webhook updated, change archived 2026-06-23-add-issuance-ledger. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01ChAsb7VghL4Mw3oJLrzku9 --- HANDOVER.md | 48 +- .../routes/webhook/invoice-paid.post.ts | 12 +- .../.openspec.yaml | 2 + .../2026-06-23-add-issuance-ledger/design.md | 125 ++++ .../proposal.md | 43 ++ .../specs/issuance-ledger/spec.md | 114 ++++ .../specs/membership-billing/spec.md | 51 ++ .../specs/payment-webhook/spec.md | 20 + .../2026-06-23-add-issuance-ledger/tasks.md | 32 + openspec/specs/issuance-ledger/spec.md | 120 ++++ openspec/specs/membership-billing/spec.md | 46 +- openspec/specs/payment-webhook/spec.md | 19 + packages/api/src/index.ts | 3 + packages/api/src/ledger/coverage.ts | 42 ++ packages/api/src/ledger/index.ts | 3 + packages/api/src/ledger/issue.ts | 153 +++++ packages/api/src/ledger/ledger.test.ts | 196 ++++++ packages/api/src/ledger/store.ts | 135 +++++ packages/api/src/router.ts | 200 ++++--- packages/api/src/stripe/invoices.ts | 117 ++-- packages/api/src/stripe/webhook.ts | 5 +- packages/db/drizzle/0007_high_vermin.sql | 16 + packages/db/drizzle/meta/0007_snapshot.json | 556 ++++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/schema.ts | 39 +- 25 files changed, 1962 insertions(+), 142 deletions(-) create mode 100644 openspec/changes/archive/2026-06-23-add-issuance-ledger/.openspec.yaml create mode 100644 openspec/changes/archive/2026-06-23-add-issuance-ledger/design.md create mode 100644 openspec/changes/archive/2026-06-23-add-issuance-ledger/proposal.md create mode 100644 openspec/changes/archive/2026-06-23-add-issuance-ledger/specs/issuance-ledger/spec.md create mode 100644 openspec/changes/archive/2026-06-23-add-issuance-ledger/specs/membership-billing/spec.md create mode 100644 openspec/changes/archive/2026-06-23-add-issuance-ledger/specs/payment-webhook/spec.md create mode 100644 openspec/changes/archive/2026-06-23-add-issuance-ledger/tasks.md create mode 100644 openspec/specs/issuance-ledger/spec.md create mode 100644 packages/api/src/ledger/coverage.ts create mode 100644 packages/api/src/ledger/index.ts create mode 100644 packages/api/src/ledger/issue.ts create mode 100644 packages/api/src/ledger/ledger.test.ts create mode 100644 packages/api/src/ledger/store.ts create mode 100644 packages/db/drizzle/0007_high_vermin.sql create mode 100644 packages/db/drizzle/meta/0007_snapshot.json diff --git a/HANDOVER.md b/HANDOVER.md index 8740f81..168924f 100644 --- a/HANDOVER.md +++ b/HANDOVER.md @@ -6,11 +6,13 @@ ## 0. TL;DR traP の Stripe 集金・払い戻しシステム「Checkin」。design.md(リポジトリ直下)が一次設計。 -第一弾スコープ(①集金 ②入出金一覧 ③払い戻し)+認証基盤+利用者 UI を **OpenSpec 仕様駆動**で実装済み。 +第一弾スコープ(①集金 ②入出金一覧 ③払い戻し)+認証基盤+利用者 UI+会計 UI+**重複支払い防止(発行台帳)**を **OpenSpec 仕様駆動**で実装済み。 -- **全ゲート緑**: `pnpm lint` / `pnpm typecheck` / `pnpm build` / `pnpm test`(111 tests)。作業ツリーはクリーン。 -- **8 changes をアーカイブ済み**(`openspec/changes/archive/`)、**11 specs**(`openspec/specs/`)。 -- 未コミットなし。**push / PR は未実施**。 +- **全ゲート緑**: `pnpm lint` / `pnpm typecheck` / `pnpm build` / `pnpm test`(120 tests)。 +- **10 changes をアーカイブ済み**(`openspec/changes/archive/`)、**13 specs**(`openspec/specs/`)。 +- **未コミット差分あり**(`add-accountant-ui` + `add-issuance-ledger` + dev 環境)。**push / PR は未実施**。 +- 直近: `add-issuance-ledger`(半期スロット台帳で会員費の二重払いを拒否)。**実 Stripe で E2E 済み**(発行→支払い→再発行拒否/未払い時 URL 再利用/通期×半期の重複拒否)。Codex 2 周レビューで money-safety(draft-first 順序)を確定。 +- **ローカル dev 構成あり**(下記 §10): `.env`+`apps/web/.env` symlink、Dev ログイン `/dev/login`、Stripe test キー+4 Price+`stripe listen` 配線済み。 - ローカル MariaDB 稼働中(`docker compose`、migration 0000–0006 適用済み)。 ## 1. スタック / レイアウト @@ -42,7 +44,7 @@ traP の Stripe 集金・払い戻しシステム「Checkin」。design.md(リ 完了条件: lint/typecheck/build/test 緑 + 可能なら実サーバ+MariaDB で E2E。 注: ユーザー記憶 `~/.claude/projects/-home-kaitoyama-Checkin/memory/`(`checkin-workflow.md` / `jomon-integration.md`)にも方針あり。 -## 3. 実装済み(8 changes / 11 specs) +## 3. 実装済み(10 changes / 13 specs) | capability (spec) | change | 要点 | |---|---|---| @@ -57,14 +59,19 @@ traP の Stripe 集金・払い戻しシステム「Checkin」。design.md(リ | connect-onboarding | add-connect-onboarding | Connect connected account JIT onboarding。`account.updated` でフラグ判定 → `payout_onboarding_status`(none/requested/done) | | payout-execution | add-payout-execution (+fix-jomon) | Jomon 取込→本人特定(`users.traq_id`)→onboarding ゲート→**原子的クレーム**→Stripe transfer(冪等)→結果書き戻し。`payouts` テーブル | | member-ui | add-member-ui | `@nuxt/ui`。`/`・`/verify-email`・`/membership`(§5.1 分岐) | +| accountant-ui | add-accountant-ui | 会計 UI。`/payments`(請求書/決済セッションのタブ・status フィルタ・append ページネーション・Dashboard リンク)・`/payouts`(Jomon 取込 processApproved・status 一覧・行ごと execute/onboarding リンク発行・状態確認)。全 `adminProc`、UI ガードは UX。一覧取得は request-seq でフィルタ変更の競合防止 | +| issuance-ledger | add-issuance-ledger | **重複支払い防止**。`membership_slots` テーブル `UNIQUE(user_id, activity_year, half)`。¥4,000=通期(前期+後期2行)/¥2,000=半期1行。発行は **draft-first**(draft作成→id付きスロット予約→finalize)で「支払い可能 invoice はガード無しで残らない」不変条件。paid拒否/未払いは同一URL再利用/部分重複拒否。`invoice.paid` で paid 確定。継続は翌年度。`packages/api/src/ledger/`(coverage/store/issue) | -価格マトリクス(ユーザー確定): 新規入部費=前期¥4,000/後期¥2,000(日付で自動、利用者発行)。継続(部費)=標準¥4,000(利用者)/特別¥2,000(**会計のみ**発行)。 +価格マトリクス・coverage(ユーザー確定、`add-issuance-ledger`): **¥4,000=通期 / ¥2,000=半期1つ**。 +- 入部・復旧 前期 → ¥4,000 通期(本人)/後期 → ¥2,000 後期のみ(本人)。 +- 継続 標準 → ¥4,000 通期(本人、会計が後期に開始=**翌年度**カバー)。 +- 特別(会計発行・常に ¥2,000・前期/後期のみ指定): 前期のみ特別、前期のみだった人の後期追加(ルール4)。`issueSpecialInvoice` に `coverage`(zenki/kouki)+`activityYear`(任意) を追加。 ## 4. 重要な設計判断・不変条件(壊さないこと) - **本人キーは `mail_hash`**。traQ ID は**認証済みセッション由来のみ**連結(フォーム入力は信頼しない=design §4.1)。Jomon payout は traQ ID で来るので `users.traq_id`→本人解決。 - **二重送金防止(payout)**: `payouts.jomon_ref` unique + 冪等 upsert + 原子的クレーム(`status=processing` への条件付き UPDATE、`paid`/`processing` 除外)+ Stripe 冪等キー `payout:${jomonRef}` + `paid` 早期 return。書き戻し失敗は再試行(送金は再実行しない、`jomon_written_back_at` で管理)。 -- **集金の冪等**: `issueInvoice` に決定的 Stripe 冪等キー(連打抑止。永続的な発行台帳は未実装=意図的後続)。 +- **集金の重複防止**(`add-issuance-ledger`): `membership_slots` の `UNIQUE(user_id, activity_year, half)` が本体。**draft-first** で支払い可能 invoice はガード無しで残らない。paid 再発行は拒否・未払いは同一 URL 再利用・通期×半期の重なりは拒否。`invoice.paid` で paid 確定。Stripe 冪等キーは廃止(台帳が dedup)。 - **認可順序**: `memberProc`/`userProc`/`adminProc` は oRPC middleware で**入力検証より前**に走る(未認可で入力形状を漏らさない)。状態変更は `assertCsrf()`。 - **Stripe/Jomon 型はアダプタ層に隔離**。`billing/`・`payouts/`・`payments/` ドメインは SDK を直接呼ばない(型のみ可)。oRPC 出力はプレーン DTO。 - **メール平文を DB に保存しない**。ログイン中もセッションは `mailHash` のみ。請求時はメール再入力+`mail_hash` 一致検証。 @@ -83,12 +90,14 @@ traP の Stripe 集金・払い戻しシステム「Checkin」。design.md(リ - 払い戻し: Connect 有効な test キー + `STRIPE_CONNECT_WEBHOOK_SECRET` → onboarding リンク→`account.updated`→`done`→transfer。 - 各 change の tasks.md 最後の E2E 項目が BLOCKED で残っている(キー投入後に実施)。 -### C. 会計(管理)UI(未着手・次の自然な塊) -design §8 の `/payments`(入出金一覧、フィルタ・ページネーション)、`/admins`、払い戻し管理(onboarding リンク発行 `payouts.createOnboardingLink`、`payouts.processApproved`/`list`/`execute`)。バックエンド API は揃っているので UI のみ。`add-member-ui` のパターン(@nuxt/ui、`useAuthMe`/`useCsrf`、`adminProc` 系は会計セッション必須)を流用。新 change `add-accountant-ui` 推奨。 +### C. 会計(管理)UI(✅ 実装済み = `add-accountant-ui`) +`/payments`(入出金一覧・フィルタ・append ページネーション・Dashboard リンク)と `/payouts`(払い戻し管理: `processApproved`/`list`/`execute`/`createOnboardingLink`/`onboardingStatus`)を実装済み。残りの会計 UI: +- **`/admins`**: 会計は env 許可リスト管理(旧 DB 管理者テーブルは deprecated)なので管理 UI は**意図的に未実装**。traQ グループ判定(後述 D)に移行する場合も UI は不要の見込み。 +- **特別請求書発行 UI**(`membership.issueSpecialInvoice`、会計のみ ¥2,000): 対象 user を選ぶ検索/一覧 API・UI が無く未実装。先に user 検索 API(または payouts 一覧の userId から発行)を足す小さな change が必要。 ### D. ハードニング・フォローアップ - `users.stripe_customer_id` に connected account 同様の **unique 制約+get-or-create 競合対策**(Codex 指摘の後続)。小さな change。 -- 集金の**永続的発行台帳**(期またぎ「既に発行/支払済み」判定。現状は Stripe 冪等キーで連打のみ抑止)。 +- ~~集金の永続的発行台帳~~ → **完了**(`add-issuance-ledger`、半期スロット台帳)。 - traQ グループによる会計判定(現状は env 許可リスト `CHECKIN_ACCOUNTANT_TRAQ_IDS`)。 - ロゴ画像(現状テキスト)、`/payouts/onboarding/{refresh,return}` の戻り先ページ(Account Link 用、現状未作成)。 @@ -120,10 +129,23 @@ pnpm test # vitest(DB バックドテストは Maria - **SSR の auth.me**: `plugins/orpc.ts` が SSR 時に cookie を転送し、クライアントは mutation に `x-csrf-token` を自動付与(CSRF cookie 由来)。 - **oRPC 入力検証は middleware の後**(`memberProc` 等で認可が先)。cookie を扱う `/csrf`・`/login`・`/logout`・`/verify-email/confirm`・webhook は **Nitro ルート**(oRPC ではない)。 - **コミットは `git add -A` で UI 等の未コミット差分も巻き込む**ので、change 境界に注意。 +- **@nuxt/ui `USelect` の選択肢に空文字 `value:''` は不可**(''は「選択クリア/placeholder」予約。ハイドレーション時に throw → ページが 500)。「すべて」等は `'all'` 等のセンチネル値にし、API へ渡す時に `=== 'all' ? undefined : v` で変換する。**lint/typecheck/build/Codex は検出できず、実ブラウザ(Playwright)でのみ顕在化**した(`add-accountant-ui` で発生・修正済み)。UI は実描画で確認すること。 +- **会計 UI のローカル検証**: traQ OAuth 無しで会計セッションを偽造できる — `sessions` に `is_admin=1` 行を挿入(`id_hash = sha256hex(token)`)し、ブラウザに `__Host-checkin_session=` を渡す。headless+http では Secure な CSRF cookie が保持されず mutation が 403 になるので、mutation は curl(`/csrf`→cookie+`x-csrf-token` ヘッダ)で確認する。 + +- **発行は draft-first**(`packages/api/src/ledger/issue.ts` の `issueWithLedger`): draft 作成→id 付きスロット予約→finalize。**ledger-first(予約→発行→id 書き戻し)にしてはいけない**(支払い可能 invoice がガード無しで残る窓ができる=Codex HIGH)。`finalizeAndSendInvoice` は「draft のときだけ throw」不変条件・email は best-effort・`createDraftInvoice` は**非冪等**(同時発行が別 id を引くため。同一冪等キーだと敗者が勝者の invoice を void)。 +- **Stripe 冪等キーのテスト汚染**: 同じ customer/price/year/feeType で過去に作った invoice が Stripe 側の冪等ウィンドウ(〜24h)に残ると、台帳を消しても再発行が「finalize 済み invoice を再 finalize」で 500。E2E は `/dev/login?as=user&email=` で毎回新規 customer を使う。 ## 9. 次の一手(推奨) 1. (並行)Jomon メンテナと Bearer 受け口+v2 書き戻しを握る(残作業 A)。 -2. `add-accountant-ui` を新 change で(残作業 C)。バックエンドは完成済み。 -3. Stripe/Connect の test キーが入ったら各 BLOCKED E2E を消化(残作業 B)。 -4. `stripe_customer_id` unique 化(残作業 D)。 +2. Stripe/Connect の test キーが入ったら各 BLOCKED E2E を消化(残作業 B)。会計 UI(`/payments`・`/payouts`)も実データで E2E(`add-accountant-ui` の tasks 4.3/4.4)。 +3. `stripe_customer_id` unique 化(残作業 D)。 +4. (任意)特別請求書発行 UI(会計が `coverage`/`activityYear` 指定)+ user 検索 API(残作業 C の残り)。 +5. (任意)銀行振込(`customer_balance`/`jp_bank_transfer`、請求書ごとに付与)=別 change。 + +## 10. ローカル dev 環境(このセッションで構築・未コミット) + +- **`.env`**(root)+ **`apps/web/.env` → `../../.env` symlink**(`nuxt dev` は rootDir=`apps/web` から `.env` を読むため必須。無いと `DATABASE_URL is not set`)。`MAIL_HASH_SECRET` 生成済み、`CHECKIN_DEV_LOGIN="1"`。 +- **Dev ログイン** `GET /dev/login`(`apps/web/server/routes/dev/login.get.ts`): `?as=admin|member|user|both`、`?email=`(user/both、既定 `dev-user@m.isct.ac.jp`)、`?redirect=`。**二重ガード**(`import.meta.dev` =本番ビルドで false に焼かれ 404 + `CHECKIN_DEV_LOGIN=1`)で本番無効を実ビルドで検証済み。traQ OAuth 無しで会計/会員/利用者セッションを発行。 +- **Stripe**: test secret key 投入済み、**4 Price 作成・配線済み**(`PRICE_*`)。`stripe listen --forward-to localhost:3000/webhook/invoice-paid --forward-connect-to localhost:3000/webhook/account-updated`(`--api-key` は .env の鍵。保存鍵は期限切れだったので。両 webhook secret に共通 `whsec_`)。`/tmp/stripe-listen.pid`、`/tmp/checkin-dev.pid`、ログ `/tmp/checkin-dev.log`。 +- 起動: `pnpm dev`(:3000)。MariaDB は docker で稼働・migration 0007 まで適用済み。 diff --git a/apps/web/server/routes/webhook/invoice-paid.post.ts b/apps/web/server/routes/webhook/invoice-paid.post.ts index d12dddc..f867bf3 100644 --- a/apps/web/server/routes/webhook/invoice-paid.post.ts +++ b/apps/web/server/routes/webhook/invoice-paid.post.ts @@ -1,4 +1,4 @@ -import { constructEvent, createNotifier, createStripeClient, hasProcessedStripeEvent, recordStripeEventOnce } from '@checkin/api' +import { constructEvent, createNotifier, createStripeClient, hasProcessedStripeEvent, markPaidByInvoiceId, recordStripeEventOnce } from '@checkin/api' /** * POST /webhook/invoice-paid — Stripe `invoice.paid` receiver. @@ -22,7 +22,7 @@ export default defineEventHandler(async (event) => { const rawBody = await readRawBody(event, false) const signature = getHeader(event, 'stripe-signature') - let verified: { id: string, type: string } + let verified: { id: string, type: string, objectId: string | null } try { if (!rawBody) { throw new Error('missing request body') @@ -45,6 +45,14 @@ export default defineEventHandler(async (event) => { return { ok: true, duplicate: true } } + // Confirm the issuance ledger: flip the paid invoice's half-slots to `paid`. + // Idempotent and independent of the accountant notification — a 台帳外 invoice + // (no slots) is a no-op. Do this before notify/record so a failure here leaves + // the event unrecorded and Stripe retries. (issuance-ledger spec: §入金時の paid 確定) + if (verified.objectId) { + await markPaidByInvoiceId(db, verified.objectId) + } + // Notify FIRST, then record. A notify failure throws before recordStripeEventOnce // runs, so the event stays unrecorded and Stripe will retry (at-least-once). const notifier = createNotifier() diff --git a/openspec/changes/archive/2026-06-23-add-issuance-ledger/.openspec.yaml b/openspec/changes/archive/2026-06-23-add-issuance-ledger/.openspec.yaml new file mode 100644 index 0000000..a4ac4d7 --- /dev/null +++ b/openspec/changes/archive/2026-06-23-add-issuance-ledger/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-23 diff --git a/openspec/changes/archive/2026-06-23-add-issuance-ledger/design.md b/openspec/changes/archive/2026-06-23-add-issuance-ledger/design.md new file mode 100644 index 0000000..0caed5a --- /dev/null +++ b/openspec/changes/archive/2026-06-23-add-issuance-ledger/design.md @@ -0,0 +1,125 @@ +## Context + +[[membership-billing]] の集金は重複支払いを防げない(Stripe 冪等キーは約24hの連打抑止のみ、永続台帳なし)。利用者が同じ年度の会員費を二重に払えないようにする。ユーザー確定のルール(前期入会=通期¥4,000 / 後期入会・復帰=後期¥2,000 / 継続=通期¥4,000・会計承認で¥2,000)を、**半期スロット**で表現して重複を拒否する。 + +既存の足場: +- `computeActivityYear(date): number`(活動年度 4/1–3/31)、`computeTerm(date): 'zenki'|'kouki'`(前期 4–9 / 後期 10–3)。 +- 発行は `router.ts` の `membership.issueInvoice`(本人・標準)/ `issueSpecialInvoice`(会計・特別¥2,000)。Stripe 発行は `stripe/invoices.ts` の `issueInvoice`。 +- `invoice.paid` は `webhook/invoice-paid.post.ts`(署名検証+event 冪等+会計通知)。 +- DB は `packages/db`(Drizzle / MariaDB)、migration はコミット対象。 + +## Goals / Non-Goals + +**Goals:** +- 半期スロット `(user_id, activity_year, half)` の UNIQUE 台帳で、会員費の二重支払いを拒否。 +- 通期=両半期、半期=片半期の coverage を原子的に予約。 +- 未払い(open)の同一発行は既存 URL を再利用、paid は拒否、部分重複は拒否。 +- `invoice.paid` でスロットを paid 確定、void で解放。 + +**Non-Goals:** +- 銀行振込(別 change)。前期入会¥2,000 承認の UI。過去分のバックフィル。Stripe Customer 単位の重複(本台帳は user_id 基準)。 + +## Decisions + +### D1. テーブル `membership_slots`(1 行=占有された半期スロット) + +| 列 | 内容 | +| --- | --- | +| `id` | PK (uuid) | +| `user_id` | FK → users.id | +| `activity_year` | int(活動年度) | +| `half` | enum `zenki` / `kouki` | +| `charge_group` | uuid(1 回の発行が占める 1〜2 スロットを束ねる) | +| `stripe_invoice_id` | varchar(**予約時に必ずセット**=draft invoice の id。null 状態のスロットは存在しない) | +| `status` | enum `open` / `paid` | +| `created_at` / `updated_at` / `paid_at` | | + +- **UNIQUE `(user_id, activity_year, half)`**: これが重複防止の本体。1 人・1 年度・1 半期につき高々 1 スロット。 +- 通期発行=同一 `charge_group`・同一 `stripe_invoice_id` の **2 行**(zenki, kouki)。半期発行=1 行。 +- **void/draft 削除=行を DELETE**(スロット解放)。よって `status` は `open`/`paid` の 2 値で足りる(void 行は残さない=UNIQUE と両立)。 +- `amount` は持たない(Stripe が source of truth)。kind/variant は当面持たない(必要なら後続)。 +- **money-safety: スロットは常に invoice id を持つ**(後述 D3 の draft-first)。null-id の宙ぶらりんスロットが無いので、webhook は必ず突き合わせでき、「孤児予約の自動削除」も不要(=削除レースが原理的に起きない)。 + +### D2. coverage の決定(埋める半期) + +**原則: 金額が coverage を決める — ¥4,000=通期(前期+後期)、¥2,000=半期1つ。** 通期と半期で混在する `full` 金額の特別はない。 + +| シナリオ | 時期 | 発行者 | 金額 | coverage | 既存パス | +| --- | --- | --- | --- | --- | --- | +| 入部・復旧 | 前期 | 本人 | ¥4,000 | `full` | `issueInvoice` new(zenki) | +| 入部・復旧 | 後期 | 本人 | ¥2,000 | `kouki` | `issueInvoice` new(kouki) | +| 継続(標準) | 後期に会計が開始 | 本人 | ¥4,000 | `full` | `issueInvoice` continuation | +| 特別事情(前期のみ) | 任意 | 会計 | ¥2,000 | `zenki` | `issueSpecialInvoice` coverage=zenki | +| 後期追加(前期のみだった人) | 後期 | 会計 | ¥2,000 | `kouki` | `issueSpecialInvoice` coverage=kouki | + +- **標準(本人 `issueInvoice`)**: `new` は期で自動(前期→`full`¥4,000 / 後期→`kouki`¥2,000)、`continuation` は `full`¥4,000。復旧/再入部は `new` と同一(期ベース)。 +- **特別(会計 `issueSpecialInvoice`、常に ¥2,000)**: coverage は **`zenki` または `kouki` のみ**(`full` は無い)。`issueSpecialInvoice` に `coverage`(`zenki`/`kouki`) 入力を追加。 + - 前期のみ特別(ルール1.3 / 3.2)→ `zenki`。 + - 後期追加(ルール4: 前期のみだった人が後期も所属)→ `kouki`。 + +coverage → 半期集合: `full`→{zenki,kouki}、`zenki`→{zenki}、`kouki`→{kouki}。 + +**ルール4 が半期スロットの正しさを裏付ける**: 前期のみ(¥2,000)を払った人に会計が後期のみ(¥2,000)を別発行 → 前期・後期が**別 charge_group の別スロット**で埋まる(部分重複ではなく、空いている後期スロットの新規予約)。`(user, year, half)` UNIQUE がこれを自然に許容しつつ、同じ半期の二重支払いだけを拒否する。 + +### D2-1. activity_year の決定(covers する年度) + +スロットの `activity_year` は **その支払いがカバーする活動年度**であり、発行時刻の年度とは限らない: +- **入部・復旧・後期追加**: 現在の活動年度(`computeActivityYear(now)`)。 +- **継続(ルール3: 後期に会計が開始)**: 翌活動年度の更新を集めるため、**`computeActivityYear(now) + 1`(次年度)**の通期をカバーする(ユーザー確定)。今年度を既に払った人とはキーが別になり衝突しない。 + +台帳の重複防止機構(半期スロット UNIQUE)自体は年度の決め方に依存しないため、年度算出は [[membership-billing]] 側の規則として確定する。 + +### D3. 台帳ゲートの発行フロー(draft-first=money-safe な順序) + +**不変条件: 支払い可能な invoice は、必ずそれを守るスロットを伴う。** これを満たすため「非支払いの draft を作る → そのidでスロット予約 → 予約完了後に finalize(支払い可能化)」の順にする(`reserveOrResolve` ではなく `resolveIssuance`+`issueWithLedger`): + +1. `activity_year`、coverage→対象半期集合 H を決定。 +2. **判定(読み取りのみ・無副作用)** `resolveIssuance`(同一 `(user_id, activity_year)` の H ∩ 既存スロット): + - `paid`: H のいずれかが paid → `CONFLICT`「既に支払い済み」。 + - `existing`: H 全体が単一 open `charge_group`(占有半期=H 一致)→ その invoice を `finalizeAndSend`(draft なら finalize、既 finalized は URL を返すだけ)で **既存の支払いページを返す**(冪等再要求/クラッシュで残った draft の復旧も兼ねる)。 + - `conflict`: H が既存 open と部分重複(例: 後期 open に通期要求)→ `CONFLICT`「期間重複」。 + - `free`: 誰も占有なし → 3 へ。 +3. **draft 作成**: `createDraftInvoice`(Invoice 作成+InvoiceItem、**finalize しない=まだ支払い不可**)。**冪等キーは使わない**(同時2発行が同一 id を引くと敗者が勝者の invoice を void してしまうため。台帳が重複ガード)。 +4. **原子的予約(id 付き)**: 1 文の複数行 INSERT で H 各半期に `status='open'`・新規 `charge_group`・`stripe_invoice_id=`。UNIQUE 違反(同時予約の敗者)→ 自分の draft を `voidInvoiceSafe` で破棄し 2 を再判定(多くは勝者の `existing` に収束)。 +5. **finalize(支払い可能化)**: 予約成立後に `finalizeAndSend`。**失敗時のみ** invoice を void+スロット解放。`finalizeAndSend` は「draft のまま=支払い不可」のときだけ throw する不変条件なので、解放は常に安全(支払い可能な invoice をガードごと消すことは起きない)。email 送信は best-effort。 + +これにより「二者同時発行」「連打」「日跨ぎ再発行」全てが台帳で 1 回に収束し、かつ支払い可能 invoice がガード無しで残ることは原理的に無い。 + +### D4. `invoice.paid` での確定 / void での解放 + +- `webhook/invoice-paid.post.ts`: 検証済み `invoice.paid` の `invoice.id` で `membership_slots` を引き、該当 `charge_group` の全スロットを `status='paid'`・`paid_at` 設定。event id 冪等は従来どおり(paid 化も冪等=既 paid は no-op)。 +- 通知は従来どおり発火。台帳更新は通知と独立(どちらかの失敗で全体を壊さない)。 +- void/draft 削除(発行途中失敗の後始末や会計の取消): 対応スロット行を DELETE。 + +### D5. ドメイン境界 + +- `issuance-ledger` ドメイン(`packages/api/src/ledger/`)に store(予約/確定/解放/参照)と coverage 純関数を置く。Stripe/Jomon 型は触らない。`stripe_invoice_id` は不透明文字列として扱う。 +- oRPC 出力は従来の DTO(`{ invoiceId, hostedInvoiceUrl }`)。拒否は `ORPCError('CONFLICT', …)`。 + +## Risks / Trade-offs + +- **部分重複の扱い**を「拒否」に倒す(自動で残り半期だけ発行はしない)。シンプル・安全だが、後期既発行で通期を求めると会計対応が要る。→ 必要になれば後続で「残り半期のみ発行」を足す。 +- **特別の coverage** は `zenki`/`kouki` の半期のみ(`full` 無し=¥2,000 は常に半期)。会計が前期/後期を明示。 +- **draft-first で book-keeping のゴミ**: 同時発行や finalize 失敗で void 済み draft が Stripe 側に少量残りうる(支払い不可なので money リスクは無い)。気になれば定期スイープ。 +- **クラッシュで残った draft**: 予約 → finalize 前に落ちると open スロット+draft invoice が残る。次回の同条件要求は `existing` → `finalizeAndSend` が draft を finalize して復旧(支払いページに到達できる)。 +- **user 単位 vs Customer 単位**: 台帳は `user_id` 基準(mail_hash 本人)。同一人物が複数 user 行を持たない前提([[identity]] の get-or-create で担保)。 +- **Codex レビュー反映(money-safety)**: 旧 ledger-first 案(予約→発行→id 書き戻し)は「支払い可能 invoice がガード無しで残る窓」があった(孤児削除レース/finalize 後の解放)。draft-first +「finalizeAndSend は draft のときだけ throw」不変条件+email best-effort で解消(Codex 再レビューで resolved 確認)。 + +## Migration Plan + +1. `packages/db` に `membership_slots` を定義 → `pnpm db:generate` → migration コミット。UNIQUE `(user_id, activity_year, half)`。 +2. `packages/api/src/ledger/`:coverage 純関数+store(予約/確定/解放/参照)+テスト。 +3. `router.ts`:`issueInvoice`/`issueSpecialInvoice` を台帳ゲート化、`issueSpecialInvoice` に `coverage` 追加。 +4. `webhook/invoice-paid.post.ts`:paid 確定を追加。 +5. ローカル実 Stripe E2E(発行→支払い→再発行拒否 / 未払い時 URL 再利用 / 通期×半期の重複拒否)。lint/typecheck/build/test 緑。 + +## Resolved Decisions(確定) + +- **継続(ルール3)の activity_year**: 後期に会計が開始する継続は **翌年度(current+1)の通期**をカバーする(ユーザー確定)。 +- **特別(会計)の coverage**: `zenki`/`kouki` の2択(`full` 無し)。前期のみ=`zenki`、後期追加=`kouki`。 +- **金額と coverage**: ¥4,000=通期 / ¥2,000=半期1つ。 + +## Open Questions + +- 部分重複時に「残り半期のみ自動発行」まで今回やるか(本 change では拒否に留める提案。会計はルール4どおり後期のみを別発行する運用)。 +- 会計が請求を取消す UI(void)は本 change の範囲外(API の解放だけ用意し、UI は後続)。 diff --git a/openspec/changes/archive/2026-06-23-add-issuance-ledger/proposal.md b/openspec/changes/archive/2026-06-23-add-issuance-ledger/proposal.md new file mode 100644 index 0000000..34cd644 --- /dev/null +++ b/openspec/changes/archive/2026-06-23-add-issuance-ledger/proposal.md @@ -0,0 +1,43 @@ +## Why + +現状の集金は **重複支払いを防げない**。`membership.issueInvoice` は呼ぶたびに新しい Invoice を作り、決定的冪等キーは **Stripe の冪等ウィンドウ(約24h)内の連打**しか抑止しない([[membership-billing]] の「連打しても重複請求書を作らない(冪等ウィンドウ内)」のみ)。永続的な「発行/支払い済み」台帳が無いため(HANDOVER §5D の意図的後続)、同一人物が**同じ年度の会員費を日をまたいで2回払える**。銀行振込(後続 change)は非同期なので更に起きやすい。利用者が二重に払えないようにする。 + +ユーザー確定の支払いルール(重複判定の前提)。**原則 ¥4,000=通期 / ¥2,000=半期1つ**: +- **前期の入部・復旧**: ¥4,000・通期(本人)。 +- **後期の入部・復旧**: ¥2,000・後期のみ(本人)。 +- **継続(会計が後期に開始)**: 標準 ¥4,000・通期(本人)。 +- **特別事情(会計発行 ¥2,000)**: 前期のみ(前期の特別/継続特別)または後期のみ(前期のみだった人の後期追加)。会計が前期/後期を指定。 + +## What Changes + +- **半期スロット台帳の導入**: 活動年度を **前期(zenki)/後期(kouki)** の2スロットに分け、`(user_id, activity_year, half)` を **UNIQUE** とする発行台帳 `membership_slots` を追加。1 回の発行が埋めるスロット(coverage)を定義する: + - 通期(前期入部・復旧 ¥4,000 / 継続標準 ¥4,000)→ **[前期]+[後期]** を原子的に予約。 + - 半期(後期入部・復旧 ¥2,000)→ **[後期]** のみ。 + - 特別(会計発行 ¥2,000)→ 会計が **前期のみ/後期のみ**を指定(通期は無い)。前期のみだった人の後期追加(ルール4)も別 charge で後期を予約。 +- **発行を台帳ゲートにする**: `issueInvoice` / `issueSpecialInvoice` は Stripe 発行の前に対象スロットを判定する: + - 対象スロットが **paid** → 拒否(「既に支払い済み」)。 + - 対象スロットが**同一発行の open**(同じ期の未払い請求が既にある)→ **新規発行せず既存の支払いページ URL を返す**(あなたの選択どおり)。 + - 対象スロットが**他発行と部分的に重複**(例: 後期が既発行なのに通期を要求)→ 拒否(期間重複)。 + - いずれも無ければ予約 → Stripe 発行 → スロットに invoice id を記録。 +- **入金で台帳を確定**: `invoice.paid` Webhook は、その invoice の全スロットを **paid** にする([[payment-webhook]] を拡張)。 +- **void で解放**: 請求を取り消した(void/draft 削除)スロットは解放し再発行可能にする。 +- 競合は **UNIQUE 制約+原子的予約**で安全(二者同時発行は一方のみ成功、他方は既存 URL を返すか拒否)。 + +明確に**スコープ外**(後続): 銀行振込(`customer_balance`/`jp_bank_transfer`)対応=別 change(あなたの選択どおり)。前期入会で ¥2,000 にする会計承認パスの UI、過去分の遡及台帳バックフィル。 + +## Capabilities + +### New Capabilities +- `issuance-ledger`: 会員費の発行/支払いを半期スロット `(user_id, activity_year, half)` で永続管理する台帳。coverage(前期/後期/通期)の定義、原子的予約と UNIQUE による重複拒否、open の既存 URL 再利用、`invoice.paid` での paid 確定、void での解放。Stripe 非依存のドメイン+アダプタ越しの状態遷移。 + +### Modified Capabilities +- `membership-billing`: 請求書発行を**台帳ゲート**にする(発行前に重複を判定: paid 拒否 / open 再利用 / 重複拒否)。発行する coverage(埋める半期)を費目・期・区分から決定する規則を追加。 +- `payment-webhook`: `invoice.paid` 受信時に、その invoice に対応する台帳スロットを **paid** に更新する責務を追加(通知は従来どおり)。 + +## Impact + +- **DB(`packages/db`)**: 新テーブル `membership_slots`(`user_id`/`activity_year`/`half`/`charge_group`/`stripe_invoice_id`/`status`/timestamps、UNIQUE `(user_id, activity_year, half)`)+ migration(コミット対象)。 +- **API(`packages/api`)**: `issuance-ledger` ドメイン(store+coverage+予約/確定/解放)。`router.ts` の `membership.issueInvoice`/`issueSpecialInvoice` を台帳ゲート化。`issueSpecialInvoice` に coverage 指定を追加。`stripe/invoices.ts` の発行に invoice id をスロットへ書き戻す流れ。 +- **Nitro**: `webhook/invoice-paid.post.ts` で paid 確定を呼ぶ。 +- **既存修正との関係**: 適用済みの「invoice idempotency key をエンドポイント別にする」バグ修正の上に乗る(台帳が本命の重複ガードになり、Stripe 冪等キーは連打抑止に格下げ)。 +- **検証**: ローカル実 Stripe で「発行→支払い→再発行が拒否」「未払い時は同一 URL 再取得」「通期と半期の重なり拒否」を E2E。lint/typecheck/build/test 緑。 diff --git a/openspec/changes/archive/2026-06-23-add-issuance-ledger/specs/issuance-ledger/spec.md b/openspec/changes/archive/2026-06-23-add-issuance-ledger/specs/issuance-ledger/spec.md new file mode 100644 index 0000000..f1126f3 --- /dev/null +++ b/openspec/changes/archive/2026-06-23-add-issuance-ledger/specs/issuance-ledger/spec.md @@ -0,0 +1,114 @@ +## ADDED Requirements + +### Requirement: 半期スロット台帳 + +システムは、会員費の発行/支払いを **半期スロット** `(user_id, activity_year, half)`(`half` は `zenki`/`kouki`)で永続記録 SHALL。1 人・1 活動年度・1 半期につき占有スロットは高々 1 とし、`(user_id, activity_year, half)` を一意とする MUST。1 回の発行が占めるスロット群は同一の発行グループ(`charge_group`)として束ね、各スロットは発行 Invoice を参照(`stripe_invoice_id`)し、状態(`open`/`paid`)を持つ SHALL。 + +#### Scenario: 1 半期につき 1 スロット + +- **WHEN** ある利用者の活動年度・前期スロットが既に占有されている +- **THEN** 同じ利用者・年度・前期に対する新たなスロット占有は一意制約により拒否される + +#### Scenario: 通期は両半期を占有 + +- **WHEN** 通期 coverage の発行が行われる +- **THEN** 同一 `charge_group` で前期・後期の 2 スロットが占有される + +### Requirement: coverage の決定 + +システムは、費目・期・区分から発行が占める半期(coverage)を決定 SHALL。**原則として金額が coverage を決め、¥4,000=通期(前期+後期)・¥2,000=半期1つ**とする MUST(通期額の半期や半期額の通期は無い): +- **入部・復旧(前期、標準 ¥4,000)** → 通期。 +- **入部・復旧(後期、標準 ¥2,000)** → 後期のみ。 +- **継続(標準 ¥4,000)** → 通期。 +- **特別(会計発行 ¥2,000)** → 会計が **前期のみ/後期のみ**を明示指定する MUST(通期は選べない)。前期のみの特別は前期、前期のみだった人の後期追加は後期。 + +coverage から半期集合への対応は決定的とし、`full`→{前期,後期}・`zenki`→{前期}・`kouki`→{後期} とする MUST。 + +#### Scenario: 前期の入部・復旧は通期 + +- **WHEN** 前期の入部または復旧の標準発行(¥4,000) +- **THEN** coverage は通期(前期+後期)と決定される + +#### Scenario: 後期の入部・復旧は後期のみ + +- **WHEN** 後期の入部または復旧(¥2,000)の発行 +- **THEN** coverage は後期のみと決定される + +#### Scenario: 継続標準は通期 + +- **WHEN** 継続の標準発行(¥4,000) +- **THEN** coverage は通期と決定される + +#### Scenario: 特別は会計が前期/後期を指定 + +- **WHEN** 会計が特別(¥2,000)を発行する +- **THEN** coverage は会計の指定した前期のみ/後期のみに従う(通期は選べない) + +#### Scenario: 前期のみの後に後期を追加できる + +- **WHEN** 前期のみ(特別 ¥2,000)で支払い済みの利用者に、会計が後期のみ(¥2,000)を発行する +- **THEN** 後期スロットは空いているため新規に予約でき、前期・後期が別々の発行として両立する + +### Requirement: 原子的な予約と重複拒否 + +システムは、発行に先立ち対象半期集合 H を台帳で判定 SHALL: +- H のいずれかが **paid** なら発行を拒否する MUST(既に支払い済み)。 +- H が既存の **open** 発行と部分的に重複(占有半期が H と一致しない重なり)するなら拒否する MUST(期間の重複)。 +- H が未占有なら、トランザクション内で H 各半期を `open` として原子的に予約する SHALL。一意制約違反(同時予約)が起きた場合は予約を行わず、再判定する MUST。 + +#### Scenario: 支払い済みは拒否 + +- **WHEN** 対象の半期が既に paid のスロットで占有されている +- **THEN** 新規発行は拒否される(既に支払い済み) + +#### Scenario: 期間の部分重複は拒否 + +- **WHEN** 後期が既に発行済み(open)の状態で通期の発行が要求される +- **THEN** 期間が重複するため拒否される + +#### Scenario: 未占有なら原子的に予約 + +- **WHEN** 対象の半期がいずれも未占有 +- **THEN** 対象半期が同一 `charge_group` で `open` として予約される + +#### Scenario: 同時予約は一方のみ成功 + +- **WHEN** 同一の利用者・年度・半期に対する 2 つの予約がほぼ同時に走る +- **THEN** 一意制約により一方のみが予約に成功し、他方は予約せず再判定に回る + +### Requirement: 未払い既存発行の再利用 + +システムは、対象半期集合 H が**単一の open 発行**にちょうど一致する(占有半期=H)場合、新規発行を行わず、その発行の支払いページ(`hosted_invoice_url`)を返す SHALL。これにより同一条件の再要求が二重の請求書を生まない MUST。 + +#### Scenario: 未払いの同一発行は既存 URL を返す + +- **WHEN** 対象半期が、既存の open 発行(占有半期が一致)で占有されている +- **THEN** 新しい Invoice は作られず、既存発行の支払いページ URL が返る + +### Requirement: 入金時の paid 確定 + +システムは、`invoice.paid` を受けたとき、その Invoice に対応する全スロットを **paid** にし支払い時刻を記録 SHALL。確定は冪等とし、既に paid のスロットへの再適用は副作用を持たない MUST。 + +#### Scenario: 入金で全スロットが paid になる + +- **WHEN** ある発行の Invoice について `invoice.paid` を受信する +- **THEN** その `charge_group` の全スロットが paid になり、支払い時刻が記録される + +#### Scenario: 再入金通知は冪等 + +- **WHEN** 既に paid のスロットに対して `invoice.paid` が再送される +- **THEN** 状態は paid のまま変わらず、二重の副作用は起きない + +### Requirement: 取消による解放 + +システムは、発行が取り消された(Invoice の void / 未確定の削除)場合、その `charge_group` のスロットを台帳から解放(削除)し、同じ半期を再発行可能に戻す SHALL。発行途中で Stripe 発行に失敗した予約も解放する MUST。 + +#### Scenario: void で半期が解放される + +- **WHEN** open のスロットを占有していた発行が void される +- **THEN** そのスロットは解放され、同じ半期に再発行できる + +#### Scenario: 発行失敗の予約は解放される + +- **WHEN** 予約後の Stripe 発行に失敗する +- **THEN** 予約したスロットは解放され、台帳に open のまま残らない diff --git a/openspec/changes/archive/2026-06-23-add-issuance-ledger/specs/membership-billing/spec.md b/openspec/changes/archive/2026-06-23-add-issuance-ledger/specs/membership-billing/spec.md new file mode 100644 index 0000000..c83c87f --- /dev/null +++ b/openspec/changes/archive/2026-06-23-add-issuance-ledger/specs/membership-billing/spec.md @@ -0,0 +1,51 @@ +## ADDED Requirements + +### Requirement: 発行がカバーする活動年度の決定 + +システムは、発行が占有する半期スロットの活動年度(`activity_year`)を、**その支払いがカバーする年度**として決定 SHALL: +- **入部・復旧・後期追加**は、発行時点の活動年度(`computeActivityYear(now)`)をカバーする MUST。 +- **継続(会計が後期に開始する更新)**は、**翌活動年度(`computeActivityYear(now) + 1`)**をカバーする MUST。これにより、今年度を既に支払った利用者と継続(来年度)のスロットキーが衝突しない。 + +#### Scenario: 入部は現年度をカバー + +- **WHEN** 利用者が入部・復旧の請求書を発行する +- **THEN** 占有スロットの活動年度は発行時点の活動年度になる + +#### Scenario: 継続は翌年度をカバー + +- **WHEN** 会計が後期に開始した継続で、利用者が継続の請求書を発行する +- **THEN** 占有スロットの活動年度は翌活動年度(発行時点+1)になる + +## MODIFIED Requirements + +### Requirement: 請求書の作成・確定・送付 + +システムは、対象 Customer に対し Invoice + InvoiceItem(選択した Price)を作成し、finalize して支払い可能にする SHALL。利用者は Stripe の支払いページ(`hosted_invoice_url`)で支払える MUST。請求書はドメイン非依存のアダプタ越しに作成する。 + +発行は **発行台帳([[issuance-ledger]])のゲート越し**に行う SHALL。費目・期・区分から coverage(埋める半期)を決定し、対象半期を台帳で判定する MUST: +- 対象半期が **支払い済み**なら発行せず拒否する(既に支払い済み)。 +- 対象半期が**未払いの同一発行(open)**で占有されているなら、新規発行せず**既存の支払いページ URL を返す**。 +- 対象半期が既存発行と**期間重複**するなら拒否する。 +- いずれでもなければ発行する。 + +発行は **支払い可能な Invoice がそれを守る台帳スロットを必ず伴う**順序で行う MUST(実装は draft 作成 → スロット予約 → finalize=支払い可能化)。発行が途中失敗(finalize 失敗)した場合は、支払い不可の Invoice を後始末し予約スロットを解放してエラーを返す SHALL。連打・同時発行でも、台帳の一意制約により**単一の支払い可能 Invoice に収束**する MUST(決定的冪等キーには依存しない)。 + +#### Scenario: 発行で支払いページに到達できる + +- **WHEN** 認可され、対象半期が未占有の請求書発行が要求される +- **THEN** 台帳にスロットが予約され、Customer の Invoice(Price 指定)が支払い可能化され、支払いページ(URL)が利用可能になる + +#### Scenario: 支払い済みの再発行は拒否 + +- **WHEN** 対象半期が既に支払い済みの利用者が、同じ年度・期の発行を要求する +- **THEN** 新しい Invoice は作られず、拒否される(既に支払い済み) + +#### Scenario: 未払いの再要求は既存の支払いページを返す + +- **WHEN** 対象半期に未払いの同一発行(open)が既にある状態で、同じ条件の発行が再要求される +- **THEN** 新しい Invoice は作られず、既存発行の支払いページ URL が返る + +#### Scenario: 連打・同時発行でも単一 Invoice に収束 + +- **WHEN** 同一条件の発行要求が短時間に複数回/同時に届く +- **THEN** 台帳の一意制約により単一の支払い可能 Invoice に収束し、支払い可能 Invoice がガード無しで残らない diff --git a/openspec/changes/archive/2026-06-23-add-issuance-ledger/specs/payment-webhook/spec.md b/openspec/changes/archive/2026-06-23-add-issuance-ledger/specs/payment-webhook/spec.md new file mode 100644 index 0000000..fbb7c4b --- /dev/null +++ b/openspec/changes/archive/2026-06-23-add-issuance-ledger/specs/payment-webhook/spec.md @@ -0,0 +1,20 @@ +## ADDED Requirements + +### Requirement: 入金時に発行台帳を paid 確定 + +システムは、`invoice.paid` を正常受信・検証したとき、その Invoice に対応する発行台帳([[issuance-ledger]])のスロットを **paid** に確定 SHALL。確定は当該 Invoice の全スロット(通期なら前期・後期の両方)に及び、冪等とする(既に paid のスロットへの再適用は副作用を持たない)MUST。台帳に対応スロットが無い Invoice(台帳導入前や台帳外の発行)は確定をスキップし、受信自体は成功(200)として扱う SHALL。台帳確定は会計通知とは独立で、一方の失敗が他方を壊しては SHALL NOT。 + +#### Scenario: 入金で台帳が paid になる + +- **WHEN** 発行台帳に対応する Invoice について、検証済みの `invoice.paid`(未処理 event id)を受信する +- **THEN** その発行の全スロットが paid に確定され、会計通知も発火する + +#### Scenario: 台帳外の Invoice はスキップ + +- **WHEN** 発行台帳に対応スロットが無い Invoice の `invoice.paid` を受信する +- **THEN** 台帳確定はスキップされ、受信は成功(200)として扱われる + +#### Scenario: 再送の paid 確定は冪等 + +- **WHEN** 既に paid のスロットに対応する `invoice.paid` が再送される +- **THEN** スロットは paid のまま変わらず、二重の副作用は起きない diff --git a/openspec/changes/archive/2026-06-23-add-issuance-ledger/tasks.md b/openspec/changes/archive/2026-06-23-add-issuance-ledger/tasks.md new file mode 100644 index 0000000..e7f7a8e --- /dev/null +++ b/openspec/changes/archive/2026-06-23-add-issuance-ledger/tasks.md @@ -0,0 +1,32 @@ +## 1. DB(発行台帳テーブル) + +- [x] 1.1 `packages/db/src/schema.ts` に `membership_slots` を追加(`id`/`user_id`(FK)/`activity_year`(int)/`half`(enum zenki,kouki)/`charge_group`(uuid)/`stripe_invoice_id`/`status`(enum open,paid)/`created_at`/`updated_at`/`paid_at`(nullable))。**UNIQUE `(user_id, activity_year, half)`**。money-safety で `stripe_invoice_id` は予約時に必ずセット(null スロット無し) +- [x] 1.2 `pnpm db:generate` で migration 生成(`0007_high_vermin.sql`、baseline 保持)。適用済み + +## 2. ledger ドメイン(`packages/api/src/ledger/`) + +- [x] 2.1 coverage 純関数(`coverage.ts`): `standardCoverage`(標準)、`halvesForCoverage`、`standardActivityYear`(継続=翌年度)。特別 coverage は引数(zenki/kouki) +- [x] 2.2 store(`store.ts`): `reserveSlots`(id 付き原子的 INSERT、UNIQUE 違反→'conflict')/ `getSlots` / `getSlotsByChargeGroup` / `markPaidByInvoiceId`(冪等・open のみ)/ `releaseByChargeGroup`(DELETE 解放) +- [x] 2.3 判定+発行(`issue.ts`): `resolveIssuance`(読み取りのみ: paid/existing/conflict/free)+ `issueWithLedger`(draft-first オーケストレーション、Stripe ops 注入でテスト可) +- [x] 2.4 ユニットテスト(`ledger.test.ts`、MariaDB バックド self-skip/coverage は常時): 発行・open 再利用・paid 拒否・rule4 共存・部分重複拒否・finalize 失敗時の解放+void。9 tests green + +## 3. 発行を台帳ゲート化(`packages/api/src/router.ts` + `stripe/invoices.ts`) + +- [x] 3.1 `membership.issueInvoice`(標準・本人): coverage 決定 → `issueWithLedger`(paid/重複=`CONFLICT` / 既存=同一 URL 再利用 / free=draft→予約→finalize)。Customer 解決は `createDraft` 内(free のみ)で副作用最小化 +- [x] 3.2 `membership.issueSpecialInvoice`(特別・会計、常に ¥2,000): 入力に `coverage`(`zenki`/`kouki`)+`activityYear`(任意) を追加し台帳ゲート化。前期のみ=`zenki`、後期追加=`kouki` +- [x] 3.3 `stripe/invoices.ts`: `createDraftInvoice`(非冪等=同時発行が別 id)/ `finalizeAndSendInvoice`(draft のときだけ throw=money-safe 不変条件、email best-effort、finalize レース耐性)/ `voidInvoiceSafe`。旧 `issueInvoice`/`retrieveInvoice` は撤去 +- [x] 3.4 拒否は `ORPCError('CONFLICT', …)`、出力 DTO は従来どおり `{ invoiceId, hostedInvoiceUrl }` + +## 4. Webhook で paid 確定(`apps/web/server/routes/webhook/invoice-paid.post.ts`) + +- [x] 4.1 検証済み `invoice.paid` の `objectId`(invoice id)で `markPaidByInvoiceId` を呼ぶ(台帳外は 0 件 no-op で 200)。通知/記録の前・独立。event id 冪等は従来どおり(`VerifiedEvent.objectId` を追加) + +## 5. 検証 + +- [x] 5.1 `pnpm lint` / `pnpm typecheck` / `pnpm build` / `pnpm test`(120 tests)緑 +- [x] 5.2 ローカル実 Stripe + `stripe listen` で E2E 確認: + - 発行 → 支払い(out-of-band) → `invoice.paid` で両スロット paid → 同条件の再発行が **CONFLICT「既に支払い済み」** ✓ + - 未払いのまま同条件を再要求 → **同一 invoice id / 支払いページ URL** が返る(新規 draft 作らない)✓ + - 通期を支払い済みで **後期のみ特別** を要求 → **重複拒否** ✓ + - 特別(会計)coverage=zenki / activityYear 指定で ¥2,000 半期発行 ✓ +- [x] 5.3 Codex レビュー(money/concurrency): 初回で HIGH×3(ledger-first の孤児削除レース/finalize 後の解放)→ **draft-first に再設計**して解消。再レビューで HIGH(send 失敗時の解放)→ finalizeAndSend を「draft のときだけ throw」不変条件+email best-effort に修正。最終レビューで **全 resolved・残リスク無し** を確認 diff --git a/openspec/specs/issuance-ledger/spec.md b/openspec/specs/issuance-ledger/spec.md new file mode 100644 index 0000000..055299d --- /dev/null +++ b/openspec/specs/issuance-ledger/spec.md @@ -0,0 +1,120 @@ +# issuance-ledger + +## Purpose + +会員費の発行/支払いを **半期スロット** `(user_id, activity_year, half)` で永続管理し、**同じ会員費を二重に支払えないようにする**台帳。coverage(前期/後期/通期)の定義、`UNIQUE(user_id, activity_year, half)` を本体とする原子的予約と重複拒否、未払い既存発行の支払いページ再利用、`invoice.paid` での paid 確定、取消での解放を Stripe 非依存のドメインとして定義する。発行フローは **draft-first**(非支払いの draft を作り、その id でスロット予約してから finalize=支払い可能化)とし、支払い可能 invoice がガード無しで残らないことを不変条件とする。 + +## Requirements + +### Requirement: 半期スロット台帳 + +システムは、会員費の発行/支払いを **半期スロット** `(user_id, activity_year, half)`(`half` は `zenki`/`kouki`)で永続記録 SHALL。1 人・1 活動年度・1 半期につき占有スロットは高々 1 とし、`(user_id, activity_year, half)` を一意とする MUST。1 回の発行が占めるスロット群は同一の発行グループ(`charge_group`)として束ね、各スロットは発行 Invoice を参照(`stripe_invoice_id`)し、状態(`open`/`paid`)を持つ SHALL。 + +#### Scenario: 1 半期につき 1 スロット + +- **WHEN** ある利用者の活動年度・前期スロットが既に占有されている +- **THEN** 同じ利用者・年度・前期に対する新たなスロット占有は一意制約により拒否される + +#### Scenario: 通期は両半期を占有 + +- **WHEN** 通期 coverage の発行が行われる +- **THEN** 同一 `charge_group` で前期・後期の 2 スロットが占有される + +### Requirement: coverage の決定 + +システムは、費目・期・区分から発行が占める半期(coverage)を決定 SHALL。**原則として金額が coverage を決め、¥4,000=通期(前期+後期)・¥2,000=半期1つ**とする MUST(通期額の半期や半期額の通期は無い): +- **入部・復旧(前期、標準 ¥4,000)** → 通期。 +- **入部・復旧(後期、標準 ¥2,000)** → 後期のみ。 +- **継続(標準 ¥4,000)** → 通期。 +- **特別(会計発行 ¥2,000)** → 会計が **前期のみ/後期のみ**を明示指定する MUST(通期は選べない)。前期のみの特別は前期、前期のみだった人の後期追加は後期。 + +coverage から半期集合への対応は決定的とし、`full`→{前期,後期}・`zenki`→{前期}・`kouki`→{後期} とする MUST。 + +#### Scenario: 前期の入部・復旧は通期 + +- **WHEN** 前期の入部または復旧の標準発行(¥4,000) +- **THEN** coverage は通期(前期+後期)と決定される + +#### Scenario: 後期の入部・復旧は後期のみ + +- **WHEN** 後期の入部または復旧(¥2,000)の発行 +- **THEN** coverage は後期のみと決定される + +#### Scenario: 継続標準は通期 + +- **WHEN** 継続の標準発行(¥4,000) +- **THEN** coverage は通期と決定される + +#### Scenario: 特別は会計が前期/後期を指定 + +- **WHEN** 会計が特別(¥2,000)を発行する +- **THEN** coverage は会計の指定した前期のみ/後期のみに従う(通期は選べない) + +#### Scenario: 前期のみの後に後期を追加できる + +- **WHEN** 前期のみ(特別 ¥2,000)で支払い済みの利用者に、会計が後期のみ(¥2,000)を発行する +- **THEN** 後期スロットは空いているため新規に予約でき、前期・後期が別々の発行として両立する + +### Requirement: 原子的な予約と重複拒否 + +システムは、発行に先立ち対象半期集合 H を台帳で判定 SHALL: +- H のいずれかが **paid** なら発行を拒否する MUST(既に支払い済み)。 +- H が既存の **open** 発行と部分的に重複(占有半期が H と一致しない重なり)するなら拒否する MUST(期間の重複)。 +- H が未占有なら、トランザクション内で H 各半期を `open` として原子的に予約する SHALL。一意制約違反(同時予約)が起きた場合は予約を行わず、再判定する MUST。 + +#### Scenario: 支払い済みは拒否 + +- **WHEN** 対象の半期が既に paid のスロットで占有されている +- **THEN** 新規発行は拒否される(既に支払い済み) + +#### Scenario: 期間の部分重複は拒否 + +- **WHEN** 後期が既に発行済み(open)の状態で通期の発行が要求される +- **THEN** 期間が重複するため拒否される + +#### Scenario: 未占有なら原子的に予約 + +- **WHEN** 対象の半期がいずれも未占有 +- **THEN** 対象半期が同一 `charge_group` で `open` として予約される + +#### Scenario: 同時予約は一方のみ成功 + +- **WHEN** 同一の利用者・年度・半期に対する 2 つの予約がほぼ同時に走る +- **THEN** 一意制約により一方のみが予約に成功し、他方は予約せず再判定に回る + +### Requirement: 未払い既存発行の再利用 + +システムは、対象半期集合 H が**単一の open 発行**にちょうど一致する(占有半期=H)場合、新規発行を行わず、その発行の支払いページ(`hosted_invoice_url`)を返す SHALL。これにより同一条件の再要求が二重の請求書を生まない MUST。 + +#### Scenario: 未払いの同一発行は既存 URL を返す + +- **WHEN** 対象半期が、既存の open 発行(占有半期が一致)で占有されている +- **THEN** 新しい Invoice は作られず、既存発行の支払いページ URL が返る + +### Requirement: 入金時の paid 確定 + +システムは、`invoice.paid` を受けたとき、その Invoice に対応する全スロットを **paid** にし支払い時刻を記録 SHALL。確定は冪等とし、既に paid のスロットへの再適用は副作用を持たない MUST。 + +#### Scenario: 入金で全スロットが paid になる + +- **WHEN** ある発行の Invoice について `invoice.paid` を受信する +- **THEN** その `charge_group` の全スロットが paid になり、支払い時刻が記録される + +#### Scenario: 再入金通知は冪等 + +- **WHEN** 既に paid のスロットに対して `invoice.paid` が再送される +- **THEN** 状態は paid のまま変わらず、二重の副作用は起きない + +### Requirement: 取消による解放 + +システムは、発行が取り消された(Invoice の void / 未確定の削除)場合、その `charge_group` のスロットを台帳から解放(削除)し、同じ半期を再発行可能に戻す SHALL。発行途中で Stripe 発行に失敗した予約も解放する MUST。 + +#### Scenario: void で半期が解放される + +- **WHEN** open のスロットを占有していた発行が void される +- **THEN** そのスロットは解放され、同じ半期に再発行できる + +#### Scenario: 発行失敗の予約は解放される + +- **WHEN** 予約後の Stripe 発行(finalize)に失敗する +- **THEN** 予約したスロットは解放され、台帳に open のまま残らない(支払い可能 invoice も残さない) diff --git a/openspec/specs/membership-billing/spec.md b/openspec/specs/membership-billing/spec.md index 389ee5c..b7259ab 100644 --- a/openspec/specs/membership-billing/spec.md +++ b/openspec/specs/membership-billing/spec.md @@ -80,19 +80,53 @@ - **WHEN** 会計が、Customer 未連携の対象者に対し、対象者の `mail_hash` と一致しないメールを指定して発行を要求する - **THEN** 発行は拒否される +### Requirement: 発行がカバーする活動年度の決定 + +システムは、発行が占有する半期スロットの活動年度(`activity_year`)を、**その支払いがカバーする年度**として決定 SHALL: +- **入部・復旧・後期追加**は、発行時点の活動年度(`computeActivityYear(now)`)をカバーする MUST。 +- **継続(会計が後期に開始する更新)**は、**翌活動年度(`computeActivityYear(now) + 1`)**をカバーする MUST。これにより、今年度を既に支払った利用者と継続(来年度)のスロットキーが衝突しない。 + +#### Scenario: 入部は現年度をカバー + +- **WHEN** 利用者が入部・復旧の請求書を発行する +- **THEN** 占有スロットの活動年度は発行時点の活動年度になる + +#### Scenario: 継続は翌年度をカバー + +- **WHEN** 会計が後期に開始した継続で、利用者が継続の請求書を発行する +- **THEN** 占有スロットの活動年度は翌活動年度(発行時点+1)になる + ### Requirement: 請求書の作成・確定・送付 -システムは、対象 Customer に対し Invoice + InvoiceItem(選択した Price)を作成し、finalize して send まで行う SHALL。送付後、利用者は Stripe の支払いページで支払える MUST。請求書はドメイン非依存のアダプタ越しに作成する。短時間の二重 submit を防ぐため、決定的な冪等キーを Invoice 作成に付与 SHALL。作成途中で失敗した場合は、できる範囲で未確定の Invoice を後始末(void/削除)してエラーを返す MUST。 +システムは、対象 Customer に対し Invoice + InvoiceItem(選択した Price)を作成し、finalize して支払い可能にする SHALL。利用者は Stripe の支払いページ(`hosted_invoice_url`)で支払える MUST。請求書はドメイン非依存のアダプタ越しに作成する。 + +発行は **発行台帳([[issuance-ledger]])のゲート越し**に行う SHALL。費目・期・区分から coverage(埋める半期)を決定し、対象半期を台帳で判定する MUST: +- 対象半期が **支払い済み**なら発行せず拒否する(既に支払い済み)。 +- 対象半期が**未払いの同一発行(open)**で占有されているなら、新規発行せず**既存の支払いページ URL を返す**。 +- 対象半期が既存発行と**期間重複**するなら拒否する。 +- いずれでもなければ発行する。 + +発行は **支払い可能な Invoice がそれを守る台帳スロットを必ず伴う**順序で行う MUST(実装は draft 作成 → スロット予約 → finalize=支払い可能化)。発行が途中失敗(finalize 失敗)した場合は、支払い不可の Invoice を後始末し予約スロットを解放してエラーを返す SHALL。連打・同時発行でも、台帳の一意制約により**単一の支払い可能 Invoice に収束**する MUST(決定的冪等キーには依存しない)。 #### Scenario: 発行で支払いページに到達できる -- **WHEN** 認可された請求書発行が要求される -- **THEN** Customer に Invoice(Price 指定)が作成・finalize・send され、支払いページの案内(請求書メール/URL)が利用可能になる +- **WHEN** 認可され、対象半期が未占有の請求書発行が要求される +- **THEN** 台帳にスロットが予約され、Customer の Invoice(Price 指定)が支払い可能化され、支払いページ(URL)が利用可能になる + +#### Scenario: 支払い済みの再発行は拒否 + +- **WHEN** 対象半期が既に支払い済みの利用者が、同じ年度・期の発行を要求する +- **THEN** 新しい Invoice は作られず、拒否される(既に支払い済み) + +#### Scenario: 未払いの再要求は既存の支払いページを返す + +- **WHEN** 対象半期に未払いの同一発行(open)が既にある状態で、同じ条件の発行が再要求される +- **THEN** 新しい Invoice は作られず、既存発行の支払いページ URL が返る -#### Scenario: 連打しても重複請求書を作らない(冪等ウィンドウ内) +#### Scenario: 連打・同時発行でも単一 Invoice に収束 -- **WHEN** 同一条件の発行要求が短時間に複数回届く -- **THEN** 冪等キーにより Stripe 側で重複が抑止され、同一の Invoice が返る +- **WHEN** 同一条件の発行要求が短時間に複数回/同時に届く +- **THEN** 台帳の一意制約により単一の支払い可能 Invoice に収束し、支払い可能 Invoice がガード無しで残らない ### Requirement: 支払い時の traQ ID 連結 diff --git a/openspec/specs/payment-webhook/spec.md b/openspec/specs/payment-webhook/spec.md index 3d86745..1ff1cdc 100644 --- a/openspec/specs/payment-webhook/spec.md +++ b/openspec/specs/payment-webhook/spec.md @@ -42,3 +42,22 @@ Stripe の `invoice.paid` Webhook を受信し、署名検証・event id によ - **WHEN** 検証済みの `invoice.paid`(未処理の event id)を受信する - **THEN** `Notifier` を通じて会計向けの通知が発火する + +### Requirement: 入金時に発行台帳を paid 確定 + +システムは、`invoice.paid` を正常受信・検証したとき、その Invoice に対応する発行台帳([[issuance-ledger]])のスロットを **paid** に確定 SHALL。確定は当該 Invoice の全スロット(通期なら前期・後期の両方)に及び、冪等とする(既に paid のスロットへの再適用は副作用を持たない)MUST。台帳に対応スロットが無い Invoice(台帳導入前や台帳外の発行)は確定をスキップし、受信自体は成功(200)として扱う SHALL。台帳確定は会計通知とは独立で、一方の失敗が他方を壊しては SHALL NOT。 + +#### Scenario: 入金で台帳が paid になる + +- **WHEN** 発行台帳に対応する Invoice について、検証済みの `invoice.paid`(未処理 event id)を受信する +- **THEN** その発行の全スロットが paid に確定され、会計通知も発火する + +#### Scenario: 台帳外の Invoice はスキップ + +- **WHEN** 発行台帳に対応スロットが無い Invoice の `invoice.paid` を受信する +- **THEN** 台帳確定はスキップされ、受信は成功(200)として扱われる + +#### Scenario: 再送の paid 確定は冪等 + +- **WHEN** 既に paid のスロットに対応する `invoice.paid` が再送される +- **THEN** スロットは paid のまま変わらず、二重の副作用は起きない diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index c92eb5c..665867e 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -27,5 +27,8 @@ export * from './notify' /** Webhook idempotency ledger helpers. */ export * from './webhook' +/** Membership issuance/payment ledger (half-period slots, duplicate-payment guard). */ +export * from './ledger' + /** Type of the application router — import this (type-only) on the client. */ export type AppRouter = typeof appRouter diff --git a/packages/api/src/ledger/coverage.ts b/packages/api/src/ledger/coverage.ts new file mode 100644 index 0000000..1a71f42 --- /dev/null +++ b/packages/api/src/ledger/coverage.ts @@ -0,0 +1,42 @@ +import { computeActivityYear } from '../billing/term' +import type { FeeType } from '../billing/pricing' +import type { Term } from '../billing/term' + +/** + * Coverage span of a membership payment (issuance-ledger spec: §coverage の決定). + * Principle: ¥4,000 = 通期 (`full` = both halves), ¥2,000 = one half. + */ +export type Coverage = 'full' | 'zenki' | 'kouki' + +/** A half of the activity year occupied by a ledger slot. */ +export type Half = 'zenki' | 'kouki' + +/** The set of half-slots a coverage occupies (`full` → both). */ +export function halvesForCoverage(coverage: Coverage): Half[] { + return coverage === 'full' ? ['zenki', 'kouki'] : [coverage] +} + +/** + * Coverage for a STANDARD (self-serve) issuance from 費目・期: + * - 入部・復旧 前期 (¥4,000) → 通期 + * - 入部・復旧 後期 (¥2,000) → 後期のみ + * - 継続 (¥4,000) → 通期 + * (復旧/再入部 are priced as `new`.) Special (¥2,000) is NOT derived here — the + * accountant passes the half explicitly. (issuance-ledger spec: §coverage の決定) + */ +export function standardCoverage(feeType: FeeType, term: Term): Coverage { + if (feeType === 'continuation') { + return 'full' + } + return term === 'zenki' ? 'full' : 'kouki' +} + +/** + * The activity year a STANDARD issuance covers (membership-billing spec: + * §発行がカバーする活動年度の決定). Entry/recovery cover the current year; + * 継続 (collected by the accountant in 後期) covers the NEXT year's renewal. + */ +export function standardActivityYear(now: Date, feeType: FeeType): number { + const base = computeActivityYear(now) + return feeType === 'continuation' ? base + 1 : base +} diff --git a/packages/api/src/ledger/index.ts b/packages/api/src/ledger/index.ts new file mode 100644 index 0000000..6b384ff --- /dev/null +++ b/packages/api/src/ledger/index.ts @@ -0,0 +1,3 @@ +export * from './coverage' +export * from './store' +export * from './issue' diff --git a/packages/api/src/ledger/issue.ts b/packages/api/src/ledger/issue.ts new file mode 100644 index 0000000..d6aecc5 --- /dev/null +++ b/packages/api/src/ledger/issue.ts @@ -0,0 +1,153 @@ +import type { Database } from '@checkin/db' +import type { Half } from './coverage' +import { + getSlots, + getSlotsByChargeGroup, + releaseByChargeGroup, + reserveSlots, + type SlotRow, +} from './store' + +/** Target person/year/halves an issuance wants to cover. */ +export interface LedgerTarget { + userId: string + activityYear: number + halves: Half[] +} + +/** + * Read-only decision over a target half-set — NO reservation, NO deletion. + * (issuance-ledger spec: §原子的な予約と重複拒否 / §未払い既存発行の再利用) + * - `paid` : a target half is already paid → reject. + * - `existing` : an OPEN issuance covers exactly these halves → reuse its invoice. + * - `conflict` : a target half overlaps a different issuance → reject (期間重複). + * - `free` : nothing occupies the target halves → safe to issue. + */ +export type IssuanceResolution + = | { kind: 'paid' } + | { kind: 'existing', invoiceId: string } + | { kind: 'conflict' } + | { kind: 'free' } + +/** Set-equality of two half lists (order-independent, 1–2 elements). */ +function sameHalves(a: Half[], b: Half[]): boolean { + if (a.length !== b.length) { + return false + } + const setB = new Set(b) + return a.every(h => setB.has(h)) +} + +/** First non-null Stripe invoice id among a charge group's slots. */ +function firstInvoiceId(slots: SlotRow[]): string | null { + for (const s of slots) { + if (s.stripeInvoiceId) { + return s.stripeInvoiceId + } + } + return null +} + +/** Decide the ledger outcome for a target WITHOUT mutating anything. */ +export async function resolveIssuance(db: Database, target: LedgerTarget): Promise { + const { userId, activityYear, halves } = target + const existing = await getSlots(db, userId, activityYear, halves) + + if (existing.some(s => s.status === 'paid')) { + return { kind: 'paid' } + } + + if (existing.length > 0) { + const chargeGroups = [...new Set(existing.map(s => s.chargeGroup))] + if (chargeGroups.length === 1) { + const groupSlots = await getSlotsByChargeGroup(db, chargeGroups[0]!) + // Exact match: an existing open charge covers precisely the target halves. + if (sameHalves(groupSlots.map(s => s.half), halves)) { + const invoiceId = firstInvoiceId(groupSlots) + // Slots are always reserved WITH an invoice id, so this is non-null in + // practice; reuse the existing issuance's invoice. + if (invoiceId) { + return { kind: 'existing', invoiceId } + } + } + } + // Different/partial coverage overlaps the target → 期間重複. + return { kind: 'conflict' } + } + + return { kind: 'free' } +} + +/** What an issuance returns: the invoice to pay, or why it was rejected. */ +export type IssuanceOutcome + = | { ok: true, invoiceId: string, hostedInvoiceUrl: string | null } + | { ok: false, rejected: 'paid' | 'conflict' } + +/** + * Stripe-backed operations the ledger flow drives (injected so the orchestration + * is unit-testable without Stripe). `createDraft` makes a NON-payable draft + * invoice (so a lost reservation race only discards a harmless draft); + * `finalizeAndSend` makes an invoice payable and returns its hosted URL (and is + * idempotent for an already-finalized invoice, recovering a stuck draft); + * `voidInvoice` discards a draft/finalized-unpaid invoice (best-effort). + */ +export interface IssuanceOps { + createDraft: () => Promise + finalizeAndSend: (invoiceId: string) => Promise<{ invoiceId: string, hostedInvoiceUrl: string | null }> + voidInvoice: (invoiceId: string) => Promise +} + +/** + * Issue over `target` with the ledger as the duplicate-payment guard, ordered so + * a payable invoice NEVER exists without a slot guarding it: + * 1. Decide (resolve). paid/conflict → reject; existing → finalize+reuse. + * 2. free → create a non-payable DRAFT, then reserve the slot(s) WITH its id. + * - reservation conflict (race) → void the draft and re-decide. + * 3. Finalize the invoice (now payable) — the guard is already in place. + * - finalize failure → void the (non-payable) invoice AND release the slots. + * (issuance-ledger: money-safety — reserve-with-id before finalize) + */ +export async function issueWithLedger( + db: Database, + target: LedgerTarget, + ops: IssuanceOps, + attempt = 0, +): Promise { + const resolution = await resolveIssuance(db, target) + if (resolution.kind === 'paid') { + return { ok: false, rejected: 'paid' } + } + if (resolution.kind === 'conflict') { + return { ok: false, rejected: 'conflict' } + } + if (resolution.kind === 'existing') { + // Reuse: finalize-if-needed (recovers a crash-stuck draft) and return the URL. + const sent = await ops.finalizeAndSend(resolution.invoiceId) + return { ok: true, ...sent } + } + + // free: create a non-payable draft, then reserve the slot(s) carrying its id. + const invoiceId = await ops.createDraft() + const reserved = await reserveSlots(db, { ...target, stripeInvoiceId: invoiceId }) + if (reserved === 'conflict') { + // A concurrent issuer won the slot; discard our (non-payable) draft and + // re-decide — the winner now shows as existing/paid/conflict. + await ops.voidInvoice(invoiceId) + if (attempt < 2) { + return issueWithLedger(db, target, ops, attempt + 1) + } + return { ok: false, rejected: 'conflict' } + } + + // Guard is in place; make the invoice payable. On failure void it (not payable) + // and release the slots — a payable invoice without a guard can never result. + try { + const sent = await ops.finalizeAndSend(invoiceId) + return { ok: true, ...sent } + } + catch (err) { + await ops.voidInvoice(invoiceId).catch(() => {}) + await releaseByChargeGroup(db, reserved.chargeGroup) + throw err + } +} diff --git a/packages/api/src/ledger/ledger.test.ts b/packages/api/src/ledger/ledger.test.ts new file mode 100644 index 0000000..aa66a85 --- /dev/null +++ b/packages/api/src/ledger/ledger.test.ts @@ -0,0 +1,196 @@ +import { randomUUID } from 'node:crypto' +import { eq } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { createDatabase, schema, type Database } from '@checkin/db' +import { halvesForCoverage, standardActivityYear, standardCoverage } from './coverage' +import { getSlots, markPaidByInvoiceId } from './store' +import { issueWithLedger, resolveIssuance, type IssuanceOps } from './issue' +import { computeActivityYear } from '../billing/term' + +/** + * Tests for the membership issuance ledger (add-issuance-ledger). The coverage + * pure functions always run; the slot/gate tests are DB-backed against the + * MariaDB at DATABASE_URL and self-skip when unreachable so `pnpm test` stays + * green without infrastructure. + */ + +describe('ledger: coverage (pure)', () => { + it('standardCoverage: 前期入部=通期, 後期入部=後期のみ, 継続=通期', () => { + expect(standardCoverage('new', 'zenki')).toBe('full') + expect(standardCoverage('new', 'kouki')).toBe('kouki') + expect(standardCoverage('continuation', 'zenki')).toBe('full') + expect(standardCoverage('continuation', 'kouki')).toBe('full') + }) + + it('halvesForCoverage: full=both, half=one', () => { + expect(halvesForCoverage('full').sort()).toEqual(['kouki', 'zenki']) + expect(halvesForCoverage('zenki')).toEqual(['zenki']) + expect(halvesForCoverage('kouki')).toEqual(['kouki']) + }) + + it('standardActivityYear: 継続は翌年度(+1)、入部は現年度', () => { + const now = new Date('2027-02-15T00:00:00Z') // 後期 of activity year 2026 + const base = computeActivityYear(now) + expect(standardActivityYear(now, 'new')).toBe(base) + expect(standardActivityYear(now, 'continuation')).toBe(base + 1) + }) +}) + +let db: Database +let available = false + +beforeAll(async () => { + try { + db = createDatabase(process.env.DATABASE_URL ?? 'mysql://checkin:password@localhost:3306/checkin') + await db.select({ id: schema.users.id }).from(schema.users).limit(1) + available = true + } + catch { + available = false + } +}) + +const userIds: string[] = [] + +afterAll(async () => { + if (!available) { + return + } + for (const id of userIds) { + await db.delete(schema.membershipSlots).where(eq(schema.membershipSlots.userId, id)) + await db.delete(schema.users).where(eq(schema.users.id, id)) + } +}) + +async function makeUser(): Promise { + const id = randomUUID() + await db.insert(schema.users).values({ id, mailHash: `hash_${id.slice(0, 12)}` }) + userIds.push(id) + return id +} + +/** In-memory fake of the Stripe issuance ops; tracks invoice lifecycle. */ +function fakeOps(): IssuanceOps & { invoices: Map, created: number } { + const invoices = new Map() + const ctx = { + invoices, + created: 0, + createDraft: async () => { + ctx.created += 1 + const id = `in_${ctx.created}_${randomUUID().slice(0, 6)}` + invoices.set(id, 'draft') + return id + }, + finalizeAndSend: async (id: string) => { + if (invoices.get(id) === 'draft') { + invoices.set(id, 'open') + } + return { invoiceId: id, hostedInvoiceUrl: `https://pay.example/${id}` } + }, + voidInvoice: async (id: string) => { + invoices.set(id, 'void') + }, + } + return ctx +} + +const YEAR = 2099 + +describe('ledger: issueWithLedger (DB + fake Stripe)', () => { + it('issues on free halves, reuses the same invoice while unpaid', async () => { + if (!available) { + return + } + const userId = await makeUser() + const ops = fakeOps() + + const first = await issueWithLedger(db, { userId, activityYear: YEAR, halves: ['zenki', 'kouki'] }, ops) + expect(first.ok).toBe(true) + if (!first.ok) { + return + } + expect(ops.created).toBe(1) + const slots = await getSlots(db, userId, YEAR, ['zenki', 'kouki']) + expect(slots.length).toBe(2) + expect(slots.every(s => s.stripeInvoiceId === first.invoiceId)).toBe(true) // reserved WITH the id + + // Re-issue while unpaid → reuse the SAME invoice, no new draft. + const again = await issueWithLedger(db, { userId, activityYear: YEAR, halves: ['zenki', 'kouki'] }, ops) + expect(again.ok && again.invoiceId).toBe(first.invoiceId) + expect(ops.created).toBe(1) + }) + + it('rejects re-issue after the invoice is paid', async () => { + if (!available) { + return + } + const userId = await makeUser() + const ops = fakeOps() + const r = await issueWithLedger(db, { userId, activityYear: YEAR, halves: ['kouki'] }, ops) + expect(r.ok).toBe(true) + if (!r.ok) { + return + } + expect(await markPaidByInvoiceId(db, r.invoiceId)).toBe(1) + + const after = await issueWithLedger(db, { userId, activityYear: YEAR, halves: ['kouki'] }, ops) + expect(after).toEqual({ ok: false, rejected: 'paid' }) + }) + + it('allows 後期 after 前期-only (rule 4), but rejects 通期 overlap', async () => { + if (!available) { + return + } + const userId = await makeUser() + const ops = fakeOps() + + expect((await issueWithLedger(db, { userId, activityYear: YEAR, halves: ['zenki'] }, ops)).ok).toBe(true) + expect((await issueWithLedger(db, { userId, activityYear: YEAR, halves: ['kouki'] }, ops)).ok).toBe(true) + + const full = await issueWithLedger(db, { userId, activityYear: YEAR, halves: ['zenki', 'kouki'] }, ops) + expect(full).toEqual({ ok: false, rejected: 'conflict' }) + }) + + it('rejects a half that a full issuance already covers', async () => { + if (!available) { + return + } + const userId = await makeUser() + const ops = fakeOps() + expect((await issueWithLedger(db, { userId, activityYear: YEAR, halves: ['zenki', 'kouki'] }, ops)).ok).toBe(true) + + const kouki = await issueWithLedger(db, { userId, activityYear: YEAR, halves: ['kouki'] }, ops) + expect(kouki).toEqual({ ok: false, rejected: 'conflict' }) + }) + + it('releases the slots and voids the invoice when finalize fails', async () => { + if (!available) { + return + } + const userId = await makeUser() + const ops = fakeOps() + // Force finalize to fail: the slots must be released and the draft voided so + // no payable invoice survives and the half stays issuable. + ops.finalizeAndSend = async () => { + throw new Error('stripe finalize boom') + } + await expect(issueWithLedger(db, { userId, activityYear: YEAR, halves: ['zenki'] }, ops)).rejects.toThrow('boom') + expect((await getSlots(db, userId, YEAR, ['zenki'])).length).toBe(0) // released + const voided = [...ops.invoices.values()] + expect(voided).toContain('void') + + // The half is free again — a retry succeeds. + const retry = await issueWithLedger(db, { userId, activityYear: YEAR, halves: ['zenki'] }, fakeOps()) + expect(retry.ok).toBe(true) + }) +}) + +describe('ledger: resolveIssuance (DB, read-only)', () => { + it('returns free when nothing occupies the halves', async () => { + if (!available) { + return + } + const userId = await makeUser() + expect(await resolveIssuance(db, { userId, activityYear: YEAR, halves: ['zenki'] })).toEqual({ kind: 'free' }) + }) +}) diff --git a/packages/api/src/ledger/store.ts b/packages/api/src/ledger/store.ts new file mode 100644 index 0000000..d6a60ec --- /dev/null +++ b/packages/api/src/ledger/store.ts @@ -0,0 +1,135 @@ +import { randomUUID } from 'node:crypto' +import { and, eq, inArray } from 'drizzle-orm' +import { schema, type Database } from '@checkin/db' +import type { Half } from './coverage' + +/** Slot lifecycle. `void`/failure removes the row, so two states suffice. */ +export type SlotStatus = 'open' | 'paid' + +/** A membership ledger slot as the domain reads it. */ +export interface SlotRow { + id: string + userId: string + activityYear: number + half: Half + chargeGroup: string + stripeInvoiceId: string | null + status: SlotStatus +} + +const SLOT_COLUMNS = { + id: schema.membershipSlots.id, + userId: schema.membershipSlots.userId, + activityYear: schema.membershipSlots.activityYear, + half: schema.membershipSlots.half, + chargeGroup: schema.membershipSlots.chargeGroup, + stripeInvoiceId: schema.membershipSlots.stripeInvoiceId, + status: schema.membershipSlots.status, +} as const + +/** + * Whether an error is a MySQL/MariaDB duplicate-key (ER_DUP_ENTRY = 1062). Walks + * the `cause` chain because drizzle wraps the mysql2 error. (mirrors identity.ts) + */ +function isDuplicateKeyError(err: unknown): boolean { + let cur: unknown = err + for (let depth = 0; cur && depth < 5; depth++) { + const code = (cur as { code?: string }).code + const errno = (cur as { errno?: number }).errno + if (code === 'ER_DUP_ENTRY' || errno === 1062) { + return true + } + cur = (cur as { cause?: unknown }).cause + } + return false +} + +/** Fetch existing slots for a person/year limited to the given halves. */ +export async function getSlots( + db: Database, + userId: string, + activityYear: number, + halves: Half[], +): Promise { + if (halves.length === 0) { + return [] + } + return db + .select(SLOT_COLUMNS) + .from(schema.membershipSlots) + .where(and( + eq(schema.membershipSlots.userId, userId), + eq(schema.membershipSlots.activityYear, activityYear), + inArray(schema.membershipSlots.half, halves), + )) +} + +/** Fetch all slots of a charge group (the 1–2 halves of one issuance). */ +export async function getSlotsByChargeGroup(db: Database, chargeGroup: string): Promise { + return db + .select(SLOT_COLUMNS) + .from(schema.membershipSlots) + .where(eq(schema.membershipSlots.chargeGroup, chargeGroup)) +} + +/** + * Atomically reserve `halves` as a single new charge group (`open`), each row + * carrying the Stripe Invoice id from the start. The insert is one multi-row + * statement, so a UNIQUE `(user_id, activity_year, half)` violation on ANY half + * rolls back the whole reservation (no partial reserve). Returns the new + * `chargeGroup`, or `'conflict'` if a half was already taken. + * + * The invoice id is set at reservation time (the issuance flow creates a + * non-payable DRAFT first, reserves, THEN finalizes), so a slot is NEVER open + * with a null invoice id — the webhook can always match it, and there is no + * "orphaned reservation" to mistakenly delete. (issuance-ledger: money-safety) + */ +export async function reserveSlots( + db: Database, + input: { userId: string, activityYear: number, halves: Half[], stripeInvoiceId: string }, +): Promise<{ chargeGroup: string } | 'conflict'> { + const chargeGroup = randomUUID() + const rows = input.halves.map(half => ({ + id: randomUUID(), + userId: input.userId, + activityYear: input.activityYear, + half, + chargeGroup, + stripeInvoiceId: input.stripeInvoiceId, + status: 'open' as const, + })) + try { + await db.insert(schema.membershipSlots).values(rows) + return { chargeGroup } + } + catch (err) { + if (isDuplicateKeyError(err)) { + return 'conflict' + } + throw err + } +} + +/** + * Mark every slot of an Invoice `paid` (idempotent). Returns the affected row + * count — 0 means the invoice has no ledger slots (台帳外) and the caller treats + * the webhook as a success no-op. (issuance-ledger spec: §入金時の paid 確定) + */ +export async function markPaidByInvoiceId(db: Database, invoiceId: string, at: Date = new Date()): Promise { + const result = await db + .update(schema.membershipSlots) + .set({ status: 'paid', paidAt: at }) + .where(and( + eq(schema.membershipSlots.stripeInvoiceId, invoiceId), + eq(schema.membershipSlots.status, 'open'), + )) + const affected = (result as unknown as { affectedRows?: number })?.affectedRows ?? 0 + return Array.isArray(result) + ? ((result[0] as { affectedRows?: number })?.affectedRows ?? 0) + : affected +} + +/** Release (delete) all slots of a charge group — void / issuance failure. */ +export async function releaseByChargeGroup(db: Database, chargeGroup: string): Promise { + await db.delete(schema.membershipSlots).where(eq(schema.membershipSlots.chargeGroup, chargeGroup)) +} diff --git a/packages/api/src/router.ts b/packages/api/src/router.ts index d91ba04..9306ff5 100644 --- a/packages/api/src/router.ts +++ b/packages/api/src/router.ts @@ -13,12 +13,20 @@ import { import { computeActivityYear, computeTerm, selectPriceId } from './billing' import { createAccountOnboardingLink, + createDraftInvoice, + finalizeAndSendInvoice, getOrCreateConnectedAccount, getOrCreateCustomer, - issueInvoice, listCheckoutSessions, listInvoices, + voidInvoiceSafe, } from './stripe' +import { + halvesForCoverage, + issueWithLedger, + standardActivityYear, + standardCoverage, +} from './ledger' import { executePayout, listPayouts, @@ -149,40 +157,59 @@ export const appRouter = { variant: 'standard', }) - // Resolve the Customer (DB → Stripe search → create) and persist its id. - const row = await getUserById(context.db, user.userId) - if (!row) { - throw new ORPCError('NOT_FOUND', { message: 'user not found' }) - } - const customerId = await getOrCreateCustomer(context.stripe, context.db, { - userId: row.id, - stripeCustomerId: row.stripeCustomerId, - email: input.email, - name: input.name, - mailHash: user.mailHash, - // Stamp the authenticated traQ ID into Customer metadata when present - // (reference keys stay mail_hash / customer_id). Use the session traq_id - // ONLY when it legitimately belongs to this user (link result - // linked/exists); on conflict it is owned by someone else, so fall back - // to the user's already-linked id and never the foreign session traq_id. - traqId: linkedTraqId ?? row.traqId ?? undefined, - }) - - const activityYear = computeActivityYear(now) - const result = await issueInvoice(context.stripe, { - customerId, - priceId, - daysUntilDue: context.billing.invoiceDaysUntilDue, - // Deterministic key so a rapid double-submit dedupes within Stripe's - // idempotency window (not a cross-time issuance ledger — see design Risks). - idempotencyKey: `inv:${customerId}:${priceId}:${activityYear}:${input.feeType}:standard`, - metadata: { - fee_type: input.feeType, - term, - activity_year: String(activityYear), + // Ledger gate (issuance-ledger): determine the half-period(s) this payment + // covers and the activity year it covers (継続 → 翌年度), then issue through + // the ledger so a payable invoice never exists without a slot guard. The + // Customer is resolved lazily inside `createDraft` (only on the `free` + // path), so a rejected duplicate creates no Customer/invoice side effects. + const coverage = standardCoverage(input.feeType, term) + const activityYear = standardActivityYear(now, input.feeType) + const outcome = await issueWithLedger( + context.db, + { userId: user.userId, activityYear, halves: halvesForCoverage(coverage) }, + { + createDraft: async () => { + const row = await getUserById(context.db, user.userId) + if (!row) { + throw new ORPCError('NOT_FOUND', { message: 'user not found' }) + } + const customerId = await getOrCreateCustomer(context.stripe, context.db, { + userId: row.id, + stripeCustomerId: row.stripeCustomerId, + email: input.email, + name: input.name, + mailHash: user.mailHash, + // Stamp the authenticated traQ ID into Customer metadata when present + // (reference keys stay mail_hash / customer_id). Use the session traq_id + // ONLY when it legitimately belongs to this user (link result + // linked/exists); on conflict it is owned by someone else, so fall back + // to the user's already-linked id and never the foreign session traq_id. + traqId: linkedTraqId ?? row.traqId ?? undefined, + }) + const { invoiceId } = await createDraftInvoice(context.stripe, { + customerId, + priceId, + daysUntilDue: context.billing.invoiceDaysUntilDue, + metadata: { + fee_type: input.feeType, + term, + activity_year: String(activityYear), + }, + }) + return invoiceId + }, + finalizeAndSend: id => finalizeAndSendInvoice(context.stripe, id), + voidInvoice: id => voidInvoiceSafe(context.stripe, id), }, - }) - return result + ) + if (!outcome.ok) { + throw new ORPCError('CONFLICT', { + message: outcome.rejected === 'paid' + ? '対象の期間は既に支払い済みです。' + : '支払い対象の期間が既存の請求と重複しています。', + }) + } + return { invoiceId: outcome.invoiceId, hostedInvoiceUrl: outcome.hostedInvoiceUrl } }), /** @@ -196,6 +223,12 @@ export const appRouter = { userId: z.string().min(1), name: z.string().min(1).optional(), email: z.string().email().optional(), + // Special (¥2,000) always covers a SINGLE half — the accountant picks + // which (前期のみ=zenki / 後期追加=kouki). 通期 is not a special option. + coverage: z.enum(['zenki', 'kouki']), + // Activity year this payment covers; defaults to the current year. Pass + // the next year for a 継続特別 collected in 後期. (issuance-ledger spec) + activityYear: z.number().int().optional(), })) .handler(async ({ input, context }) => { context.assertCsrf() @@ -205,48 +238,67 @@ export const appRouter = { throw new ORPCError('NOT_FOUND', { message: 'target user not found' }) } - // Reuse the linked Customer; otherwise an email is required to create one. - if (!row.stripeCustomerId && !input.email) { - throw new ORPCError('BAD_REQUEST', { - message: 'email is required to create a Stripe Customer for this user', - }) - } - // When we will create a Customer from the supplied email, the email must - // belong to the target user (mail_hash match). Otherwise we would - // permanently link the user row to the wrong email/Customer. - if (!row.stripeCustomerId && input.email - && deriveMailHash(input.email, context.config.mailHashSecret) !== row.mailHash) { - throw new ORPCError('BAD_REQUEST', { message: 'email does not match the target user' }) - } - const customerId = await getOrCreateCustomer(context.stripe, context.db, { - userId: row.id, - stripeCustomerId: row.stripeCustomerId, - // email is only used when no Customer exists yet (guarded above). - email: input.email ?? '', - name: input.name, - mailHash: row.mailHash, - }) - + // Ledger gate (issuance-ledger): reserve the single target half through the + // ledger so a payable invoice never exists without a guard. Customer + // resolution + email validation happen lazily in `createDraft` (only on + // the `free` path), so a rejected duplicate has no side effects. const now = new Date() - const priceId = selectPriceId(context.billing, { - feeType: 'continuation', - variant: 'special', - }) - const activityYear = computeActivityYear(now) - const result = await issueInvoice(context.stripe, { - customerId, - priceId, - daysUntilDue: context.billing.invoiceDaysUntilDue, - // Deterministic key so a rapid double-submit dedupes within Stripe's - // idempotency window (not a cross-time issuance ledger — see design Risks). - idempotencyKey: `inv:${customerId}:${priceId}:${activityYear}:continuation:special`, - metadata: { - fee_type: 'continuation', - variant: 'special', - activity_year: String(activityYear), + const activityYear = input.activityYear ?? computeActivityYear(now) + const outcome = await issueWithLedger( + context.db, + { userId: row.id, activityYear, halves: halvesForCoverage(input.coverage) }, + { + createDraft: async () => { + // Reuse the linked Customer; otherwise an email is required to create one. + if (!row.stripeCustomerId && !input.email) { + throw new ORPCError('BAD_REQUEST', { + message: 'email is required to create a Stripe Customer for this user', + }) + } + // When we will create a Customer from the supplied email, the email must + // belong to the target user (mail_hash match). Otherwise we would + // permanently link the user row to the wrong email/Customer. + if (!row.stripeCustomerId && input.email + && deriveMailHash(input.email, context.config.mailHashSecret) !== row.mailHash) { + throw new ORPCError('BAD_REQUEST', { message: 'email does not match the target user' }) + } + const customerId = await getOrCreateCustomer(context.stripe, context.db, { + userId: row.id, + stripeCustomerId: row.stripeCustomerId, + // email is only used when no Customer exists yet (guarded above). + email: input.email ?? '', + name: input.name, + mailHash: row.mailHash, + }) + const priceId = selectPriceId(context.billing, { + feeType: 'continuation', + variant: 'special', + }) + const { invoiceId } = await createDraftInvoice(context.stripe, { + customerId, + priceId, + daysUntilDue: context.billing.invoiceDaysUntilDue, + metadata: { + fee_type: 'continuation', + variant: 'special', + coverage: input.coverage, + activity_year: String(activityYear), + }, + }) + return invoiceId + }, + finalizeAndSend: id => finalizeAndSendInvoice(context.stripe, id), + voidInvoice: id => voidInvoiceSafe(context.stripe, id), }, - }) - return result + ) + if (!outcome.ok) { + throw new ORPCError('CONFLICT', { + message: outcome.rejected === 'paid' + ? '対象の期間は既に支払い済みです。' + : '支払い対象の期間が既存の請求と重複しています。', + }) + } + return { invoiceId: outcome.invoiceId, hostedInvoiceUrl: outcome.hostedInvoiceUrl } }), }, diff --git a/packages/api/src/stripe/invoices.ts b/packages/api/src/stripe/invoices.ts index 2c5d628..94b8e01 100644 --- a/packages/api/src/stripe/invoices.ts +++ b/packages/api/src/stripe/invoices.ts @@ -1,7 +1,7 @@ import type { StripeClient } from './client' -/** Inputs for issuing a single-line invoice for one Price to a Customer. */ -export interface IssueInvoiceInput { +/** Inputs for creating a single-line draft invoice for one Price to a Customer. */ +export interface CreateDraftInput { /** Target Stripe Customer id (resolved via get-or-create). */ customerId: string /** The Price id chosen by the domain (費目・期・区分 → price). */ @@ -10,11 +10,6 @@ export interface IssueInvoiceInput { daysUntilDue: number /** Optional metadata (e.g. activity-year label) attached to the Invoice. */ metadata?: Record - /** - * Optional deterministic Stripe idempotency key. Dedupes a rapid double-submit - * within Stripe's window; it is not a cross-time issuance ledger (design Risks). - */ - idempotencyKey?: string } /** What the caller needs to direct the user to the hosted payment page. */ @@ -24,65 +19,97 @@ export interface IssuedInvoice { } /** - * Create a one-line Invoice for the given Price, finalize it, and send it. + * Create a one-line **draft** Invoice for the given Price — NOT finalized, so it + * is not yet payable. The issuance ledger reserves a slot carrying this id before + * the invoice is finalized, so a payable invoice never exists without a guard. + * (issuance-ledger: money-safety — draft before reserve before finalize) * - * Steps (membership-billing spec: §請求書の作成・確定・送付): - * 1. Create a draft Invoice for the Customer (`send_invoice` collection). - * 2. Attach an InvoiceItem for the Price to that draft. - * 3. Finalize → send. The returned `hosted_invoice_url` is the payment page. + * Deliberately NOT idempotent: each call mints a fresh invoice so two concurrent + * issuers get DIFFERENT ids — the reservation race then has one winner and the + * loser voids its own (distinct) draft. A shared idempotency key would make the + * loser void the winner's invoice. The ledger is the duplicate-payment guard. + * If the item step fails the orphaned draft is deleted before rethrowing. */ -export async function issueInvoice( - stripe: StripeClient, - input: IssueInvoiceInput, -): Promise { - // The idempotency key (when supplied) is passed as Stripe request options so a - // rapid double-submit collapses to a single invoice within Stripe's window. - const requestOptions = input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : undefined - - // 1. Draft invoice. `send_invoice` + days_until_due gives the user a payable - // hosted invoice (vs charge_automatically which needs a saved payment method). +export async function createDraftInvoice(stripe: StripeClient, input: CreateDraftInput): Promise<{ invoiceId: string }> { const draft = await stripe.sdk.invoices.create({ customer: input.customerId, collection_method: 'send_invoice', days_until_due: input.daysUntilDue, metadata: input.metadata, - }, requestOptions) + }) if (!draft.id) { throw new Error('Stripe did not return an invoice id') } const invoiceId = draft.id - // Once the draft exists, clean it up if any later step fails so we don't leave - // an orphaned draft/finalized invoice behind, then rethrow the original error. try { - // 2. Add the single priced line to the draft. await stripe.sdk.invoiceItems.create({ customer: input.customerId, invoice: invoiceId, pricing: { price: input.priceId }, - }, requestOptions) - - // 3. Finalize then send so the customer receives the hosted invoice / email. - await stripe.sdk.invoices.finalizeInvoice(invoiceId) - const sent = await stripe.sdk.invoices.sendInvoice(invoiceId) + }) + return { invoiceId } + } + catch (err) { + // Delete the still-draft invoice so a failed item doesn't orphan it. + await stripe.sdk.invoices.del(invoiceId).catch(() => {}) + throw err + } +} - return { invoiceId: sent.id ?? invoiceId, hostedInvoiceUrl: sent.hosted_invoice_url ?? null } +/** + * Make an invoice payable and return its hosted payment page. Idempotent: a draft + * is finalized and sent (once); an already-finalized invoice's URL is returned + * WITHOUT re-sending the email (so reusing an open invoice doesn't spam). Also + * recovers a crash-stuck draft when an open ledger slot is reused. + * + * MONEY-SAFETY INVARIANT: this throws ONLY when the invoice is still a draft + * (NOT payable). Once `finalizeInvoice` makes it payable, a failing `sendInvoice` + * (email) is swallowed — the hosted URL works regardless — so the caller never + * voids/releases a payable invoice. A concurrent finalize that already made it + * payable is detected by re-retrieving and returning its URL rather than erroring. + * (issuance-ledger: §再利用 / draft-only-throw invariant) + */ +export async function finalizeAndSendInvoice(stripe: StripeClient, invoiceId: string): Promise { + const invoice = await stripe.sdk.invoices.retrieve(invoiceId) + if (invoice.status !== 'draft') { + return { invoiceId: invoice.id ?? invoiceId, hostedInvoiceUrl: invoice.hosted_invoice_url ?? null } + } + try { + const finalized = await stripe.sdk.invoices.finalizeInvoice(invoiceId) + // Email is best-effort: a send failure must NOT fail issuance now that the + // invoice is payable (else the caller would void a valid payable invoice). + await stripe.sdk.invoices.sendInvoice(invoiceId).catch(() => {}) + return { invoiceId: finalized.id ?? invoiceId, hostedInvoiceUrl: finalized.hosted_invoice_url ?? null } } catch (err) { - // Best-effort cleanup: delete a still-draft invoice, or void it if finalized. - // Swallow cleanup failures so the original error is what surfaces. - try { - const current = await stripe.sdk.invoices.retrieve(invoiceId) - if (current.status === 'draft') { - await stripe.sdk.invoices.del(invoiceId) - } - else { - await stripe.sdk.invoices.voidInvoice(invoiceId) - } - } - catch { - // ignore cleanup errors; surface the original failure below + // A concurrent issuer may have finalized it already (→ now payable): return + // its URL instead of erroring. Still draft ⇒ a genuine finalize failure (the + // invoice is NOT payable), so rethrow and let the caller void + release. + const after = await stripe.sdk.invoices.retrieve(invoiceId) + if (after.status !== 'draft') { + return { invoiceId: after.id ?? invoiceId, hostedInvoiceUrl: after.hosted_invoice_url ?? null } } throw err } } + +/** + * Best-effort discard of a NON-paid invoice: delete a draft, void a finalized + * (unpaid) one, no-op on paid/void. Used when a reservation race or a finalize + * failure means a created invoice must not survive. Never throws. + */ +export async function voidInvoiceSafe(stripe: StripeClient, invoiceId: string): Promise { + try { + const invoice = await stripe.sdk.invoices.retrieve(invoiceId) + if (invoice.status === 'draft') { + await stripe.sdk.invoices.del(invoiceId) + } + else if (invoice.status !== 'paid' && invoice.status !== 'void') { + await stripe.sdk.invoices.voidInvoice(invoiceId) + } + } + catch { + // best-effort; swallow + } +} diff --git a/packages/api/src/stripe/webhook.ts b/packages/api/src/stripe/webhook.ts index e5044d8..937ea1e 100644 --- a/packages/api/src/stripe/webhook.ts +++ b/packages/api/src/stripe/webhook.ts @@ -5,6 +5,8 @@ import type { StripeClient } from './client' export interface VerifiedEvent { id: string type: string + /** The id of the event's data object (e.g. the Invoice id for `invoice.paid`), or null. */ + objectId: string | null } /** @@ -26,5 +28,6 @@ export function constructEvent( throw new Error('STRIPE_WEBHOOK_SECRET is not set; refusing to verify webhook') } const event: Stripe.Event = stripe.sdk.webhooks.constructEvent(rawBody, signature, webhookSecret) - return { id: event.id, type: event.type } + const object = event.data?.object as { id?: string } | undefined + return { id: event.id, type: event.type, objectId: object?.id ?? null } } diff --git a/packages/db/drizzle/0007_high_vermin.sql b/packages/db/drizzle/0007_high_vermin.sql new file mode 100644 index 0000000..2d0855a --- /dev/null +++ b/packages/db/drizzle/0007_high_vermin.sql @@ -0,0 +1,16 @@ +CREATE TABLE `membership_slots` ( + `id` varchar(36) NOT NULL, + `user_id` varchar(36) NOT NULL, + `activity_year` int NOT NULL, + `half` enum('zenki','kouki') NOT NULL, + `charge_group` varchar(36) NOT NULL, + `stripe_invoice_id` varchar(255), + `status` enum('open','paid') NOT NULL DEFAULT 'open', + `paid_at` timestamp, + `created_at` timestamp NOT NULL DEFAULT (now()), + `updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `membership_slots_id` PRIMARY KEY(`id`), + CONSTRAINT `membership_slots_user_year_half_uq` UNIQUE(`user_id`,`activity_year`,`half`) +); +--> statement-breakpoint +ALTER TABLE `membership_slots` ADD CONSTRAINT `membership_slots_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE no action ON UPDATE no action; \ No newline at end of file diff --git a/packages/db/drizzle/meta/0007_snapshot.json b/packages/db/drizzle/meta/0007_snapshot.json new file mode 100644 index 0000000..1c4119e --- /dev/null +++ b/packages/db/drizzle/meta/0007_snapshot.json @@ -0,0 +1,556 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "5f9f129b-bdb5-4917-9eda-008fc38c009f", + "prevId": "687a0892-e654-400f-a672-305388fd4d60", + "tables": { + "email_verifications": { + "name": "email_verifications", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mail_hash": { + "name": "mail_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect": { + "name": "redirect", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "email_verifications_id": { + "name": "email_verifications_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "email_verifications_token_hash_unique": { + "name": "email_verifications_token_hash_unique", + "columns": [ + "token_hash" + ] + } + }, + "checkConstraint": {} + }, + "membership_slots": { + "name": "membership_slots", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "activity_year": { + "name": "activity_year", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "half": { + "name": "half", + "type": "enum('zenki','kouki')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "charge_group": { + "name": "charge_group", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('open','paid')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "membership_slots_user_id_users_id_fk": { + "name": "membership_slots_user_id_users_id_fk", + "tableFrom": "membership_slots", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "membership_slots_id": { + "name": "membership_slots_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "membership_slots_user_year_half_uq": { + "name": "membership_slots_user_year_half_uq", + "columns": [ + "user_id", + "activity_year", + "half" + ] + } + }, + "checkConstraint": {} + }, + "payouts": { + "name": "payouts", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "jomon_ref": { + "name": "jomon_ref", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "amount": { + "name": "amount", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "currency": { + "name": "currency", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('pending','onboarding_waiting','processing','paid','failed')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "stripe_transfer_id": { + "name": "stripe_transfer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "jomon_written_back_at": { + "name": "jomon_written_back_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "payouts_user_id_users_id_fk": { + "name": "payouts_user_id_users_id_fk", + "tableFrom": "payouts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "payouts_id": { + "name": "payouts_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "payouts_jomon_ref_unique": { + "name": "payouts_jomon_ref_unique", + "columns": [ + "jomon_ref" + ] + } + }, + "checkConstraint": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id_hash": { + "name": "id_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "traq_id": { + "name": "traq_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sessions_id": { + "name": "sessions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "sessions_id_hash_unique": { + "name": "sessions_id_hash_unique", + "columns": [ + "id_hash" + ] + } + }, + "checkConstraint": {} + }, + "stripe_events": { + "name": "stripe_events", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "received_at": { + "name": "received_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "stripe_events_id": { + "name": "stripe_events_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "stripe_events_event_id_unique": { + "name": "stripe_events_event_id_unique", + "columns": [ + "event_id" + ] + } + }, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mail_hash": { + "name": "mail_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "traq_id": { + "name": "traq_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripe_connected_account_id": { + "name": "stripe_connected_account_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payout_onboarding_status": { + "name": "payout_onboarding_status", + "type": "enum('none','requested','done')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'none'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "users_mail_hash_unique": { + "name": "users_mail_hash_unique", + "columns": [ + "mail_hash" + ] + }, + "users_traq_id_unique": { + "name": "users_traq_id_unique", + "columns": [ + "traq_id" + ] + }, + "users_stripe_connected_account_id_unique": { + "name": "users_stripe_connected_account_id_unique", + "columns": [ + "stripe_connected_account_id" + ] + } + }, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 18774fc..2530ae6 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1781959782887, "tag": "0006_wild_nemesis", "breakpoints": true + }, + { + "idx": 7, + "version": "5", + "when": 1782186295450, + "tag": "0007_high_vermin", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 59347d2..4fac596 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -1,4 +1,4 @@ -import { mysqlTable, varchar, timestamp, mysqlEnum, int, boolean } from 'drizzle-orm/mysql-core' +import { mysqlTable, varchar, timestamp, mysqlEnum, int, boolean, unique } from 'drizzle-orm/mysql-core' /** * Auth / identity foundation (OpenSpec change: add-auth-foundation). @@ -138,3 +138,40 @@ export const payouts = mysqlTable('payouts', { createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow().onUpdateNow(), }) + +/** + * Membership issuance/payment ledger — half-period slots (OpenSpec change: + * add-issuance-ledger). Prevents paying twice for the same membership coverage. + * + * One row = one occupied half-slot of an activity year for a person. The UNIQUE + * `(user_id, activity_year, half)` is the duplicate-payment guard: a person can + * hold at most one slot per (year, half). A 通期 issuance occupies BOTH halves + * (two rows sharing `charge_group` + `stripe_invoice_id`); a 半期 issuance one. + * + * `activity_year` is the year the payment COVERS (継続 collected in 後期 covers + * next year), not the issuance time. coverage: ¥4,000=通期 (both halves), + * ¥2,000=one half. Issuance reserves the slot(s) `open` before the Stripe call, + * writes back `stripe_invoice_id` on success, and frees them (DELETE) on + * void/failure. `invoice.paid` flips the charge's slots to `paid`. (issuance-ledger spec) + */ +export const membershipSlots = mysqlTable('membership_slots', { + id: varchar('id', { length: 36 }).primaryKey(), + userId: varchar('user_id', { length: 36 }).notNull().references(() => users.id), + // Activity year the payment covers (4/1–3/31). 継続 in 後期 covers year+1. + activityYear: int('activity_year').notNull(), + // Which half of the year this slot covers. + half: mysqlEnum('half', ['zenki', 'kouki']).notNull(), + // Groups the 1–2 half-slots of a single issuance (通期 = two rows share this). + chargeGroup: varchar('charge_group', { length: 36 }).notNull(), + // Stripe Invoice id; NULL between reservation and successful Stripe issuance. + stripeInvoiceId: varchar('stripe_invoice_id', { length: 255 }), + // Slot lifecycle: open (reserved/issued) → paid. void/failure = row DELETED + // (frees the half), so two states suffice and stay compatible with the UNIQUE. + status: mysqlEnum('status', ['open', 'paid']).notNull().default('open'), + paidAt: timestamp('paid_at'), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow().onUpdateNow(), +}, table => [ + // The duplicate-payment guard: at most one slot per person/year/half. + unique('membership_slots_user_year_half_uq').on(table.userId, table.activityYear, table.half), +]) From 3447e3fe97ace5edcc8fe430d4af5c6b7bca6e24 Mon Sep 17 00:00:00 2001 From: kaitoyama Date: Tue, 23 Jun 2026 15:29:49 +0900 Subject: [PATCH 12/30] =?UTF-8?q?feat:=20bank=20transfer=20(=E5=8F=A3?= =?UTF-8?q?=E5=BA=A7=E6=8C=AF=E8=BE=BC)=20for=20membership=20invoices?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Offer customer_balance / jp_bank_transfer alongside card on every membership invoice (createDraftInvoice payment_settings), so members can pay dues by bank transfer. Settlement is async (invoice stays open until funds arrive) and rejoins the existing invoice.paid path — issuance-ledger paid-confirmation, accountant notification, and the duplicate-payment guard are unchanged; it only adds a payment method. OpenSpec: add-bank-transfer-payment (membership-billing delta synced to specs + archived). Codex money-safety review: no blocking issues. Verified end-to-end in Stripe test mode: issue -> hosted page offers card + 銀行振込 -> simulated transfer -> invoice.paid -> both ledger slots paid + accountant notified; paid re-issue rejected (409). Co-Authored-By: Claude Opus 4.8 (1M context) --- HANDOVER.md | 6 +- .../.openspec.yaml | 2 + .../design.md | 76 +++++++++++++++ .../proposal.md | 27 ++++++ .../specs/membership-billing/spec.md | 45 +++++++++ .../tasks.md | 20 ++++ openspec/specs/membership-billing/spec.md | 12 +++ packages/api/src/stripe/invoices.test.ts | 92 +++++++++++++++++++ packages/api/src/stripe/invoices.ts | 16 ++++ 9 files changed, 293 insertions(+), 3 deletions(-) create mode 100644 openspec/changes/archive/2026-06-23-add-bank-transfer-payment/.openspec.yaml create mode 100644 openspec/changes/archive/2026-06-23-add-bank-transfer-payment/design.md create mode 100644 openspec/changes/archive/2026-06-23-add-bank-transfer-payment/proposal.md create mode 100644 openspec/changes/archive/2026-06-23-add-bank-transfer-payment/specs/membership-billing/spec.md create mode 100644 openspec/changes/archive/2026-06-23-add-bank-transfer-payment/tasks.md create mode 100644 packages/api/src/stripe/invoices.test.ts diff --git a/HANDOVER.md b/HANDOVER.md index 168924f..f5ca889 100644 --- a/HANDOVER.md +++ b/HANDOVER.md @@ -9,7 +9,7 @@ traP の Stripe 集金・払い戻しシステム「Checkin」。design.md(リ 第一弾スコープ(①集金 ②入出金一覧 ③払い戻し)+認証基盤+利用者 UI+会計 UI+**重複支払い防止(発行台帳)**を **OpenSpec 仕様駆動**で実装済み。 - **全ゲート緑**: `pnpm lint` / `pnpm typecheck` / `pnpm build` / `pnpm test`(120 tests)。 -- **10 changes をアーカイブ済み**(`openspec/changes/archive/`)、**13 specs**(`openspec/specs/`)。 +- **11 changes をアーカイブ済み**(`openspec/changes/archive/`)、**13 specs**(`openspec/specs/`)。 - **未コミット差分あり**(`add-accountant-ui` + `add-issuance-ledger` + dev 環境)。**push / PR は未実施**。 - 直近: `add-issuance-ledger`(半期スロット台帳で会員費の二重払いを拒否)。**実 Stripe で E2E 済み**(発行→支払い→再発行拒否/未払い時 URL 再利用/通期×半期の重複拒否)。Codex 2 周レビューで money-safety(draft-first 順序)を確定。 - **ローカル dev 構成あり**(下記 §10): `.env`+`apps/web/.env` symlink、Dev ログイン `/dev/login`、Stripe test キー+4 Price+`stripe listen` 配線済み。 @@ -53,7 +53,7 @@ traP の Stripe 集金・払い戻しシステム「Checkin」。design.md(リ | session | add-auth-foundation (+traq) | **デュアル・アイデンティティ**: `{ traqId, isAdmin, userId, mailHash }`。`__Host-` cookie+double-submit CSRF。`requireMember/User/Admin` | | admin-authorization | add-auth-foundation (+traq) | traQ OAuth(PKCE)=**会員セッション**。会計は env 許可リスト・サブセット(`isAdmin`) | | stripe-customer | add-membership-collection | Customer get-or-create(DB→検索→作成、競合安全)。アダプタ境界 | -| membership-billing | add-membership-collection (+traq) | 期判定(前期4-9/後期10-3、活動年度4/1-3/31)→ price 選択。`issueInvoice`(本人=mail_hash一致) / `issueSpecialInvoice`(会計のみ ¥2,000)。支払い時に traq_id 連結 | +| membership-billing | add-membership-collection (+traq, +bank-transfer) | 期判定(前期4-9/後期10-3、活動年度4/1-3/31)→ price 選択。`issueInvoice`(本人=mail_hash一致) / `issueSpecialInvoice`(会計のみ ¥2,000)。支払い時に traq_id 連結。**請求書はカード+口座振込(`customer_balance`/`jp_bank_transfer`)両対応**(`add-bank-transfer-payment`、`createDraftInvoice` の `payment_settings`。着金は非同期で `invoice.paid` 経路に合流) | | payment-webhook | add-membership-collection | `invoice.paid` 署名検証+event 冪等+会計通知(Notifier) | | payment-listing | add-payment-listing | 会計のみ `payments.listInvoices`/`listCheckoutSessions`(Stripe 由来、カーソル、Dashboard URL) | | connect-onboarding | add-connect-onboarding | Connect connected account JIT onboarding。`account.updated` でフラグ判定 → `payout_onboarding_status`(none/requested/done) | @@ -141,7 +141,7 @@ pnpm test # vitest(DB バックドテストは Maria 2. Stripe/Connect の test キーが入ったら各 BLOCKED E2E を消化(残作業 B)。会計 UI(`/payments`・`/payouts`)も実データで E2E(`add-accountant-ui` の tasks 4.3/4.4)。 3. `stripe_customer_id` unique 化(残作業 D)。 4. (任意)特別請求書発行 UI(会計が `coverage`/`activityYear` 指定)+ user 検索 API(残作業 C の残り)。 -5. (任意)銀行振込(`customer_balance`/`jp_bank_transfer`、請求書ごとに付与)=別 change。 +5. ~~(任意)銀行振込(`customer_balance`/`jp_bank_transfer`)~~ → **完了**(`add-bank-transfer-payment`、全請求書をカード+口座振込の両対応に)。残: Stripe アカウントで JPY+日本の銀行振込を有効化のうえ test mode E2E(archive の tasks 4.x が BLOCKED)。 ## 10. ローカル dev 環境(このセッションで構築・未コミット) diff --git a/openspec/changes/archive/2026-06-23-add-bank-transfer-payment/.openspec.yaml b/openspec/changes/archive/2026-06-23-add-bank-transfer-payment/.openspec.yaml new file mode 100644 index 0000000..a4ac4d7 --- /dev/null +++ b/openspec/changes/archive/2026-06-23-add-bank-transfer-payment/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-23 diff --git a/openspec/changes/archive/2026-06-23-add-bank-transfer-payment/design.md b/openspec/changes/archive/2026-06-23-add-bank-transfer-payment/design.md new file mode 100644 index 0000000..157d52d --- /dev/null +++ b/openspec/changes/archive/2026-06-23-add-bank-transfer-payment/design.md @@ -0,0 +1,76 @@ +## Context + +集金([[add-membership-collection]] + [[add-issuance-ledger]])は、本人の Stripe Customer に対し draft-first で Invoice を作り(`createDraftInvoice`)、台帳スロットを予約してから finalize(`finalizeAndSendInvoice`)し、`hosted_invoice_url` を返す。現状 `createDraftInvoice` は `payment_settings` を指定しておらず、支払い手段は Stripe アカウント既定(実質カード)に委ねている。 + +口座振込(銀行振込)を足したい。Stripe では Customer の **`customer_balance`** 支払い手段(funding=`jp_bank_transfer`)で実現する:Invoice 確定時に Stripe が**専用のバーチャル口座(振込先)**を発行し hosted invoice ページに表示、利用者が振り込むと着金が Customer の cash balance に入り、その Invoice に**自動充当**され `invoice.paid` が発火する。 + +制約・前提: +- **money-safety 不変条件は不変**([[issuance-ledger]]): draft → スロット予約 → finalize の順序、draft-only-throw、台帳の `UNIQUE(user_id, activity_year, half)` による dedup。支払い手段はこれらに直交。 +- **Stripe boundary はアダプタ層に隔離**(design D3): 支払い手段の設定は `stripe/invoices.ts` 内で完結し、billing/ledger ドメインや oRPC 入力には漏らさない。 +- **着金が非同期**: 口座振込は即時決済ではない。Invoice は着金まで `open`。[[issuance-ledger]] の「未払いは同一 URL 再利用」がこの待ち時間をそのまま吸収する。 +- **入金確定は既存経路**: 口座振込でも着金後に `invoice.paid` が出るため、[[payment-webhook]] の署名検証・冪等・会計通知・台帳 paid 確定はそのまま再利用でき、手段を区別しない。 + +## Goals / Non-Goals + +**Goals:** +- 集金で発行する全 Invoice(標準・特別)がカードと口座振込の両方で払えるようにする。 +- 既存の発行順序・重複防止・本人連結・入金確定経路を一切変えずに、支払い手段だけを追加する。 +- アダプタ層の最小変更(1 関数の create 引数追加)で実現し、ユニットテストで設定を固定する。 + +**Non-Goals:** +- コンビニ払い(`konbini`)・Checkout Session 経路の追加。 +- 払い戻し(Connect connected account / payout)側への変更(無関係)。 +- cash balance の手動 reconciliation・過不足の返金フロー(Stripe 既定の automatic reconciliation に委ねる)。 +- 口座振込の独自 UI(hosted invoice ページが Stripe 側で振込先を提示)。 +- 期判定・price 選択・coverage ロジックの変更。 + +## Decisions + +### D1: `customer_balance` + `jp_bank_transfer` を Invoice の `payment_settings` で指定 +`createDraftInvoice` の `invoices.create` に以下を追加する: + +``` +payment_settings: { + payment_method_types: ['card', 'customer_balance'], + payment_method_options: { + customer_balance: { + bank_transfer: { type: 'jp_bank_transfer' }, + funding_type: 'bank_transfer', + }, + }, +}, +``` + +- **理由**: Invoice 単位で支払い手段を確定でき、finalize 時に Stripe が口座振込の専用振込先(funding instructions)を生成して hosted ページに出す。`['card', 'customer_balance']` の併記で利用者がページ上で選べる(ユーザー決定)。 +- **Customer 追加設定は不要**: Invoice に `customer_balance` を載せれば finalize 時に Invoice 用の funding instructions が出る。cash balance の reconciliation は Stripe 既定(automatic)で着金を未充当 Invoice に充てる。 +- **代替案**: (a) Checkout Session に `customer_balance` → 既存は send_invoice/hosted invoice 経路なので Invoice 側に寄せる方が一貫。(b) Customer の `invoice_settings.default_payment_method` で手段固定 → 利用者が選べず、カード併用が崩れる。 + +### D2: アダプタ層だけで完結(ドメイン・API・台帳は不変) +支払い手段はビジネスルールではなく Invoice の生成属性なので、`stripe/invoices.ts` に閉じる。`CreateDraftInput`・`issueInvoice`/`issueSpecialInvoice`・oRPC 入力・台帳には手を入れない。 +- **理由**: 「カード+口座振込を全 Invoice に」はユーザー決定であり手段の分岐がない。1 か所で固定すれば標準・特別の両発行に自動適用され、money-safety 不変条件(draft-first 等)に触れずに済む。 +- **代替案**: oRPC 入力に `method` を足して請求書ごとに切替(AskUserQuestion の選択肢3)→ 今回は両方固定なので不要。将来オプトインが必要になれば `CreateDraftInput` に手段を足す小 change で対応。 + +### D3: 入金確定・通知・台帳 paid は既存 `invoice.paid` 経路を再利用(変更なし) +口座振込の着金でも Stripe は `invoice.paid` を出す。[[payment-webhook]] は手段非依存(「入金時」)なので、署名検証・event 冪等・会計通知・台帳 paid 確定はそのまま動く。 +- **検証**: 口座振込で paid になった Invoice も `customer`・`metadata`(traQ 連結等)を従来同様に持つことを E2E で確認(Webhook ハンドラのキー項目が欠けないこと)。コード変更は無いが、テスト項目として明示する。 + +### D4: 非同期着金は「未払い=open」を [[issuance-ledger]] が吸収 +口座振込は着金まで `open`。台帳スロットは発行時に予約済みで、同一条件の再要求は「未払いの同一発行 → 既存 URL 返却」に落ちる。重複振込先の濫造や二重スロットは台帳の一意制約が防ぐ。 +- **理由**: 追加の pending 状態管理を作らず、既存の open-invoice 再利用モデルにそのまま乗る。 + +## Risks / Trade-offs + +- **Stripe アカウントで Bank transfers(Customer Balance)未有効だと finalize/funding が失敗** → ダッシュボードで有効化が前提(test mode は利用可)。proposal の「前提」に明記。コード側の前提エラーは Stripe からのエラーで顕在化。 +- **着金が遅い/部分入金・過入金で cash balance に残る** → Stripe 既定の automatic reconciliation が未充当 Invoice に充てる。過不足の手動処理は本スコープ外(会計が Stripe ダッシュボードで確認)。 +- **`jp_bank_transfer` の最低額・利用条件** → 部費(¥2,000/¥4,000)は通常問題ない見込み。test mode で実値確認(E2E)。 +- **hosted invoice ページの実描画は実 Stripe でしか確認できない**(HANDOVER §8 の UI 実描画方針に同じ)→ ユニットは create 引数を検証し、振込先表示は test mode E2E で確認。 + +## Migration Plan + +- 変更は `createDraftInvoice` の create 引数追加のみ(後方互換、DB・env 変更なし)。 +- ロールバック: `payment_settings` を除けば従来(カードのみ)に戻る。 +- 検証ゲート: `pnpm lint` / `typecheck` / `build` / `test`。実 test mode で発行→hosted ページ口座振込表示→テスト着金→`invoice.paid`→台帳 paid 確定の E2E(HANDOVER §10 の dev 構成+[[payout-e2e-stripe]] 系の足場)。 + +## Open Questions + +- なし(手段はカード+口座振込で固定、reconciliation は Stripe 既定。将来オプトイン化・過不足返金フローは別 change 候補)。 diff --git a/openspec/changes/archive/2026-06-23-add-bank-transfer-payment/proposal.md b/openspec/changes/archive/2026-06-23-add-bank-transfer-payment/proposal.md new file mode 100644 index 0000000..bda1863 --- /dev/null +++ b/openspec/changes/archive/2026-06-23-add-bank-transfer-payment/proposal.md @@ -0,0 +1,27 @@ +## Why + +部費の集金で、現状の請求書はカード払いしか想定していない(`createDraftInvoice` は `payment_method_types` を指定せず Stripe 既定=カードに委ねている)。カード手数料を避けたい利用者・会計のために、**口座振込(銀行振込)**でも部費を払えるようにする。Stripe では Customer の `customer_balance`(funding=`jp_bank_transfer`)として実現でき、入金確定は既存の `invoice.paid` Webhook 経路にそのまま乗る([[payment-webhook]])。HANDOVER §9-5 が「別 change」として挙げていた項目。 + +## What Changes + +- **請求書がカード+口座振込の両方で払える**: 集金で発行する Invoice の `payment_settings.payment_method_types` を `['card', 'customer_balance']` にし、`payment_method_options.customer_balance` を `{ bank_transfer: { type: 'jp_bank_transfer' }, funding_type: 'bank_transfer' }` に設定する。利用者は hosted invoice ページでカードか口座振込を選べる。 +- **口座振込は非同期入金**: 利用者は hosted invoice ページに表示される**専用の振込先(バーチャル口座)**へ振り込む。着金まで Invoice は `open` のまま。Stripe が着金を Customer の cash balance に反映し Invoice へ自動充当 → 既存の `invoice.paid` が発火し、会計通知・発行台帳の paid 確定([[issuance-ledger]])がそのまま走る。 +- **発行台帳・本人連結はそのまま**: draft-first 発行順序([[issuance-ledger]] の money-safety 不変条件)・期判定・price 選択・traQ ID 連結は変更しない。口座振込は支払い**手段**の追加であり、重複防止や本人特定のロジックには触れない。 +- 標準発行(`issueInvoice`)と特別発行(`issueSpecialInvoice`)の両方に等しく適用する(請求書作成アダプタ 1 か所で設定するため自動的に両対応)。 + +明確に**スコープ外**: コンビニ払い(`konbini`)・Checkout Session 経路。払い戻し(Connect connected account)側=こちらは別系統で無関係。cash balance の手動 reconciliation/過不足返金フロー(Stripe 既定の automatic reconciliation に委ねる)。口座振込専用 UI(hosted invoice ページが Stripe 側で提供)。 + +## Capabilities + +### New Capabilities + + +### Modified Capabilities +- `membership-billing`: 「請求書の作成・確定・送付」要件に、発行する Invoice が**カードと口座振込(`jp_bank_transfer`)の両方**で支払える(`customer_balance` 経由)こと、口座振込は非同期着金で Invoice が着金まで `open` を維持しつつ着金後は既存の入金確定経路に合流することを追記。 + +## Impact + +- **API(`packages/api`)**: `stripe/invoices.ts` の `createDraftInvoice` で `payment_settings`(`payment_method_types` + `payment_method_options.customer_balance.bank_transfer = jp_bank_transfer` / `funding_type = bank_transfer`)を設定。ドメイン(billing/ledger)・oRPC 入力は不変(手段はアダプタ層で完結)。 +- **Web/Nitro(`apps/web`)**: 変更なし(hosted invoice ページが Stripe 側で振込先を表示)。`invoice.paid` Webhook 経路は既存のまま再利用。 +- **設定/前提**: Stripe アカウントで「Bank transfers(Customer Balance)」が有効である必要(ダッシュボード設定。test mode は利用可)。新規 env なし。 +- **テスト/検証**: `createDraftInvoice` が口座振込オプション付きで Invoice を作ることをユニットテスト(Stripe モックの呼び出し引数を検証)。実 test mode で発行→hosted ページに口座振込が出る→テスト着金(Stripe の cash-balance 加算ヘルパ)→`invoice.paid`→台帳 paid 確定まで E2E(HANDOVER §10 の dev 構成上)。 diff --git a/openspec/changes/archive/2026-06-23-add-bank-transfer-payment/specs/membership-billing/spec.md b/openspec/changes/archive/2026-06-23-add-bank-transfer-payment/specs/membership-billing/spec.md new file mode 100644 index 0000000..26816ac --- /dev/null +++ b/openspec/changes/archive/2026-06-23-add-bank-transfer-payment/specs/membership-billing/spec.md @@ -0,0 +1,45 @@ +## MODIFIED Requirements + +### Requirement: 請求書の作成・確定・送付 + +システムは、対象 Customer に対し Invoice + InvoiceItem(選択した Price)を作成し、finalize して支払い可能にする SHALL。利用者は Stripe の支払いページ(`hosted_invoice_url`)で支払える MUST。請求書はドメイン非依存のアダプタ越しに作成する。 + +発行する Invoice は**カードと口座振込(銀行振込)の両方**で支払える SHALL:`payment_method_types` にカードと `customer_balance` を含め、`customer_balance` の funding を `jp_bank_transfer`(`funding_type = bank_transfer`)に設定する MUST。利用者は hosted invoice ページでいずれかの手段を選べる。口座振込は**非同期着金**であり、Invoice は着金まで `open` を維持する SHALL:利用者は表示される専用振込先へ振り込み、着金が Customer 残高に反映され Invoice へ充当されたとき、その入金は既存の入金確定経路([[payment-webhook]]:`invoice.paid` → 会計通知・[[issuance-ledger]] の paid 確定)に合流する MUST。支払い手段の追加は重複防止・本人特定・発行順序のロジックを変更しては SHALL NOT。 + +発行は **発行台帳([[issuance-ledger]])のゲート越し**に行う SHALL。費目・期・区分から coverage(埋める半期)を決定し、対象半期を台帳で判定する MUST: +- 対象半期が **支払い済み**なら発行せず拒否する(既に支払い済み)。 +- 対象半期が**未払いの同一発行(open)**で占有されているなら、新規発行せず**既存の支払いページ URL を返す**。 +- 対象半期が既存発行と**期間重複**するなら拒否する。 +- いずれでもなければ発行する。 + +発行は **支払い可能な Invoice がそれを守る台帳スロットを必ず伴う**順序で行う MUST(実装は draft 作成 → スロット予約 → finalize=支払い可能化)。発行が途中失敗(finalize 失敗)した場合は、支払い不可の Invoice を後始末し予約スロットを解放してエラーを返す SHALL。連打・同時発行でも、台帳の一意制約により**単一の支払い可能 Invoice に収束**する MUST(決定的冪等キーには依存しない)。 + +#### Scenario: 発行で支払いページに到達できる + +- **WHEN** 認可され、対象半期が未占有の請求書発行が要求される +- **THEN** 台帳にスロットが予約され、Customer の Invoice(Price 指定)が支払い可能化され、支払いページ(URL)が利用可能になる + +#### Scenario: 請求書はカードと口座振込の両方で払える + +- **WHEN** 請求書が発行される +- **THEN** その Invoice はカードと口座振込(`customer_balance` の `jp_bank_transfer`)の両方の支払い手段を備え、hosted invoice ページで利用者がいずれかを選べる + +#### Scenario: 口座振込は着金まで open を維持し、着金後に入金確定へ合流 + +- **WHEN** 利用者が口座振込を選び、表示された専用振込先へ振り込む +- **THEN** 着金までは Invoice が `open` のまま保たれ、着金が Customer 残高に反映され Invoice へ充当されると `invoice.paid` 経路で会計通知と発行台帳の paid 確定が行われる + +#### Scenario: 支払い済みの再発行は拒否 + +- **WHEN** 対象半期が既に支払い済みの利用者が、同じ年度・期の発行を要求する +- **THEN** 新しい Invoice は作られず、拒否される(既に支払い済み) + +#### Scenario: 未払いの再要求は既存の支払いページを返す + +- **WHEN** 対象半期に未払いの同一発行(open)が既にある状態で、同じ条件の発行が再要求される +- **THEN** 新しい Invoice は作られず、既存発行の支払いページ URL が返る + +#### Scenario: 連打・同時発行でも単一 Invoice に収束 + +- **WHEN** 同一条件の発行要求が短時間に複数回/同時に届く +- **THEN** 台帳の一意制約により単一の支払い可能 Invoice に収束し、支払い可能 Invoice がガード無しで残らない diff --git a/openspec/changes/archive/2026-06-23-add-bank-transfer-payment/tasks.md b/openspec/changes/archive/2026-06-23-add-bank-transfer-payment/tasks.md new file mode 100644 index 0000000..b474c41 --- /dev/null +++ b/openspec/changes/archive/2026-06-23-add-bank-transfer-payment/tasks.md @@ -0,0 +1,20 @@ +## 1. アダプタ:請求書に口座振込を追加 + +- [x] 1.1 `packages/api/src/stripe/invoices.ts` の `createDraftInvoice` で `invoices.create` に `payment_settings` を追加する:`payment_method_types: ['card', 'customer_balance']`、`payment_method_options.customer_balance = { bank_transfer: { type: 'jp_bank_transfer' }, funding_type: 'bank_transfer' }`。 +- [x] 1.2 既存コメント(draft-first / 非冪等の理由)を壊さず、口座振込が支払い**手段**の追加であり発行順序・台帳ロジックに影響しないことを 1〜2 行で補記する。 +- [x] 1.3 ドメイン(billing/ledger)・oRPC 入力・`CreateDraftInput` を変更していないことを確認(手段はアダプタ層で完結)。 + +## 2. テスト + +- [x] 2.1 `stripe/invoices.ts` のユニットテストに、`createDraftInvoice` が `payment_settings`(`['card','customer_balance']` + `jp_bank_transfer` / `funding_type: 'bank_transfer'`)付きで `invoices.create` を呼ぶことを検証するケースを追加(Stripe モックの呼び出し引数アサート)。新規 `packages/api/src/stripe/invoices.test.ts`(3 ケース)。 +- [x] 2.2 既存の発行・台帳・Webhook テストが緑のままであること(手段追加で回帰しない)を確認。123 tests green。 + +## 3. 検証ゲート + +- [x] 3.1 `pnpm lint` / `pnpm typecheck` / `pnpm build` / `pnpm test` を緑にする。Codex レビュー(money-safety 4 観点)も blocking 無し。 + +## 4. 実 Stripe E2E(test mode/キー投入後)— BLOCKED(Stripe アカウントで JPY + 日本の銀行振込の有効化が前提) + +- [ ] 4.1 dev 構成(HANDOVER §10)で `/membership` から請求書を発行し、hosted invoice ページにカードと口座振込(専用振込先)が両方表示されることを確認。 +- [ ] 4.2 test mode のテスト着金(Stripe の cash-balance 加算 API/ヘルパ)で口座振込入金をシミュレートし、`invoice.paid` が発火 → 会計通知 → 発行台帳が paid 確定することを確認(Webhook ハンドラのキー項目=`customer`/`metadata` が欠けないこと)。 +- [ ] 4.3 口座振込が着金前は Invoice が `open` のまま保たれ、同一条件の再発行が既存 URL を返す([[issuance-ledger]] の未払い再利用)ことを確認。 diff --git a/openspec/specs/membership-billing/spec.md b/openspec/specs/membership-billing/spec.md index b7259ab..be0c4ab 100644 --- a/openspec/specs/membership-billing/spec.md +++ b/openspec/specs/membership-billing/spec.md @@ -100,6 +100,8 @@ システムは、対象 Customer に対し Invoice + InvoiceItem(選択した Price)を作成し、finalize して支払い可能にする SHALL。利用者は Stripe の支払いページ(`hosted_invoice_url`)で支払える MUST。請求書はドメイン非依存のアダプタ越しに作成する。 +発行する Invoice は**カードと口座振込(銀行振込)の両方**で支払える SHALL:`payment_method_types` にカードと `customer_balance` を含め、`customer_balance` の funding を `jp_bank_transfer`(`funding_type = bank_transfer`)に設定する MUST。利用者は hosted invoice ページでいずれかの手段を選べる。口座振込は**非同期着金**であり、Invoice は着金まで `open` を維持する SHALL:利用者は表示される専用振込先へ振り込み、着金が Customer 残高に反映され Invoice へ充当されたとき、その入金は既存の入金確定経路([[payment-webhook]]:`invoice.paid` → 会計通知・[[issuance-ledger]] の paid 確定)に合流する MUST。支払い手段の追加は重複防止・本人特定・発行順序のロジックを変更しては SHALL NOT。 + 発行は **発行台帳([[issuance-ledger]])のゲート越し**に行う SHALL。費目・期・区分から coverage(埋める半期)を決定し、対象半期を台帳で判定する MUST: - 対象半期が **支払い済み**なら発行せず拒否する(既に支払い済み)。 - 対象半期が**未払いの同一発行(open)**で占有されているなら、新規発行せず**既存の支払いページ URL を返す**。 @@ -113,6 +115,16 @@ - **WHEN** 認可され、対象半期が未占有の請求書発行が要求される - **THEN** 台帳にスロットが予約され、Customer の Invoice(Price 指定)が支払い可能化され、支払いページ(URL)が利用可能になる +#### Scenario: 請求書はカードと口座振込の両方で払える + +- **WHEN** 請求書が発行される +- **THEN** その Invoice はカードと口座振込(`customer_balance` の `jp_bank_transfer`)の両方の支払い手段を備え、hosted invoice ページで利用者がいずれかを選べる + +#### Scenario: 口座振込は着金まで open を維持し、着金後に入金確定へ合流 + +- **WHEN** 利用者が口座振込を選び、表示された専用振込先へ振り込む +- **THEN** 着金までは Invoice が `open` のまま保たれ、着金が Customer 残高に反映され Invoice へ充当されると `invoice.paid` 経路で会計通知と発行台帳の paid 確定が行われる + #### Scenario: 支払い済みの再発行は拒否 - **WHEN** 対象半期が既に支払い済みの利用者が、同じ年度・期の発行を要求する diff --git a/packages/api/src/stripe/invoices.test.ts b/packages/api/src/stripe/invoices.test.ts new file mode 100644 index 0000000..adecfec --- /dev/null +++ b/packages/api/src/stripe/invoices.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from 'vitest' +import type { StripeClient } from './client' +import { createDraftInvoice } from './invoices' + +/** + * Adapter-level test for `createDraftInvoice`: it must offer BOTH card and bank + * transfer (口座振込) on the issued invoice. We assert the exact `invoices.create` + * payload (the bank-transfer `payment_settings`) rather than calling Stripe. + * The domain/ledger order is covered elsewhere; here we pin the payment methods. + */ + +/** The subset of the `invoices.create` payload this test inspects. */ +interface InvoiceCreateParams { + customer: string + collection_method: string + payment_settings: { + payment_method_types: string[] + payment_method_options: { + customer_balance: { bank_transfer: { type: string }, funding_type: string } + } + } +} + +/** The subset of the `invoiceItems.create` payload this test inspects. */ +interface InvoiceItemParams { + customer: string + invoice: string + pricing: { price: string } +} + +/** Minimal Stripe fake exposing only what `createDraftInvoice` touches. */ +function fakeStripe() { + const create = vi.fn(async (_params: InvoiceCreateParams) => ({ id: 'in_test_123' })) + const itemCreate = vi.fn(async (_params: InvoiceItemParams) => ({ id: 'ii_test_123' })) + const del = vi.fn(async (_id: string) => ({})) + const sdk = { + invoices: { create, del }, + invoiceItems: { create: itemCreate }, + } + return { stripe: { sdk } as unknown as StripeClient, create, itemCreate, del } +} + +describe('createDraftInvoice', () => { + it('offers card and bank transfer (jp_bank_transfer via customer_balance)', async () => { + const { stripe, create } = fakeStripe() + + await createDraftInvoice(stripe, { + customerId: 'cus_abc', + priceId: 'price_xyz', + daysUntilDue: 30, + metadata: { activityYear: '2026' }, + }) + + expect(create).toHaveBeenCalledTimes(1) + const arg = create.mock.calls[0]![0] + expect(arg.payment_settings.payment_method_types).toEqual(['card', 'customer_balance']) + expect(arg.payment_settings.payment_method_options.customer_balance).toEqual({ + bank_transfer: { type: 'jp_bank_transfer' }, + funding_type: 'bank_transfer', + }) + // The base invoice shape (send_invoice collection) is preserved. + expect(arg.collection_method).toBe('send_invoice') + expect(arg.customer).toBe('cus_abc') + }) + + it('attaches the chosen price as the single line item', async () => { + const { stripe, itemCreate } = fakeStripe() + + await createDraftInvoice(stripe, { + customerId: 'cus_abc', + priceId: 'price_xyz', + daysUntilDue: 30, + }) + + expect(itemCreate).toHaveBeenCalledTimes(1) + const arg = itemCreate.mock.calls[0]![0] + expect(arg.customer).toBe('cus_abc') + expect(arg.invoice).toBe('in_test_123') + expect(arg.pricing).toEqual({ price: 'price_xyz' }) + }) + + it('deletes the orphaned draft if creating the line item fails', async () => { + const { stripe, itemCreate, del } = fakeStripe() + itemCreate.mockRejectedValueOnce(new Error('item boom')) + + await expect( + createDraftInvoice(stripe, { customerId: 'cus_abc', priceId: 'price_xyz', daysUntilDue: 30 }), + ).rejects.toThrow('item boom') + + expect(del).toHaveBeenCalledWith('in_test_123') + }) +}) diff --git a/packages/api/src/stripe/invoices.ts b/packages/api/src/stripe/invoices.ts index 94b8e01..99614ab 100644 --- a/packages/api/src/stripe/invoices.ts +++ b/packages/api/src/stripe/invoices.ts @@ -36,6 +36,22 @@ export async function createDraftInvoice(stripe: StripeClient, input: CreateDraf collection_method: 'send_invoice', days_until_due: input.daysUntilDue, metadata: input.metadata, + // Offer card AND bank transfer (口座振込) on the hosted invoice page. Bank + // transfer settles asynchronously: Stripe shows a dedicated virtual account, + // the payer transfers, the funds land in the Customer cash balance and + // auto-apply to this invoice, then `invoice.paid` fires exactly like a card + // payment. So this only adds a payment *method* — the draft-first issuance + // order and the webhook/ledger paid-confirmation path are unchanged. + // (membership-billing: §請求書の作成・確定・送付) + payment_settings: { + payment_method_types: ['card', 'customer_balance'], + payment_method_options: { + customer_balance: { + bank_transfer: { type: 'jp_bank_transfer' }, + funding_type: 'bank_transfer', + }, + }, + }, }) if (!draft.id) { throw new Error('Stripe did not return an invoice id') From 34dd63b5dae0219ebedd74c6c39bbceee9e0e111 Mon Sep 17 00:00:00 2001 From: kaitoyama Date: Wed, 24 Jun 2026 12:56:03 +0900 Subject: [PATCH 13/30] fix: align Jomon v1 payout driver with live API + multi-payee guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v1 driver was guessed and rejected every live application: it required a per-payee `amount` on each repayment log, but real Jomon v1 has none — the amount lives on the application (`current_detail.amount`). Verified against a local Jomon v1 (master branch). Now: - v1 reads the amount from `current_detail.amount`; the schema no longer requires a per-log amount. - Decide by TOTAL payees on an application (not just unpaid count): exactly one payee → pay the application amount; MORE THAN ONE payee → never auto-pay (the application total can't be split safely, even when only one remains unpaid — would overpay). Multi-payee apps are flagged needs-review via `summary.multiPayeeRefs`; `/payouts` shows a warning alert. Operations guarantee one payee per application; this guards the exception. - `executePayout` also refuses multi-payee markers. Codex money-safety review caught the partial-repaid overpay (HIGH) on the first pass; fixed by the total-payee gate and re-reviewed clean. OpenSpec: fix-jomon-v1-amount-multipayee (payout-execution delta synced + archived). Verified end-to-end on local Jomon v1: single payee -> ingest -> resolve -> onboarding gate -> real Stripe transfer (tr_…, ¥1,500) -> paid -> `repaid` write-back to Jomon (live 200); re-run is idempotent (already_paid). Multi-payee and partial-repaid -> multiPayeeRefs + UI alert, no transfer. Co-Authored-By: Claude Opus 4.8 (1M context) --- HANDOVER.md | 16 +++-- apps/web/app/pages/payouts.vue | 16 +++++ .../.openspec.yaml | 2 + .../design.md | 43 +++++++++++ .../proposal.md | 25 +++++++ .../specs/payout-execution/spec.md | 27 +++++++ .../tasks.md | 31 ++++++++ openspec/specs/payout-execution/spec.md | 26 +++++++ packages/api/src/jomon/http.ts | 40 ++++++++--- packages/api/src/jomon/jomon.test.ts | 72 ++++++++++++++++--- packages/api/src/jomon/types.ts | 9 +++ packages/api/src/payouts/execute.ts | 27 +++++++ 12 files changed, 310 insertions(+), 24 deletions(-) create mode 100644 openspec/changes/archive/2026-06-24-fix-jomon-v1-amount-multipayee/.openspec.yaml create mode 100644 openspec/changes/archive/2026-06-24-fix-jomon-v1-amount-multipayee/design.md create mode 100644 openspec/changes/archive/2026-06-24-fix-jomon-v1-amount-multipayee/proposal.md create mode 100644 openspec/changes/archive/2026-06-24-fix-jomon-v1-amount-multipayee/specs/payout-execution/spec.md create mode 100644 openspec/changes/archive/2026-06-24-fix-jomon-v1-amount-multipayee/tasks.md diff --git a/HANDOVER.md b/HANDOVER.md index f5ca889..6c949dd 100644 --- a/HANDOVER.md +++ b/HANDOVER.md @@ -9,7 +9,7 @@ traP の Stripe 集金・払い戻しシステム「Checkin」。design.md(リ 第一弾スコープ(①集金 ②入出金一覧 ③払い戻し)+認証基盤+利用者 UI+会計 UI+**重複支払い防止(発行台帳)**を **OpenSpec 仕様駆動**で実装済み。 - **全ゲート緑**: `pnpm lint` / `pnpm typecheck` / `pnpm build` / `pnpm test`(120 tests)。 -- **11 changes をアーカイブ済み**(`openspec/changes/archive/`)、**13 specs**(`openspec/specs/`)。 +- **12 changes をアーカイブ済み**(`openspec/changes/archive/`)、**13 specs**(`openspec/specs/`)。 - **未コミット差分あり**(`add-accountant-ui` + `add-issuance-ledger` + dev 環境)。**push / PR は未実施**。 - 直近: `add-issuance-ledger`(半期スロット台帳で会員費の二重払いを拒否)。**実 Stripe で E2E 済み**(発行→支払い→再発行拒否/未払い時 URL 再利用/通期×半期の重複拒否)。Codex 2 周レビューで money-safety(draft-first 順序)を確定。 - **ローカル dev 構成あり**(下記 §10): `.env`+`apps/web/.env` symlink、Dev ログイン `/dev/login`、Stripe test キー+4 Price+`stripe listen` 配線済み。 @@ -78,12 +78,14 @@ traP の Stripe 集金・払い戻しシステム「Checkin」。design.md(リ ## 5. 残作業(優先度順) -### A. 実 Jomon 連携(要 Jomon メンテナ調整 → その後コードは概ね準備済み) -実 API 調査済み(`packages/api/src/jomon/http.ts` に実装、default driver は `stub`)。**Jomon 側に 2 つの追加が必要**: -1. **Bearer サービストークン受け口**: 現状 Jomon(v1/v2) は traQ OAuth/cookie のみで Bearer 受け口が無い。Checkin→Jomon は片方向 Bearer 前提。 -2. **v2 の per-payee 書き戻し API**: v2 は `ApplicationTarget.paid_at` が読み取り専用で「支払い済み」を個別に書ける API が無い(v1 は `PUT /api/applications/{id}/states/repaid/{trapId}` あり)。design §9 / Jomon issue #183 系。 -- 上記が入ったら `JOMON_API_VERSION=v1`/`v2` で実接続 E2E。`jomon/http.ts` の `// TODO: confirm field names/paths against live Jomon` を実レスポンスで確定(特に v1 の 1 application 複数 payee の amount 分配は未確定)。 -- 実 API 形(確認済み): 一覧 `GET /api/applications`(v1 `current_state=accepted`/v2 `status=approved`)。payee=traQ ID(v1 `repaid_to_user.trap_id`、v2 `target`UUID→`GET /api/users`の`name`)。通貨なし(jpy)。 +### A. 実 Jomon 連携(**v1 ローカルで実接続 E2E 済み**。本番接続は Bearer 受け口のみ要調整) +**本番は v1(Jomon repo の `master` ブランチ)**。`v2` は default ブランチだが開発中の見込み。ローカル Jomon v1 を `/home/kaitoyama/Jomon-v1` に clone・起動済み(`docker-compose up -d --build db jomon-server`、API は **:1323 直**、db :3308=Checkin の 3306 と衝突回避、Taskfile に `-buildvcs=false` を追加して起動)。 +- **debug ビルド(`-tags debug`)は `AuthUser` がセッション/トークンを見ず常に admin `MyUser` 認証** → **ローカル検証では Bearer パッチ不要**。本番(`!debug`)に入れるときだけ `router/service.go` の `AuthUserMiddleware`/`AuthUser` に Bearer 受け口を足す(compose に `SERVICE_TOKEN` placeholder あり)。これが残る唯一の Jomon 側調整。 +- **実 v1 で確定した連携**(`fix-jomon-v1-amount-multipayee` で Checkin v1 ドライバを実機整合に修正済み): 一覧 `GET /api/applications?current_state=accepted`(素の配列)→ 詳細 `GET /api/applications/{id}` の `repayment_logs[].repaid_to_user.trap_id`+`repaid_at`。**金額は per-payee に無く `current_detail.amount`(申請単位)**。書き戻し `PUT /api/applications/{id}/states/repaid/{trapId}` `{repaid_at:"YYYY-MM-DD"}`(**v1 は per-payee 書き戻しあり=閉ループ可**。live 200 確認)。 +- **複数 payee は自動送金せず needs-review+UI 警告**(運用上は 1 人前提。総 payee 数≥2 ならマーカー化=過払い防止。Codex HIGH 修正済み)。POST 申請は **multipart**(`-F 'details={...}'`)。 +- **closed-loop E2E 完了**(`.env` を `JOMON_API_VERSION=v1` / `JOMON_API_BASE_URL=http://localhost:1323` / `JOMON_API_TOKEN=任意`): 単一 payee → `ingested:1`→本人解決→onboarding ゲート→**実 Stripe transfer ¥1,500(`tr_1TlhrP…`→`acct_1QUM03CW4ItwVkk3`)→ `paid` → Jomon に `repaid` 書き戻し(live 200)** まで通った。再実行は `already_paid`(二重送金なし、balance 1617→117)。複数/部分支払い → `multiPayeeRefs`+`/payouts` 警告(送金せず)。 + - 送金は onboarding 済みテスト account(`acct_1QUM03CW4ItwVkk3`、[[payout-e2e-stripe]])に devmember を DB で紐付け+`done` にして実施(**新規 Express の onboarding 完了は hosted フロー必須**なので回避)。platform available 残高は少額(実施後 ¥117)。 +- **v2 は別系統で未整備**(per-payee 書き戻し API が無い=`writeBackResult` は `JomonWriteBackUnsupportedError`)。v2 を採る場合は Jomon 側に書き戻し追加が要る。Checkin v2 ドライバも実 v2(`status=accepted`/素配列/`current_detail` 不使用…)に未整合の TODO 残(v1 に注力するなら不要)。 ### B. Stripe 実 E2E(test キー投入で可能) - 集金: `STRIPE_SECRET_KEY`(test) + `PRICE_*`(4種) + `STRIPE_WEBHOOK_SECRET` → `/membership` から発行→支払い→`invoice.paid` 通知。 diff --git a/apps/web/app/pages/payouts.vue b/apps/web/app/pages/payouts.vue index d014307..53096c1 100644 --- a/apps/web/app/pages/payouts.vue +++ b/apps/web/app/pages/payouts.vue @@ -109,6 +109,13 @@ const processing = ref(false) const processError = ref(null) const processSummary = ref | null>(null) +// Applications skipped because they have multiple unpaid payees (v1 has no +// per-payee amount, so they are not auto-paid). Surfaced as a manual-action alert. +const multiPayeeRefs = computed(() => { + const v = processSummary.value?.multiPayeeRefs + return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [] +}) + async function onProcessApproved() { if (processing.value) { return @@ -318,6 +325,15 @@ onMounted(() => { + + 1`)」に変更して解消。`executePayout` にも multiPayee ガードを追加。再レビューで RESOLVED・新規問題なしを確認。 +- [x] 7.2 実 Jomon v1 で Q2 シナリオ(payees=[devmember,bob]・bob 書き戻し済み・¥8000)を `processApproved` → `multiPayeeRefs` に入り devmember へは未送金。単一は onboarding_waiting。write-back(`PUT states/repaid/{trapId}`)も live で 200。 diff --git a/openspec/specs/payout-execution/spec.md b/openspec/specs/payout-execution/spec.md index 88f52c7..ea9ead5 100644 --- a/openspec/specs/payout-execution/spec.md +++ b/openspec/specs/payout-execution/spec.md @@ -145,3 +145,29 @@ Jomon で承認済みの振込依頼を取り込み、[[connect-onboarding]] で - **WHEN** 利用者または未ログインで払い戻し操作を要求する - **THEN** 認可エラーで拒否される + +### Requirement: v1 の払い戻し金額と複数受取人の扱い + +v1 は払い戻し金額を**申請単位**(`current_detail.amount`)でしか持たず、受取人(`repayment_logs[]`)単位の金額を持たない。システムは v1 ドライバで金額を `current_detail.amount` から取得する SHALL(repayment_log に `amount` を要求しては SHALL NOT)。 + +送金へ進めるかは申請の**総受取人数**(`repayment_logs` の要素数)で判定する MUST: +- 受取人が **1 人だけ**の申請は、その人の払い戻し額が申請額に等しいので、未払いなら `current_detail.amount` で送金フローへ進める MUST。 +- 受取人が **2 人以上**の申請は、`current_detail.amount` が申請総額であり個人へ按分する根拠が無いため、**たとえ未払いが 1 人でも**自動送金しては SHALL NOT(残り 1 人へ総額を送ると過払いになる)。`payouts` 行を作らず、`processApproved` の集計で needs-review に数え、対象 application id を返却して会計の UI に**警告として明示**する MUST。 +- 全受取人が支払い済み(未払い 0 人)の申請は送金対象にしない。 + +この判定は手動の単一実行(`payouts.execute`)でも同様に適用し、複数受取人の申請を送金へ進めては SHALL NOT。 + +#### Scenario: 受取人が 1 人の申請は申請額で送金へ進む + +- **WHEN** 承認済み申請の受取人が 1 人で未払いである +- **THEN** その受取人を payee とし、`current_detail.amount` を払い戻し額として送金フロー(本人特定・onboarding・原子的クレーム・送金)へ進む + +#### Scenario: 受取人が複数の申請は自動送金せず UI 警告(一部支払い済みでも) + +- **WHEN** 承認済み申請の受取人が 2 人以上である(未払いが 1 人だけに減っていても含む) +- **THEN** 自動送金せず(`payouts` 行も作らず)、needs-review として集計し、対象 application id を会計 UI に警告として表示する + +#### Scenario: v1 の金額は申請単位から取得する + +- **WHEN** v1 ドライバが承認済み申請の詳細を取得する +- **THEN** 払い戻し額は `current_detail.amount` から取得され、repayment_log 側の金額には依存しない diff --git a/packages/api/src/jomon/http.ts b/packages/api/src/jomon/http.ts index cb982f9..7f939df 100644 --- a/packages/api/src/jomon/http.ts +++ b/packages/api/src/jomon/http.ts @@ -132,12 +132,15 @@ const v1CompactApplicationSchema = z.object({ const v1RepaymentLogSchema = z.object({ repaid_to_user: z.object({ trap_id: z.string().trim().min(1) }), - amount: z.number().int().positive(), + // NOTE: v1 has NO per-payee amount on a repayment log — the amount lives on the + // application (`current_detail.amount`). `repaid_at` present ⇒ already repaid. repaid_at: z.string().nullish(), }) const v1DetailedApplicationSchema = z.object({ application_id: z.string().trim().min(1), + // v1 holds the refund amount at the application level only (no per-payee split). + current_detail: z.object({ amount: z.number().int().positive() }), repayment_logs: z.array(v1RepaymentLogSchema), }) @@ -170,16 +173,37 @@ export class JomonV1Client extends JomonHttpClient { throw new Error(`Jomon v1 application ${appId} failed validation: ${detail.error.message}`) } - // 3. One DTO per still-unpaid (no repaid_at) repayment log. - for (const log of detail.data.repayment_logs) { - if (log.repaid_at) { - continue - } - const trapId = log.repaid_to_user.trap_id + // 3. v1 has only an application-level amount (`current_detail.amount`), + // with NO per-payee split. So the decision is by the TOTAL number of + // payees on the application, NOT how many are still unpaid: + // - all payees already repaid → nothing to do. + // - exactly ONE payee on the application → its amount IS the + // application amount; emit a normal request (if unpaid). + // - MORE THAN ONE payee → `current_detail.amount` is the application + // total and cannot be attributed to a single person, so it must + // NOT be auto-paid even when only one remains unpaid (paying the + // full total to the remainder would overpay). Emit a `multiPayee` + // marker → orchestration flags needs-review, UI alerts (no transfer). + // (payout-execution: §v1 の払い戻し金額と複数受取人の扱い) + const amount = detail.data.current_detail.amount + const logs = detail.data.repayment_logs + const unpaid = logs.filter(log => !log.repaid_at) + if (unpaid.length === 0) { + // Every payee already repaid — nothing to pay (single or multi). + } + else if (logs.length > 1) { + // Multiple payees on one application → no reliable per-payee amount. + // Marker built directly because `toTransferRequest` requires a non-empty + // payeeTraqId, which a multi-payee application has none single. + out.push({ jomonRef: appId, payeeTraqId: '', amount, currency: JOMON_CURRENCY, multiPayee: true }) + } + else { + // Exactly one payee on the application (unpaid) → amount = application amount. + const trapId = logs[0]!.repaid_to_user.trap_id out.push(toTransferRequest({ jomonRef: `${appId}:${trapId}`, payeeTraqId: trapId, - amount: log.amount, + amount, })) } } diff --git a/packages/api/src/jomon/jomon.test.ts b/packages/api/src/jomon/jomon.test.ts index 4733bee..019db0c 100644 --- a/packages/api/src/jomon/jomon.test.ts +++ b/packages/api/src/jomon/jomon.test.ts @@ -77,26 +77,78 @@ describe('JomonV1Client (/api/applications, strict, fail-safe)', () => { const client = new JomonV1Client('https://jomon.test', 'token') - it('lists accepted applications and normalizes unpaid repayment logs by trap_id', async () => { + it('normalizes a single-payee application by trap_id (amount = current_detail.amount)', async () => { mockFetchSequence([ // 1. list ?current_state=accepted (compact, no per-payee data) [{ application_id: 'app_1' }], - // 2. GET /api/applications/app_1 (detailed) + // 2. GET /api/applications/app_1 (detailed) — exactly one payee, unpaid. { application_id: 'app_1', + // v1: amount lives at the application level (no per-payee amount). + current_detail: { amount: 4000 }, repayment_logs: [ - { repaid_to_user: { trap_id: 'alice' }, amount: 4000, repaid_at: null }, - // already repaid ⇒ skipped - { repaid_to_user: { trap_id: 'bob' }, amount: 2000, repaid_at: '2026-01-01' }, + { repaid_to_user: { trap_id: 'alice' }, repaid_at: null }, ], }, ]) const out = await client.listApprovedTransferRequests() + // Single payee ⇒ amount = current_detail.amount. expect(out).toEqual([ { jomonRef: 'app_1:alice', payeeTraqId: 'alice', amount: 4000, currency: 'jpy' }, ]) }) + it('flags a MULTI-payee application as multiPayee even when only one remains unpaid (no overpay)', async () => { + // Money-safety: current_detail.amount is the application TOTAL. A 2-payee app + // with one already repaid must NOT fast-path the remainder the full total — + // it has no per-payee amount, so it goes to manual review. (Codex Q2) + mockFetchSequence([ + [{ application_id: 'app_partial' }], + { + application_id: 'app_partial', + current_detail: { amount: 6000 }, + repayment_logs: [ + { repaid_to_user: { trap_id: 'alice' }, repaid_at: null }, + { repaid_to_user: { trap_id: 'bob' }, repaid_at: '2026-01-01' }, + ], + }, + ]) + expect(await client.listApprovedTransferRequests()).toEqual([ + { jomonRef: 'app_partial', payeeTraqId: '', amount: 6000, currency: 'jpy', multiPayee: true }, + ]) + }) + + it('flags an application with MULTIPLE unpaid payees as multiPayee (no auto-pay)', async () => { + mockFetchSequence([ + [{ application_id: 'app_multi' }], + { + application_id: 'app_multi', + current_detail: { amount: 5000 }, + repayment_logs: [ + { repaid_to_user: { trap_id: 'x' }, repaid_at: null }, + { repaid_to_user: { trap_id: 'y' }, repaid_at: null }, + ], + }, + ]) + const out = await client.listApprovedTransferRequests() + // No per-payee amount to split ⇒ a single marker for manual review, no payee. + expect(out).toEqual([ + { jomonRef: 'app_multi', payeeTraqId: '', amount: 5000, currency: 'jpy', multiPayee: true }, + ]) + }) + + it('emits nothing when every payee is already repaid', async () => { + mockFetchSequence([ + [{ application_id: 'app_done' }], + { + application_id: 'app_done', + current_detail: { amount: 4000 }, + repayment_logs: [{ repaid_to_user: { trap_id: 'z' }, repaid_at: '2026-01-01' }], + }, + ]) + expect(await client.listApprovedTransferRequests()).toEqual([]) + }) + it('SKIPS a malformed application detail and still processes the others', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) mockFetchSequence([ @@ -107,8 +159,9 @@ describe('JomonV1Client (/api/applications, strict, fail-safe)', () => { // 3. GET /api/applications/app_good — valid ⇒ processed { application_id: 'app_good', + current_detail: { amount: 3000 }, repayment_logs: [ - { repaid_to_user: { trap_id: 'carol' }, amount: 3000, repaid_at: null }, + { repaid_to_user: { trap_id: 'carol' }, repaid_at: null }, ], }, ]) @@ -138,7 +191,8 @@ describe('JomonV1Client (/api/applications, strict, fail-safe)', () => { new Response( JSON.stringify({ application_id: 'app_ok', - repayment_logs: [{ repaid_to_user: { trap_id: 'dave' }, amount: 1500, repaid_at: null }], + current_detail: { amount: 1500 }, + repayment_logs: [{ repaid_to_user: { trap_id: 'dave' }, repaid_at: null }], }), { status: 200, headers: { 'content-type': 'application/json' } }, ), @@ -158,7 +212,7 @@ describe('JomonV1Client (/api/applications, strict, fail-safe)', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) mockFetchSequence([ [{ application_id: 'app_2' }], - { application_id: 'app_2', repayment_logs: [{ repaid_to_user: {}, amount: 4000 }] }, + { application_id: 'app_2', current_detail: { amount: 4000 }, repayment_logs: [{ repaid_to_user: {}, repaid_at: null }] }, ]) const out = await client.listApprovedTransferRequests() expect(out).toEqual([]) @@ -170,7 +224,7 @@ describe('JomonV1Client (/api/applications, strict, fail-safe)', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) mockFetchSequence([ [{ application_id: 'app_3' }], - { application_id: 'app_3', repayment_logs: [{ repaid_to_user: { trap_id: 'eve' }, amount: 0 }] }, + { application_id: 'app_3', current_detail: { amount: 0 }, repayment_logs: [{ repaid_to_user: { trap_id: 'eve' }, repaid_at: null }] }, ]) const out = await client.listApprovedTransferRequests() expect(out).toEqual([]) diff --git a/packages/api/src/jomon/types.ts b/packages/api/src/jomon/types.ts index c703447..e8e4d0e 100644 --- a/packages/api/src/jomon/types.ts +++ b/packages/api/src/jomon/types.ts @@ -29,6 +29,15 @@ export interface JomonTransferRequest { amount: number /** ISO currency code; always `'jpy'` for Jomon. */ currency: string + /** + * v1 only: set when an application has MORE THAN ONE unpaid payee. v1 carries + * no per-payee amount (only the application-level `current_detail.amount`), so + * such a request cannot be auto-paid without inventing a split. The + * orchestration must NOT transfer it — it flags needs-review and the UI alerts + * the accountant for manual handling. `payeeTraqId` is empty on such a marker. + * (payout-execution: §v1 の払い戻し金額と複数受取人の扱い) + */ + multiPayee?: boolean } /** Result written back to Jomon after a payout settles. */ diff --git a/packages/api/src/payouts/execute.ts b/packages/api/src/payouts/execute.ts index 6443084..13a3890 100644 --- a/packages/api/src/payouts/execute.ts +++ b/packages/api/src/payouts/execute.ts @@ -87,6 +87,13 @@ export interface ProcessApprovedSummary { errored: number /** `jomon_ref`s of items that threw, for accountant follow-up. */ errors: string[] + /** + * Application ids (v1) skipped because they have MORE THAN ONE unpaid payee: + * v1 has no per-payee amount, so they are not auto-paid. Counted in + * `needsReview`; listed here so the UI can alert the accountant for manual + * handling. (payout-execution: §v1 の払い戻し金額と複数受取人の扱い) + */ + multiPayeeRefs: string[] /** * Set when the approved-requests fetch itself failed: the run is reported with * an empty body and this top-level error indicator instead of throwing, so a @@ -117,6 +124,7 @@ export async function processApprovedPayouts( skippedFailed: 0, errored: 0, errors: [], + multiPayeeRefs: [], } // Batch-fetch isolation: the approved-requests pull happens BEFORE any per-item @@ -136,6 +144,17 @@ export async function processApprovedPayouts( } for (const req of requests) { + // Multi-payee application (v1): no per-payee amount exists, so it must NOT be + // auto-paid. Do NOT upsert a payout row (there is no single payee/userId) and + // do NOT transfer — flag needs-review and surface it for the accountant. + // (payout-execution: §v1 の払い戻し金額と複数受取人の扱い) + if (req.multiPayee) { + summary.ingested += 1 + summary.needsReview += 1 + summary.multiPayeeRefs.push(req.jomonRef) + continue + } + // Per-item error isolation: one bad item (Jomon write-back, DB hiccup, // malformed request, etc.) must NOT abort the rest of the batch. Record it // and continue. (Codex hardening) @@ -177,6 +196,14 @@ export async function executePayout( ): Promise { const requests = await deps.jomon.listApprovedTransferRequests() const req = requests.find(r => r.jomonRef === jomonRef) + if (req?.multiPayee) { + // Defensive: a multi-payee application has no single payee/amount, so it must + // NOT be paid even via the manual single-item path. No row is created. + // (The UI never offers `execute` on a marker — markers are not table rows — + // but guard here so no caller can promote one to a transfer.) + // (payout-execution: §v1 の払い戻し金額と複数受取人の扱い) + return { jomonRef, outcome: 'needs_review', status: 'pending' } + } if (!req) { // It may have already settled (and been removed from "approved"). If we have // a local row, advance using its stored amount/currency; else surface it. From 09a197091c4dd63d92a04fd02f5c74e2fe78c077 Mon Sep 17 00:00:00 2001 From: kaitoyama Date: Wed, 24 Jun 2026 13:57:31 +0900 Subject: [PATCH 14/30] =?UTF-8?q?feat:=20accountant=20special-invoice=20(?= =?UTF-8?q?=C2=A52,000)=20UI=20by=20email?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `membership.issueSpecialInvoiceByEmail` (adminProc): selects the target by email and get-or-creates the person row by mail_hash, so a first-time member can be issued the 継続特別 ¥2,000 invoice without a prior login / userId. The Customer is created from the same email, so the spec's "管理者が指定したメール=対象者" mail_hash match holds by construction; the email domain must be allowed (avoids junk rows). Extract the special-issuance core into `issueSpecialForRow` so the existing userId-based `issueSpecialInvoice` stays intact (DRY). Add the accountant page `/special-invoice` (email / name / coverage / activityYear) surfacing the hosted invoice URL to forward to the member, plus an admin nav link. Coverage keeps 前期のみ/後期のみ; the 後期のみ option is labelled for its two-stage case (前期特別→後期追加). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01ACKUEYT79nkgEKUjBWjnZM --- HANDOVER.md | 4 +- apps/web/app/layouts/default.vue | 8 + apps/web/app/pages/special-invoice.vue | 260 +++++++++++++++++++++++++ packages/api/src/router.ts | 193 ++++++++++++------ 4 files changed, 402 insertions(+), 63 deletions(-) create mode 100644 apps/web/app/pages/special-invoice.vue diff --git a/HANDOVER.md b/HANDOVER.md index 6c949dd..7473bc9 100644 --- a/HANDOVER.md +++ b/HANDOVER.md @@ -95,7 +95,7 @@ traP の Stripe 集金・払い戻しシステム「Checkin」。design.md(リ ### C. 会計(管理)UI(✅ 実装済み = `add-accountant-ui`) `/payments`(入出金一覧・フィルタ・append ページネーション・Dashboard リンク)と `/payouts`(払い戻し管理: `processApproved`/`list`/`execute`/`createOnboardingLink`/`onboardingStatus`)を実装済み。残りの会計 UI: - **`/admins`**: 会計は env 許可リスト管理(旧 DB 管理者テーブルは deprecated)なので管理 UI は**意図的に未実装**。traQ グループ判定(後述 D)に移行する場合も UI は不要の見込み。 -- **特別請求書発行 UI**(`membership.issueSpecialInvoice`、会計のみ ¥2,000): 対象 user を選ぶ検索/一覧 API・UI が無く未実装。先に user 検索 API(または payouts 一覧の userId から発行)を足す小さな change が必要。 +- **特別請求書発行 UI**(会計のみ ¥2,000): ✅ 実装済み。`/special-invoice`(会計ナビ「特別発行」)でメールアドレスを指定して発行。`membership.issueSpecialInvoiceByEmail`(`email`/`name?`/`coverage`/`activityYear?`)が mail_hash で対象者を get-or-create するため**初見ユーザーも入力可**(user 検索 API は不要)。許可ドメイン宛のみ・hosted invoice URL を会計が本人へ転送。userId 指定の `issueSpecialInvoice` も従来どおり残置(共通ヘルパ `issueSpecialForRow` に集約)。 ### D. ハードニング・フォローアップ - `users.stripe_customer_id` に connected account 同様の **unique 制約+get-or-create 競合対策**(Codex 指摘の後続)。小さな change。 @@ -142,7 +142,7 @@ pnpm test # vitest(DB バックドテストは Maria 1. (並行)Jomon メンテナと Bearer 受け口+v2 書き戻しを握る(残作業 A)。 2. Stripe/Connect の test キーが入ったら各 BLOCKED E2E を消化(残作業 B)。会計 UI(`/payments`・`/payouts`)も実データで E2E(`add-accountant-ui` の tasks 4.3/4.4)。 3. `stripe_customer_id` unique 化(残作業 D)。 -4. (任意)特別請求書発行 UI(会計が `coverage`/`activityYear` 指定)+ user 検索 API(残作業 C の残り)。 +4. ~~(任意)特別請求書発行 UI(会計が `coverage`/`activityYear` 指定)+ user 検索 API~~ → **完了**(`/special-invoice`、メール指定で初見ユーザーも発行可。user 検索 API は不要に)。残: 実 Stripe キーでの発行→支払い E2E。 5. ~~(任意)銀行振込(`customer_balance`/`jp_bank_transfer`)~~ → **完了**(`add-bank-transfer-payment`、全請求書をカード+口座振込の両対応に)。残: Stripe アカウントで JPY+日本の銀行振込を有効化のうえ test mode E2E(archive の tasks 4.x が BLOCKED)。 ## 10. ローカル dev 環境(このセッションで構築・未コミット) diff --git a/apps/web/app/layouts/default.vue b/apps/web/app/layouts/default.vue index ab71efb..833ed71 100644 --- a/apps/web/app/layouts/default.vue +++ b/apps/web/app/layouts/default.vue @@ -58,6 +58,14 @@ async function onLogout() { > 払い戻し + + 特別発行 + diff --git a/apps/web/app/pages/special-invoice.vue b/apps/web/app/pages/special-invoice.vue new file mode 100644 index 0000000..6a6eb4e --- /dev/null +++ b/apps/web/app/pages/special-invoice.vue @@ -0,0 +1,260 @@ + + + diff --git a/packages/api/src/router.ts b/packages/api/src/router.ts index 9306ff5..c40e86b 100644 --- a/packages/api/src/router.ts +++ b/packages/api/src/router.ts @@ -1,14 +1,16 @@ import { z } from 'zod' import { ORPCError } from '@orpc/server' -import { adminProc, pub, userProc } from './orpc' +import { adminProc, pub, userProc, type Context } from './orpc' import { isAllowedEmailDomain, deriveMailHash, sanitizeRedirect, createEmailVerification, getUserById, + getOrCreateUserByMailHash, linkTraqId, setPayoutOnboardingStatus, + type BillingUserRow, } from './auth' import { computeActivityYear, computeTerm, selectPriceId } from './billing' import { @@ -237,68 +239,55 @@ export const appRouter = { if (!row) { throw new ORPCError('NOT_FOUND', { message: 'target user not found' }) } + return issueSpecialForRow(context, { + row, + email: input.email, + name: input.name, + coverage: input.coverage, + activityYear: input.activityYear, + }) + }), - // Ledger gate (issuance-ledger): reserve the single target half through the - // ledger so a payable invoice never exists without a guard. Customer - // resolution + email validation happen lazily in `createDraft` (only on - // the `free` path), so a rejected duplicate has no side effects. - const now = new Date() - const activityYear = input.activityYear ?? computeActivityYear(now) - const outcome = await issueWithLedger( - context.db, - { userId: row.id, activityYear, halves: halvesForCoverage(input.coverage) }, - { - createDraft: async () => { - // Reuse the linked Customer; otherwise an email is required to create one. - if (!row.stripeCustomerId && !input.email) { - throw new ORPCError('BAD_REQUEST', { - message: 'email is required to create a Stripe Customer for this user', - }) - } - // When we will create a Customer from the supplied email, the email must - // belong to the target user (mail_hash match). Otherwise we would - // permanently link the user row to the wrong email/Customer. - if (!row.stripeCustomerId && input.email - && deriveMailHash(input.email, context.config.mailHashSecret) !== row.mailHash) { - throw new ORPCError('BAD_REQUEST', { message: 'email does not match the target user' }) - } - const customerId = await getOrCreateCustomer(context.stripe, context.db, { - userId: row.id, - stripeCustomerId: row.stripeCustomerId, - // email is only used when no Customer exists yet (guarded above). - email: input.email ?? '', - name: input.name, - mailHash: row.mailHash, - }) - const priceId = selectPriceId(context.billing, { - feeType: 'continuation', - variant: 'special', - }) - const { invoiceId } = await createDraftInvoice(context.stripe, { - customerId, - priceId, - daysUntilDue: context.billing.invoiceDaysUntilDue, - metadata: { - fee_type: 'continuation', - variant: 'special', - coverage: input.coverage, - activity_year: String(activityYear), - }, - }) - return invoiceId - }, - finalizeAndSend: id => finalizeAndSendInvoice(context.stripe, id), - voidInvoice: id => voidInvoiceSafe(context.stripe, id), - }, - ) - if (!outcome.ok) { - throw new ORPCError('CONFLICT', { - message: outcome.rejected === 'paid' - ? '対象の期間は既に支払い済みです。' - : '支払い対象の期間が既存の請求と重複しています。', - }) + /** + * Issue a **special** (継続特別 ¥2,000) invoice selecting the target by + * EMAIL. Admin (accountant) only. The email IS the person selector: we + * get-or-create the person row keyed by its `mail_hash`, so a first-time, + * never-before-seen member is created on the spot (no prior login / userId + * required). The Customer is then created from that same email, so the + * spec's "管理者が指定したメール=対象者" mail_hash match holds by construction. + * The domain must be an allowed isct domain — same guard as self-issuance — + * so a typo can't mint a junk person row. (membership-billing spec: §発行の認可 — 特別は管理者) + */ + issueSpecialInvoiceByEmail: adminProc + .input(z.object({ + email: z.string().email(), + name: z.string().min(1).optional(), + coverage: z.enum(['zenki', 'kouki']), + activityYear: z.number().int().optional(), + })) + .handler(async ({ input, context }) => { + context.assertCsrf() + + if (!isAllowedEmailDomain(input.email, context.config.allowedEmailDomains)) { + throw new ORPCError('BAD_REQUEST', { message: 'email domain is not allowed' }) } - return { invoiceId: outcome.invoiceId, hostedInvoiceUrl: outcome.hostedInvoiceUrl } + + // Resolve the person by mail_hash, creating the row for a first-time + // member, then load its full billing row (Stripe links/state). + const mailHash = deriveMailHash(input.email, context.config.mailHashSecret) + const base = await getOrCreateUserByMailHash(context.db, mailHash) + const row = await getUserById(context.db, base.id) + if (!row) { + // get-or-create just guaranteed the row exists; a miss is an internal fault. + throw new ORPCError('INTERNAL_SERVER_ERROR', { message: 'failed to resolve target user' }) + } + return issueSpecialForRow(context, { + row, + email: input.email, + name: input.name, + coverage: input.coverage, + activityYear: input.activityYear, + }) }), }, @@ -460,6 +449,88 @@ export const appRouter = { }, } +/** + * Core of special (継続特別 ¥2,000) issuance for an already-resolved person row. + * Shared by both selectors (`issueSpecialInvoice` by userId / `issueSpecialInvoiceByEmail` + * by email). Reserves the single target half through the ledger so a payable + * invoice never exists without a guard; Customer resolution + email validation + * happen lazily in `createDraft` (only on the `free` path), so a rejected + * duplicate has no side effects. (issuance-ledger spec / membership-billing spec) + */ +async function issueSpecialForRow( + context: Context, + params: { + row: BillingUserRow + /** Used only when the row has no Customer yet (to create one). */ + email?: string + name?: string + coverage: 'zenki' | 'kouki' + /** Defaults to the current activity year; pass next year for a 後期 継続特別. */ + activityYear?: number + }, +) { + const { row } = params + const now = new Date() + const activityYear = params.activityYear ?? computeActivityYear(now) + const outcome = await issueWithLedger( + context.db, + { userId: row.id, activityYear, halves: halvesForCoverage(params.coverage) }, + { + createDraft: async () => { + // Reuse the linked Customer; otherwise an email is required to create one. + if (!row.stripeCustomerId && !params.email) { + throw new ORPCError('BAD_REQUEST', { + message: 'email is required to create a Stripe Customer for this user', + }) + } + // When we will create a Customer from the supplied email, the email must + // belong to the target user (mail_hash match). Otherwise we would + // permanently link the user row to the wrong email/Customer. (For the + // by-email selector the row is keyed by this email's mail_hash, so the + // check is trivially satisfied.) + if (!row.stripeCustomerId && params.email + && deriveMailHash(params.email, context.config.mailHashSecret) !== row.mailHash) { + throw new ORPCError('BAD_REQUEST', { message: 'email does not match the target user' }) + } + const customerId = await getOrCreateCustomer(context.stripe, context.db, { + userId: row.id, + stripeCustomerId: row.stripeCustomerId, + // email is only used when no Customer exists yet (guarded above). + email: params.email ?? '', + name: params.name, + mailHash: row.mailHash, + }) + const priceId = selectPriceId(context.billing, { + feeType: 'continuation', + variant: 'special', + }) + const { invoiceId } = await createDraftInvoice(context.stripe, { + customerId, + priceId, + daysUntilDue: context.billing.invoiceDaysUntilDue, + metadata: { + fee_type: 'continuation', + variant: 'special', + coverage: params.coverage, + activity_year: String(activityYear), + }, + }) + return invoiceId + }, + finalizeAndSend: id => finalizeAndSendInvoice(context.stripe, id), + voidInvoice: id => voidInvoiceSafe(context.stripe, id), + }, + ) + if (!outcome.ok) { + throw new ORPCError('CONFLICT', { + message: outcome.rejected === 'paid' + ? '対象の期間は既に支払い済みです。' + : '支払い対象の期間が既存の請求と重複しています。', + }) + } + return { invoiceId: outcome.invoiceId, hostedInvoiceUrl: outcome.hostedInvoiceUrl } +} + /** Build the payout orchestration config from the request Context. */ function payoutExecuteConfig(context: { config: { appOrigin: string } From e229da866ba0eb2ac5d10b16f4ad60654d2d68d3 Mon Sep 17 00:00:00 2001 From: kaitoyama Date: Wed, 24 Jun 2026 16:20:32 +0900 Subject: [PATCH 15/30] fix: special-invoice activity-year input crash (UInput number coercion) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `` coerces v-model to a JS number, so onSubmit's `form.activityYear.trim()` threw TypeError, was swallowed by the catch, and the RPC never fired — the accountant saw "発行に失敗しました" and could not issue a next-year (継続特別 collected in 後期) special invoice from the UI. Normalize to a string before trimming. The server API was already correct. Verified via Playwright: activityYear=2027 now issues (metadata.activity_year= 2027); empty still defaults to the current year. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01ACKUEYT79nkgEKUjBWjnZM --- apps/web/app/pages/special-invoice.vue | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/app/pages/special-invoice.vue b/apps/web/app/pages/special-invoice.vue index 6a6eb4e..c3c4879 100644 --- a/apps/web/app/pages/special-invoice.vue +++ b/apps/web/app/pages/special-invoice.vue @@ -58,7 +58,9 @@ async function onSubmit() { errorMessage.value = null result.value = null try { - const year = form.activityYear.trim() + // `` coerces v-model to a JS number, so normalize to a + // string before trimming (a bare number has no `.trim`). Empty → server default. + const year = String(form.activityYear ?? '').trim() const res = await $orpc.membership.issueSpecialInvoiceByEmail({ email: form.email.trim(), name: form.name.trim() || undefined, From 7d9d244c724a51beed1d9a17863e8b3803cc26be Mon Sep 17 00:00:00 2001 From: kaitoyama Date: Wed, 24 Jun 2026 16:20:41 +0900 Subject: [PATCH 16/30] test: extract invoice.paid processing to a pure fn + idempotency regression tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dedup→markPaid→notify→record logic lived only in the Nitro route handler and had no unit coverage (event-id idempotency, notify-before-record ordering). Extract it into a dependency-injected pure function `processInvoicePaid(ops, verified)` (packages/api/src/webhook/process.ts); the route now just binds the real db/notifier ops. Behavior is byte-for-byte equivalent (order, return shapes, notify text, objectId null guard). Adds process.test.ts (DI spies, DB-free): paid → markPaid→notify→record in order; duplicate event → no side effects; notify failure → not recorded (Stripe retries); ledger-less invoice (objectId null) → skip markPaid, still 200. 126 → 131 tests passing. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01ACKUEYT79nkgEKUjBWjnZM --- .../routes/webhook/invoice-paid.post.ts | 31 ++---- packages/api/src/webhook/index.ts | 1 + packages/api/src/webhook/process.test.ts | 102 ++++++++++++++++++ packages/api/src/webhook/process.ts | 68 ++++++++++++ 4 files changed, 182 insertions(+), 20 deletions(-) create mode 100644 packages/api/src/webhook/process.test.ts create mode 100644 packages/api/src/webhook/process.ts diff --git a/apps/web/server/routes/webhook/invoice-paid.post.ts b/apps/web/server/routes/webhook/invoice-paid.post.ts index f867bf3..3781ecc 100644 --- a/apps/web/server/routes/webhook/invoice-paid.post.ts +++ b/apps/web/server/routes/webhook/invoice-paid.post.ts @@ -1,4 +1,4 @@ -import { constructEvent, createNotifier, createStripeClient, hasProcessedStripeEvent, markPaidByInvoiceId, recordStripeEventOnce } from '@checkin/api' +import { constructEvent, createNotifier, createStripeClient, hasProcessedStripeEvent, markPaidByInvoiceId, processInvoicePaid, recordStripeEventOnce } from '@checkin/api' /** * POST /webhook/invoice-paid — Stripe `invoice.paid` receiver. @@ -39,25 +39,16 @@ export default defineEventHandler(async (event) => { return { ok: true, ignored: verified.type } } - // Idempotency (happy path): skip if this event was already processed before. + // Process the paid event: dedup, confirm the issuance ledger, notify, record. + // The flow lives in @checkin/api as a pure, injectable function so its ordering + // (notify→record for at-least-once retry) and dedup are unit-tested without a DB + // or live Stripe; the wiring below binds it to the real DB and notifier. const db = useDatabase() - if (await hasProcessedStripeEvent(db, verified.id)) { - return { ok: true, duplicate: true } - } - - // Confirm the issuance ledger: flip the paid invoice's half-slots to `paid`. - // Idempotent and independent of the accountant notification — a 台帳外 invoice - // (no slots) is a no-op. Do this before notify/record so a failure here leaves - // the event unrecorded and Stripe retries. (issuance-ledger spec: §入金時の paid 確定) - if (verified.objectId) { - await markPaidByInvoiceId(db, verified.objectId) - } - - // Notify FIRST, then record. A notify failure throws before recordStripeEventOnce - // runs, so the event stays unrecorded and Stripe will retry (at-least-once). const notifier = createNotifier() - await notifier.notify(`入金を確認しました(Stripe event ${verified.id})。`) - await recordStripeEventOnce(db, verified) - - return { ok: true } + return await processInvoicePaid({ + hasProcessed: id => hasProcessedStripeEvent(db, id), + markPaid: async (id) => { await markPaidByInvoiceId(db, id) }, + notify: m => notifier.notify(m), + recordOnce: e => recordStripeEventOnce(db, e), + }, verified) }) diff --git a/packages/api/src/webhook/index.ts b/packages/api/src/webhook/index.ts index def94ce..e68e2bb 100644 --- a/packages/api/src/webhook/index.ts +++ b/packages/api/src/webhook/index.ts @@ -1 +1,2 @@ export * from './events' +export * from './process' diff --git a/packages/api/src/webhook/process.test.ts b/packages/api/src/webhook/process.test.ts new file mode 100644 index 0000000..212d597 --- /dev/null +++ b/packages/api/src/webhook/process.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from 'vitest' +import { processInvoicePaid, type InvoicePaidOps, type VerifiedInvoiceEvent } from './process' + +/** + * Tests for the `invoice.paid` webhook flow (payment-webhook / issuance-ledger). + * The flow is pure with all side effects injected, so these run with spies and + * NO DB — always executed in `pnpm test`. They pin the behaviour the route used + * to inline: happy-path dedup, ledger confirm, notify→record ordering, and the + * at-least-once guarantee that a notify failure leaves the event unrecorded. + */ + +/** Spy ops: each side effect resolves; tests override individual ones as needed. */ +function spyOps(): InvoicePaidOps { + return { + hasProcessed: vi.fn(async () => false), + markPaid: vi.fn(async () => {}), + notify: vi.fn(async () => {}), + recordOnce: vi.fn(async () => true), + } +} + +const EVENT: VerifiedInvoiceEvent = { + id: 'evt_123', + type: 'invoice.paid', + objectId: 'in_abc', +} + +describe('processInvoicePaid', () => { + it('横-01: unprocessed + objectId → markPaid→notify→recordOnce in order, returns { ok: true }', async () => { + const ops = spyOps() + + const result = await processInvoicePaid(ops, EVENT) + + expect(result).toEqual({ ok: true }) + + // Each side effect runs exactly once. + expect(ops.markPaid).toHaveBeenCalledTimes(1) + expect(ops.markPaid).toHaveBeenCalledWith('in_abc') + expect(ops.notify).toHaveBeenCalledTimes(1) + expect(ops.recordOnce).toHaveBeenCalledTimes(1) + + // Notify text is part of the contract — byte-for-byte stable. + expect(ops.notify).toHaveBeenCalledWith('入金を確認しました(Stripe event evt_123)。') + // recordOnce gets the id+type only (not objectId). + expect(ops.recordOnce).toHaveBeenCalledWith({ id: 'evt_123', type: 'invoice.paid' }) + + // Strict ordering markPaid → notify → recordOnce via invocation order. + const markPaidOrder = (ops.markPaid as ReturnType).mock.invocationCallOrder[0]! + const notifyOrder = (ops.notify as ReturnType).mock.invocationCallOrder[0]! + const recordOrder = (ops.recordOnce as ReturnType).mock.invocationCallOrder[0]! + expect(markPaidOrder).toBeLessThan(notifyOrder) + expect(notifyOrder).toBeLessThan(recordOrder) + }) + + it('横-02: already processed → { ok: true, duplicate: true }, no side effects run', async () => { + const ops = spyOps() + ops.hasProcessed = vi.fn(async () => true) + + const result = await processInvoicePaid(ops, EVENT) + + expect(result).toEqual({ ok: true, duplicate: true }) + expect(ops.markPaid).not.toHaveBeenCalled() + expect(ops.notify).not.toHaveBeenCalled() + expect(ops.recordOnce).not.toHaveBeenCalled() + }) + + it('横-22: notify rejects → rejects (Stripe retries) and recordOnce is NOT called', async () => { + const ops = spyOps() + ops.notify = vi.fn(async () => { + throw new Error('notify boom') + }) + + await expect(processInvoicePaid(ops, EVENT)).rejects.toThrow('notify boom') + + // markPaid runs before notify, so it may have run; recordOnce must NOT — the + // event stays unrecorded so Stripe re-sends (at-least-once). + expect(ops.markPaid).toHaveBeenCalledTimes(1) + expect(ops.recordOnce).not.toHaveBeenCalled() + }) + + it('台帳外 (boundary): objectId null → markPaid skipped, notify + recordOnce still run', async () => { + const ops = spyOps() + const event: VerifiedInvoiceEvent = { id: 'evt_456', type: 'invoice.paid', objectId: null } + + const result = await processInvoicePaid(ops, event) + + expect(result).toEqual({ ok: true }) + expect(ops.markPaid).not.toHaveBeenCalled() + expect(ops.notify).toHaveBeenCalledWith('入金を確認しました(Stripe event evt_456)。') + expect(ops.recordOnce).toHaveBeenCalledWith({ id: 'evt_456', type: 'invoice.paid' }) + }) + + it('duplicate insert: recordOnce returns false → does not change the outcome', async () => { + const ops = spyOps() + ops.recordOnce = vi.fn(async () => false) + + const result = await processInvoicePaid(ops, EVENT) + + expect(result).toEqual({ ok: true }) + expect(ops.recordOnce).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/api/src/webhook/process.ts b/packages/api/src/webhook/process.ts new file mode 100644 index 0000000..6090938 --- /dev/null +++ b/packages/api/src/webhook/process.ts @@ -0,0 +1,68 @@ +/** + * A verified Stripe event, reduced to what the `invoice.paid` flow needs: the + * event id (idempotency key), its type, and the related object id (the Stripe + * Invoice id, or null when absent). Mirrors the shape the Nitro route produces + * from {@link constructEvent} after signature verification. + */ +export interface VerifiedInvoiceEvent { + id: string + type: string + objectId: string | null +} + +/** + * The side effects the `invoice.paid` flow drives, injected so the orchestration + * is unit-testable without a DB or live Stripe (mirrors {@link IssuanceOps}): + * - `hasProcessed` — has this event id already been recorded? (happy-path dedup) + * - `markPaid` — flip the paid invoice's ledger slots to `paid` (idempotent) + * - `notify` — notify the accountant + * - `recordOnce` — record the event id once (returns false on duplicate insert) + */ +export interface InvoicePaidOps { + hasProcessed: (eventId: string) => Promise + markPaid: (invoiceId: string) => Promise + notify: (message: string) => Promise + recordOnce: (event: { id: string, type: string }) => Promise +} + +/** + * Process a verified `invoice.paid` event with at-least-once delivery semantics. + * + * Ordering is load-bearing: + * 1. Dedup (happy path): if the event id was already recorded, skip everything. + * 2. If there is an Invoice id, confirm the issuance ledger (flip slots to + * `paid`). A 台帳外 invoice (no slots) is a no-op, and this runs BEFORE + * notify/record so a failure here leaves the event unrecorded and Stripe + * retries. (issuance-ledger spec: §入金時の paid 確定) + * 3. Notify the accountant FIRST, then record the event. A notify failure + * throws before {@link InvoicePaidOps.recordOnce} runs, so the event stays + * unrecorded and Stripe retries (at-least-once). A notify-success/record-fail + * or two concurrent first deliveries may rarely double-notify, which is + * acceptable for accountant notifications. The `recordOnce` return value is + * intentionally ignored — a duplicate insert does not change the outcome. + * (payment-webhook spec: §event id による冪等処理 / §入金時に会計へ通知) + * + * The notify message text is part of the contract — keep it byte-for-byte stable. + */ +export async function processInvoicePaid( + ops: InvoicePaidOps, + verified: VerifiedInvoiceEvent, +): Promise<{ ok: true, duplicate?: boolean }> { + // Idempotency (happy path): skip if this event was already processed before. + if (await ops.hasProcessed(verified.id)) { + return { ok: true, duplicate: true } + } + + // Confirm the issuance ledger before notify/record so a failure leaves the + // event unrecorded and Stripe retries. + if (verified.objectId) { + await ops.markPaid(verified.objectId) + } + + // Notify FIRST, then record — a notify failure throws before recordOnce runs, + // so the event stays unrecorded and Stripe will retry (at-least-once). + await ops.notify(`入金を確認しました(Stripe event ${verified.id})。`) + await ops.recordOnce({ id: verified.id, type: verified.type }) + + return { ok: true } +} From 2a6fb30dcfb5b0cf4d15f1626e269643cc7928c5 Mon Sep 17 00:00:00 2001 From: kaitoyama Date: Wed, 24 Jun 2026 16:21:06 +0900 Subject: [PATCH 17/30] test: add UI E2E verification harness, scenario ledger, and results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Playwright (playwright-core) harness driving the dev server at :13000 for UI-level verification of 入部/継続/特別/払い戻し + cross-cutting flows. Mints dev sessions and injects the __Host-/Secure session + CSRF cookies (Chromium drops them from Set-Cookie over http://localhost) and pays test invoices with a test card to exercise the invoice.paid → ledger-paid loop. - E2E-SCENARIOS.md: 94-scenario verification ledger (Codex-reviewed for gaps) - scripts/e2e/: harness.mjs, pay.mjs, run_*.mjs per feature, RESULTS.md - eslint: ignore scripts/e2e/** (machine-specific helpers, absolute paths) - .gitignore: scripts/e2e/shots/ (binary screenshots) - add playwright-core devDependency Result: 84/94 green (54 UI/HTTP + 30 unit), 0 FAIL, 10 blocked by environment only (system clock / env restart / DB pre-state / bank-transfer settlement). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01ACKUEYT79nkgEKUjBWjnZM --- .gitignore | 3 + E2E-SCENARIOS.md | 232 ++++++++++++ eslint.config.mjs | 2 + package.json | 1 + pnpm-lock.yaml | 10 + scripts/e2e/RESULTS.md | 64 ++++ scripts/e2e/harness.mjs | 77 ++++ scripts/e2e/pay.mjs | 105 ++++++ scripts/e2e/run_continuation.mjs | 189 ++++++++++ scripts/e2e/run_cross.mjs | 589 +++++++++++++++++++++++++++++++ scripts/e2e/run_join.mjs | 249 +++++++++++++ scripts/e2e/run_paid.mjs | 223 ++++++++++++ scripts/e2e/run_payout.mjs | 290 +++++++++++++++ scripts/e2e/run_sp07_ui.mjs | 122 +++++++ scripts/e2e/run_special.mjs | 369 +++++++++++++++++++ 15 files changed, 2525 insertions(+) create mode 100644 E2E-SCENARIOS.md create mode 100644 scripts/e2e/RESULTS.md create mode 100644 scripts/e2e/harness.mjs create mode 100644 scripts/e2e/pay.mjs create mode 100644 scripts/e2e/run_continuation.mjs create mode 100644 scripts/e2e/run_cross.mjs create mode 100644 scripts/e2e/run_join.mjs create mode 100644 scripts/e2e/run_paid.mjs create mode 100644 scripts/e2e/run_payout.mjs create mode 100644 scripts/e2e/run_sp07_ui.mjs create mode 100644 scripts/e2e/run_special.mjs diff --git a/.gitignore b/.gitignore index d811dc9..27608a6 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ pnpm-debug.log* .DS_Store .idea *.tsbuildinfo + +# E2E verification screenshots (binary evidence, regenerated on each run) +scripts/e2e/shots/ diff --git a/E2E-SCENARIOS.md b/E2E-SCENARIOS.md new file mode 100644 index 0000000..e1dff56 --- /dev/null +++ b/E2E-SCENARIOS.md @@ -0,0 +1,232 @@ +# Checkin — 挙動確認シナリオ一覧(入部 / 継続 / 特別対応 / 払い戻し) + +> Playwright を中心とした実挙動確認のための **シナリオ網羅リスト**。 +> 仕様(`openspec/specs/`)と実装(`packages/api/src/`、`apps/web/`)を突き合わせて洗い出したもの。 +> 各シナリオは「どの経路で・何を前提に・何を期待するか・どう検証するか」をセットで記す。 +> 作成: 2026-06-24 / ブランチ: `claude/checkin-auth-collection` + +--- + +## 0. 凡例・共通前提 + +### 0.1 セッション種別(`/dev/login` で偽造) + +ローカルは traQ OAuth / isct メール確認を経ずに `GET /dev/login` でセッションを発行できる(`import.meta.dev` + `CHECKIN_DEV_LOGIN=1` の二重ガードで本番無効)。 + +| 種別 | URL | セッション内容 | 主な用途 | +|---|---|---|---| +| 未ログイン | (cookie 無し) | — | `/membership` 振り分け・認可拒否 | +| 会員のみ | `/dev/login?as=member` | `traqId=devmember, !hasUser` | 「会員だが未連結」分岐 | +| 利用者あり | `/dev/login?as=user&email=` | `hasUser`(mail_hash 連結) | **入部・継続の発行** | +| 会員+利用者 | `/dev/login?as=both&email=` | `traqId+hasUser` | traQ ID 連結(払い戻しの本人解決前提) | +| 会計 | `/dev/login?as=admin` | `isAdmin` | **特別対応・払い戻し・一覧** | + +> 同じ `email` は同じ person 行に落ちる(冪等 get-or-create)。**新規 customer/台帳が欲しいテストは毎回 fresh な `?email=` を使う**(Stripe 冪等ウィンドウ汚染回避、HANDOVER §8)。 + +### 0.2 CSRF / 状態変更 + +状態変更(発行・取込・実行)は `assertCsrf()` 必須。Playwright(ブラウザ)は `/csrf` cookie 由来の `x-csrf-token` を `plugins/orpc.ts` が自動付与するので UI 操作なら透過。**curl で叩く場合は `/csrf` 取得 → cookie + `x-csrf-token` ヘッダ**が要る。headless+http は Secure な CSRF cookie を保持しにくいので mutation は curl 併用が安全(HANDOVER §8)。 + +### 0.3 環境依存(検証時に意識する点) + +- **期判定は実時刻依存**(`computeTerm(now)`)。今日 = 2026-06-24 は**前期**(活動年度 2026)。**後期系シナリオは「後期日付」を作れないと UI では再現不可** → 単体テスト(`*.test.ts`)or 時計固定が必要。該当箇所に「⏱ 時計依存」を付す。 +- **Stripe**: test キー+4 Price+`stripe listen`(`/webhook/invoice-paid`・`/webhook/account-updated`)配線済み(HANDOVER §10)。 +- **Jomon**: v1 ローカル(`:1323`)で closed-loop 実績あり。`.env` の `JOMON_API_VERSION` で `stub`/`v1`/`v2` 切替。 +- **入金確定(`invoice.paid`)と着金は非同期**。台帳 `paid` 化・会計通知はこの webhook 経由。 + +### 0.4 検証手段の表記 + +`UI`=Playwright ブラウザ操作 / `API`=oRPC を curl 直叩き / `WH`=Stripe webhook(`stripe trigger` or 実支払い)/ `JOMON`=Jomon 取込 / `UT`=単体テスト(vitest)。 + +--- + +## 1. 入部(新規入部・再入部)— `feeType: 'new'` + +エントリ: `/membership`(未ログイン → `新規入部`/`再入部` 選択)。発行 API: `membership.issueInvoice`(`userProc`=利用者本人のみ)。 +価格/カバレッジ: **前期 → ¥4,000 通期(前期+後期)/ 後期 → ¥2,000 後期のみ**、活動年度=**現年度**。 + +| ID | 種別 | 前提 | 操作 | 期待 | 検証 | +|---|---|---|---|---|---| +| 入-01 | 正常 | 未ログイン | `/membership` で `新規入部` を選ぶ | `/verify-email?redirect=%2Fmembership%3Ftype%3Dnew`(redirect 先に `?type=new` を保持し区分を事前選択)へ誘導 | UI | +| 入-02 | 正常 | 未ログイン | `/membership` で `再入部` を選ぶ | `/verify-email?redirect=%2Fmembership%3Ftype%3Drejoin`(`rejoin`→`feeType:new`)へ誘導 | UI | +| 入-03 | 正常 | 未ログイン | `/membership` で `現役` を選ぶ | `/login?redirect=/membership`(traQ)へ誘導 | UI | +| 入-04 | 正常(前期) | 利用者あり(fresh email)| `/membership` でメール・氏名・`新規入部`→発行 | ¥4,000 通期 invoice が finalize、`hostedInvoiceUrl` 導線表示。台帳に前期+後期2スロット `open` | UI+Stripe確認 | +| 入-05 | 正常(後期)⏱ | 利用者あり(後期日付)| 後期に `新規入部` 発行 | ¥2,000 後期のみ invoice、台帳に後期1スロット `open` | UT(時計固定) | +| 入-06 | 認可 | 利用者あり | フォームに**自分以外の**メールを入れて発行 | `FORBIDDEN`「他人の分は発行できない」(mail_hash 不一致) | API | +| 入-07 | 認可 | 会員のみ(`member && !hasUser`)| `/membership` を開く | 発行フォームは出ず、連結を促す案内と「メールアドレスを確認する」ボタン(`/verify-email?redirect=/membership`)を表示(**自動遷移はしない**・押下で遷移) | UI | +| 入-08 | 認可 | 未ログイン | `membership.issueInvoice` を直接叩く | 認可エラー(`userProc` が入力検証より前に拒否) | API | +| 入-09 | 冪等(重複)| 入-04 済み(**未払いのまま**)| 同条件で再発行 | 新 invoice を作らず**既存 `hostedInvoiceUrl` を返す**(台帳 open 再利用) | UI/API | +| 入-10 | 冪等(重複)| 入-04 が**支払い済み**| 同条件で再発行 | `CONFLICT`「対象期間は既に支払い済み」 | API+WH | +| 入-11 | 境界(重複)| 後期のみ `open`/`paid` がある人 | 前期入部(通期)を発行 | `CONFLICT`「期間が重複」(通期×後期の重なり拒否) | UT/API | +| 入-12 | 連打 | 利用者あり | 同条件発行をほぼ同時に複数回 | 台帳一意制約で**単一 invoice に収束**、ガード無し invoice が残らない | UT | +| 入-13 | 異常(設定)| `PRICE_*` 未設定 | 発行 | Stripe 呼び出し前に設定エラー、UI にエラー表示・二重送信させない | API | +| 入-14 | 連結 | `as=both`(traQ+user)| 入部発行 | `users.traq_id` が未設定なら保存、Customer metadata に traQ ID | API+DB | +| 入-15 | 連結 | 既に `traq_id` 持ち | 再発行 | 既存 `traq_id` を上書きしない | DB | +| 入-16 | 連結 | isct のみ(traQ 未認証)| 発行 | `traq_id` 設定されない(発行自体は成功) | API+DB | +| 入-17 | 連結(競合)| session traq_id が他人に属する | 発行 | その traq_id を Customer metadata に書かない・発行は継続 | UT | +| 入-18 | 異常(途中失敗)| finalize を失敗させる | 発行 | draft を void・予約スロット解放・エラー返却(支払い可能 invoice を残さない) | UT | +| 入-19 | 正常(Customer解決)| ① 未連携 ② DB に customer_id 連携済み ③ DB 無いが Stripe に同メール Customer あり | 各状態で発行 | [[stripe-customer]] の3分岐: ①新規作成して保存 ②DB の customer_id 再利用 ③Stripe 検索で既存採用(重複作成しない) | API+Stripe | + +--- + +## 2. 継続 — `feeType: 'continuation'` + +エントリ: `/membership`(`現役` → traQ ログイン → `hasUser` で発行)。発行 API: `membership.issueInvoice`(同上)。 +価格/カバレッジ: **常に ¥4,000 通期**、活動年度=**翌年度**(`base+1`、会計が後期に開始する更新を想定。今年度払い済みとスロット衝突させない)。 + +| ID | 種別 | 前提 | 操作 | 期待 | 検証 | +|---|---|---|---|---|---| +| 継-01 | 正常 | `as=both`(現役相当)| `/membership` で `現役`→メール・氏名で発行 | `feeType:continuation`・¥4,000 通期 invoice、`hostedInvoiceUrl` | UI | +| 継-02 | 境界(年度)| 継-01 | 発行後の台帳行を確認 | 占有スロットの `activity_year` が**翌年度**(発行時点+1) | DB | +| 継-03 | 両立 | 同一人が**今年度**入部済み(paid)| 翌年度の継続を発行 | 年度キーが違うので衝突せず発行できる | API+DB | +| 継-04 | 冪等(重複)| 継続を**未払いのまま**再発行 | 同条件で再発行 | 既存 `hostedInvoiceUrl` を返す | UI/API | +| 継-05 | 冪等(重複)| 継続が**翌年度で支払い済み**| 同条件再発行 | `CONFLICT`「既に支払い済み」 | API+WH | +| 継-06 | 認可 | 利用者あり | 他人メールで継続発行 | `FORBIDDEN`(mail_hash 不一致) | API | +| 継-07 | UI 対応 | — | `区分=現役` を選ぶ | `feeType:'continuation'` で発行されること(新規/再入部は `'new'`) | UI | + +--- + +## 3. 特別対応(会計発行・常に ¥2,000・半期1つ) + +エントリ: `/special-invoice`(会計ナビ「特別発行」)。API: `membership.issueSpecialInvoiceByEmail`(メール指定・初見ユーザーも可)/`issueSpecialInvoice`(`userId` 指定・従来)。両方 `adminProc`。 +価格/カバレッジ: **¥2,000・`coverage` で `zenki`/`kouki` を会計が明示指定(通期不可)**、`activityYear` 任意(後期に集める継続特別は翌年度を渡す)。 + +| ID | 種別 | 前提 | 操作 | 期待 | 検証 | +|---|---|---|---|---|---| +| 特-01 | 正常(前期)| 会計 | `/special-invoice` でメール・`前期(zenki)` 指定→発行 | ¥2,000・前期1スロット `open`、hosted invoice URL を会計に表示(本人へ転送用) | UI | +| 特-02 | 正常(後期追加)| 前期のみ特別を支払い済みの人 | 同人に `後期(kouki)` を発行 | 後期スロットは空きなので新規予約でき、前期・後期が別発行で両立 | API+DB | +| 特-03 | 正常(初見)| 会計・対象は**未ログイン/初見**| メール指定で発行 | mail_hash で person を get-or-create、Customer 作成、発行成功(事前ログイン不要) | UI | +| 特-04 | 認可 | 利用者(非会計)| `issueSpecialInvoice*` を叩く | 認可エラー(`adminProc`)。利用者 UI に特別の導線は出ない | API+UI | +| 特-05 | 認可 | 会計・`userId` 指定で**メール不一致**(未連携対象)| メールを対象者の mail_hash と違う値で発行 | 発行拒否(指定メール=対象者の検証に失敗) | API | +| 特-06 | 異常(ドメイン)| 会計 | 許可ドメイン外メールで `ByEmail` 発行 | ドメインガードで拒否(junk person を作らない) | API | +| 特-07 | 境界(年度)| 会計 | `activityYear` を翌年度で指定発行 | 指定年度のスロットを占有(既定は現年度) | DB | +| 特-08 | 冪等(重複)| 同半期が既に `paid` | 同半期を再発行 | `CONFLICT`「既に支払い済み」 | API | +| 特-09 | 冪等(重複)| 同半期が `open` 未払い | 同半期を再発行 | 既存 `hostedInvoiceUrl` を返す | API | +| 特-10 | 境界(通期不可)| 会計 | `coverage` に通期相当を渡そうとする | スキーマ上 `zenki`/`kouki` のみ(通期は選べない) | API/UT | +| 特-11 | 入金合流 | 特-01 を本人が支払い | `invoice.paid` 受信 | 該当半期スロットが `paid`、会計へ通知(標準と同経路) | WH | + +--- + +## 4. 払い戻し(Jomon 承認分 → Stripe 送金 → 書き戻し) + +エントリ: `/payouts`(会計)。API: `payouts.processApproved`(取込・前進)/`list`/`execute`(行ごと前進・failed 再試行)/`createOnboardingLink`/`onboardingStatus`。全 `adminProc`。 +フロー: Jomon `accepted` 取込 → 厳格バリデーション → `jomon_ref` 冪等 upsert → traQ ID で本人解決 → onboarding ゲート → 原子的クレーム → Stripe transfer(冪等キー `payout:${ref}`)→ `paid`/`failed` → Jomon 書き戻し。 + +### 4.1 取込・本人特定・バリデーション + +| ID | 種別 | 前提 | 操作 | 期待 | 検証 | +|---|---|---|---|---|---| +| 払-01 | 正常 | 会計・Jomon に承認済み(単一 payee・本人 traQ 連結済み・onboarding done)| `/payouts` で「Jomon 取込」 | `ingested` 件数サマリ表示、本人解決→送金フローへ進み一覧更新 | UI+JOMON | +| 払-02 | 冪等 | 払-01 と同じ依頼 | 取込を再実行 | `payouts` 行は1つのまま・状態継承(`jomon_ref` unique 冪等 upsert) | API+DB | +| 払-03 | 異常(バリデーション)| payee/`jomon_ref`/`amount` のいずれか欠落・不正 | 取込 | 既定値で埋めず**拒否**、その依頼は送金へ進めない(0円・誤送金防止) | UT/JOMON | +| 払-04 | 要対応 | payee traQ ID が**未連結**(`users.traq_id` 無し)| 取込 | 送金せず要対応として記録(onboarding/送金へ進めない) | API+DB | +| 払-05 | 要対応(誤送金防止)| 既に `user_id` 確定済みの payout が後続取込で別人に解決 | 取込 | **再リンクしない**・送金しない・要対応 | UT | +| 払-29 | 設定(driver切替)| `JOMON_API_VERSION` を `stub`/`v1`/`v2` に設定 | 取込 | 対応する `JomonClient`(`createJomonClient` 分岐)が使われ、ドメインコードは不変 | API/UT | + +### 4.2 onboarding ゲート([[connect-onboarding]]) + +| ID | 種別 | 前提 | 操作 | 期待 | 検証 | +|---|---|---|---|---|---| +| 払-06 | 正常(未完)| 受取人 `payout_onboarding_status != done` | 取込/execute | connected account を get-or-create、onboarding リンク発行、payout は `onboarding_waiting`(**送金しない**) | UI+API | +| 払-07 | 正常(再利用)| `stripe_connected_account_id` 設定済み | onboarding 要求 | 新規作成せず既存 account を使う | UT | +| 払-30 | 競合 | 同一受取人に onboarding 発行が同時並行 | 並行 `createOnboardingLink` | 永続化される connected account は1つだけ・敗者が作った余分な account を後始末(CAS) | UT | +| 払-31 | 状態機械(初期)| onboarding 未開始の受取人 | `onboardingStatus` | 状態は `none`(初期)・account 無し | API | +| 払-08 | 状態機械 | リンク発行 | `createOnboardingLink` | 状態 `none→requested`(done は後退しない) | API+DB | +| 払-09 | WH 判定 | `account.updated`(`payouts_enabled=true`+`currently_due` 空)受信 | webhook | 受取人 `requested→done` に遷移 | WH | +| 払-10 | WH 判定 | `account.updated`(払い出し不可)受信 | webhook | `done` にしない(`requested` 維持) | WH | +| 払-11 | WH 冪等 | 同一 event id 再送 | webhook | 二重処理しない | WH | +| 払-12 | WH 署名 | 署名欠落/不正の webhook | webhook | 4xx 拒否 | WH/API | +| 払-13 | 再開 | done 化後の `onboarding_waiting` payout | `execute` | 送金フローへ進む | UI+API | +| 払-14 | UI | 会計・userId 解決済み行 | `/payouts` で onboarding 状態確認 | `onboardingStatus` で status と account 有無を表示 | UI | + +### 4.3 送金(原子的クレーム・冪等・結果確定) + +| ID | 種別 | 前提 | 操作 | 期待 | 検証 | +|---|---|---|---|---|---| +| 払-15 | 正常 | done・クレーム可能(`pending`/`onboarding_waiting`/`failed`)| `execute` | `processing` へ条件付き更新→Stripe transfer→`paid`+`stripe_transfer_id` 保存 | UI+Stripe | +| 払-16 | 冪等(二重送金防止)| 同一 `jomon_ref` を同時 execute | 並行実行 | クレーム成功した1つだけ送金、他方は短絡(送金しない) | UT | +| 払-17 | 冪等(再実行)| 既に `paid` | `execute` | `already_paid` で短絡・送金し直さない(balance 不変) | API+Stripe | +| 払-18 | 異常 | transfer が失敗 | `execute` | `failed` に・二重送金しない | UT/API | +| 払-19 | 認可 | 利用者/未ログイン | `processApproved`/`execute`/`list` を叩く | 認可エラー(`adminProc`) | API | + +### 4.4 複数受取人・v1 金額(誤送金/過払い防止) + +| ID | 種別 | 前提 | 操作 | 期待 | 検証 | +|---|---|---|---|---|---| +| 払-20 | 正常(v1 金額)| v1・単一 payee・未払い | 取込/execute | 払い戻し額は申請単位 `current_detail.amount`(repayment_log の amount に依存しない) | JOMON | +| 払-21 | 要対応(複数)| `repayment_logs` が **2人以上**(未払い1人でも)| 取込 | `payouts` 行を作らず needs-review に集計、対象 application id を**会計 UI に警告表示**(自動送金しない) | UI+JOMON | +| 払-22 | 対象外 | 全受取人が支払い済み(未払い0)| 取込 | 送金対象にしない | JOMON | +| 払-23 | 手動でも適用 | 複数受取人の申請 | `payouts.execute` | 単一実行でも送金へ進めない | API | + +### 4.5 書き戻し・バッチ分離 + +| ID | 種別 | 前提 | 操作 | 期待 | 検証 | +|---|---|---|---|---|---| +| 払-24 | 正常(v1)| v1・送金 `paid` | 書き戻し | `PUT .../states/repaid/{trapId}` で repaid_at 書き戻し、`jomon_written_back_at` 記録 | JOMON | +| 払-25 | 再試行(送金しない)| 送金成功・書き戻し未了 | 再 execute | **書き戻しのみ再試行**・送金は再実行しない | API | +| 払-26 | v2 未対応 | `JOMON_API_VERSION=v2`・送金 paid | 書き戻し | 未対応として失敗(警告ログ)、`paid` は保持・`written_back_at` 未設定 | UT | +| 払-27 | バッチ分離 | 取込中に1件エラー | `processApproved` | エラーは集計され残りは継続(全体停止しない) | API | +| 払-28 | failed 自動再試行なし | `failed` を含む一括 | `processApproved` | `failed` は自動再送金せずスキップ集計(再試行は会計の単一 execute のみ) | API | + +--- + +## 5. 横断シナリオ(4 機能の土台) + +### 5.1 発行台帳・入金確定([[issuance-ledger]] / [[payment-webhook]]) + +| ID | 種別 | 前提 | 操作 | 期待 | 検証 | +|---|---|---|---|---|---| +| 横-01 | 正常(入金)| open invoice を本人が支払い | `invoice.paid` 受信 | 署名検証+event 冪等→`charge_group` 全スロット `paid`・支払い時刻記録・会計通知 | WH | +| 横-02 | 冪等(再入金)| 既に `paid` | `invoice.paid` 再送 | `paid` のまま・二重副作用なし | WH | +| 横-03 | 解放(void)| open スロット占有の発行を void | void | スロット解放、同半期に再発行可能 | API/UT | +| 横-04 | 1半期1スロット | 前期占有済み | 同年度・前期を再占有 | 一意制約で拒否 | UT | +| 横-21 | 異常(署名)| 署名欠落/不正の `invoice.paid` | webhook 送信 | raw-body 署名検証に失敗し処理せず 4xx 拒否(通知も台帳確定もしない) | WH/API | +| 横-22 | 異常(通知失敗→再試行)| 未処理 event で `Notifier` が失敗 | webhook | event id を**記録せず**返す → Stripe 再送で再試行可能(at-least-once)。台帳確定とは独立 | UT/WH | +| 横-23 | 境界(台帳外)| 台帳にスロットを持たない invoice | `invoice.paid` 受信 | 台帳確定をスキップし受信は成功(200)として扱う(台帳導入前/台帳外発行の互換) | WH | + +### 5.2 口座振込([[membership-billing]] / bank-transfer) + +| ID | 種別 | 前提 | 操作 | 期待 | 検証 | +|---|---|---|---|---|---| +| 横-05 | 正常 | 任意の発行(入部/継続/特別)| hosted invoice ページを開く | カードと口座振込(`customer_balance`/`jp_bank_transfer`)の両方を選べる | UI+Stripe | +| 横-06 | 非同期着金 | 口座振込を選択 | 振込→着金 | 着金まで invoice は `open`、着金で `invoice.paid` 経路に合流(横-01 と同じ paid 確定・通知) | WH | + +### 5.3 認証・本人連結の前段([[email-verification]] / [[session]] / [[identity]]) + +| ID | 種別 | 前提 | 操作 | 期待 | 検証 | +|---|---|---|---|---|---| +| 横-07 | 正常 | 未ログイン | `/verify-email` で isct メール送信 | 「確認メールを送信しました」、マジックリンク発行(単回・期限・ハッシュ保存)| UI | +| 横-08 | redirect 保持 | `/verify-email?redirect=/membership` | 確認 | リンクが redirect を保持し確認後に `/membership` へ戻る | UI | +| 横-09 | ドメイン外 | 許可外メール | 送信 | エラー表示・メール送られない | UI | +| 横-10 | 連結 | 確認リンク踏破 | `/verify-email/confirm` | セッションに userId 連結(`hasUser`)、`/membership` で発行可能に | UI | +| 横-18 | 認可(OAuth)| traQ OAuth コールバックの `state` 不一致 | `/login/callback` | セッション確立を拒否(CSRF/リプレイ対策、`admin-authorization`) | API | +| 横-19 | 認可(CSRF)| ログイン済み・CSRF トークン欠如/不一致 | 状態変更 API(発行/取込/execute)を叩く | `assertCsrf()` で拒否(double-submit 不一致) | API | +| 横-20 | 異常(トークン)| 期限切れ/使用済みのメール確認リンク | 確認リンク踏破 | 拒否(単回・期限・ハッシュ保存。再利用/期限切れを通さない) | API/UT | +| 横-26 | セッション無効化 | ログイン済み | `/logout` 後に同一 cookie で `auth.me` | 認証不可(サーバ側セッション無効化、`session` spec) | UI/API | + +### 5.4 会計 UI 一覧([[accountant-ui]] / [[payment-listing]]) + +| ID | 種別 | 前提 | 操作 | 期待 | 検証 | +|---|---|---|---|---|---| +| 横-11 | 認可表示 | 未ログイン/会員のみ | `/payments`・`/payouts` を開く | 会計データ非表示・「要会計ログイン」案内、会計導線は admin のみ | UI | +| 横-12 | 請求書タブ | 会計 | `/payments` 請求書タブ | `listInvoices` 結果を表表示・各行に Dashboard リンク | UI | +| 横-13 | 決済セッションタブ | 会計 | タブ切替 | `listCheckoutSessions` 結果表示 | UI | +| 横-14 | フィルタ | 会計 | status を `paid` に変更 | `status:'paid'` で再取得・カーソルリセットで先頭から | UI | +| 横-15 | ページネーション | `hasMore=true` | 次ページ導線 | `startingAfter:nextCursor` で次ページ取得、末尾では導線非表示 | UI | +| 横-16 | 競合防止 | 連続フィルタ変更 | 高速に切替 | request-seq で古い応答を破棄(順序逆転で誤表示しない) | UI | +| 横-17 | ⚠️ USelect 空文字 | 会計 | status フィルタ「すべて」を選ぶ | `value:''` を使わず `'all'` 等センチネル(ハイドレーションで 500 しない)— **実ブラウザでのみ顕在化**するので必ず UI 確認 | UI | +| 横-24 | 正規化(null 許容)| 欠落フィールドのある Stripe 行 | `/payments` 一覧 | DTO 正規化が欠落を null として許容し落ちない(`payments/normalize.ts`) | UT | +| 横-25 | Dashboard URL | test / live キー | 行の Dashboard リンク | test モードは `/test/` を含む URL、live は含まない(モード反映) | UI/UT | + +--- + +## 6. 検証の進め方(推奨順) + +1. **土台**: 横-07〜10(auth)→ `/dev/login` でセッション偽造ができるので、実運用は dev login で代替し、auth は別途確認。 +2. **入部/継続(UI 中心)**: 入-01〜04・継-01〜02 を Playwright で。後期系(入-05・入-11)は ⏱ で UT に寄せる。 +3. **重複防止**: 入-09/入-10/継-04/継-05 を実 Stripe+`stripe listen` で(支払い→`invoice.paid`→再発行拒否)。fresh email を都度使う。 +4. **特別対応**: 特-01〜03 を `/special-invoice` で。特-02(前期→後期追加)は台帳の肝。 +5. **払い戻し**: Jomon v1 ローカルで 払-01(closed-loop)→ 払-06(onboarding 待ち)→ 払-15/17(送金・再実行冪等)→ 払-21(複数受取人警告)。送金は onboarded test account(`acct_1QUM03CW4ItwVkk3`)に紐付け。 +6. **横断 UI**: 横-11〜17(会計一覧・認可表示・USelect 500 回避)。 + +> ⚠️ = 過去に実ブラウザでのみ顕在化した不具合(USelect 空文字 500)。lint/typecheck/build/Codex では検出できないため **Playwright での実描画確認が必須**。 diff --git a/eslint.config.mjs b/eslint.config.mjs index 22b1221..8e7bda4 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -12,6 +12,8 @@ export default withNuxt({ 'packages/db/drizzle/**', 'openspec/**', '.claude/**', + // Machine-specific E2E verification helpers (absolute paths, ad-hoc scripts). + 'scripts/e2e/**', ], }, { // Nuxt pages/layouts map to routes/named layouts, so single-word filenames diff --git a/package.json b/package.json index 7eeaa82..b99cc1d 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@fission-ai/openspec": "1.4.1", "@nuxt/eslint": "^1.16.0", "eslint": "^9.39.0", + "playwright-core": "^1.61.1", "typescript": "^5.9.0", "vitest": "^2.1.9" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 44c3907..d21fde4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: eslint: specifier: ^9.39.0 version: 9.39.4(jiti@2.7.0) + playwright-core: + specifier: ^1.61.1 + version: 1.61.1 typescript: specifier: ^5.9.0 version: 5.9.3 @@ -4974,6 +4977,11 @@ packages: pkg-types@2.3.1: resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + playwright-core@1.61.1: + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} + engines: {node: '>=18'} + hasBin: true + pluralize@8.0.0: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} @@ -11173,6 +11181,8 @@ snapshots: exsolve: 1.0.8 pathe: 2.0.3 + playwright-core@1.61.1: {} + pluralize@8.0.0: {} postcss-calc@10.1.1(postcss@8.5.15): diff --git a/scripts/e2e/RESULTS.md b/scripts/e2e/RESULTS.md new file mode 100644 index 0000000..a324e2a --- /dev/null +++ b/scripts/e2e/RESULTS.md @@ -0,0 +1,64 @@ +# Checkin — UI レベル実挙動検証 結果(2026-06-24) + +> `E2E-SCENARIOS.md` の全 94 シナリオを、サブエージェント+Playwright(playwright-core)+実 Stripe(test)/実 Jomon(v1) で UI レベルから検証した記録。**修正は未実施**(観測のみ)。 +> 環境: dev サーバ http://localhost:13000(`CHECKIN_DEV_LOGIN=1`)/ Stripe test キー+`stripe listen` / Jomon v1 :1323 / 共有ハーネス `scripts/e2e/harness.mjs`。 + +## サマリ(修正・追加検証を反映した最終状態) + +| 機能 | 総数 | 直接PASS(UI/HTTP/API) | UT裏取り(vitest) | FAIL | BLOCKED(環境制約) | +|---|---:|---:|---:|---:|---:| +| 入部 | 19 | 10 | — | 0 | 9 | +| 継続 | 7 | 7 | — | 0 | 0 | +| 特別対応 | 11 | 11 | — | 0 | 0 | +| 払い戻し | 31 | 8 | 23 | 0 | 0 | +| 横断 | 26 | 18 | 7 | 0 | 1 | +| **計** | **94** | **54** | **30** | **0** | **10** | + +- 直接 PASS 54 + UT 裏取り 30 = **84 シナリオが緑**。**FAIL 0**。残り 10 は環境制約(時計/env/DB事前state/口座振込着金)で UI 不能。 +- vitest は **131/131 pass**(webhook 回帰テスト +5)。lint/typecheck/build も緑。 + +## ✅ 解決済み(このセッションで修正・実証) + +### 特-07 — 修正完了・再検証 PASS +- 根因: `special-invoice.vue` の活動年度入力が `type="number"`(@nuxt/ui が number にコアース)なのに `form.activityYear.trim()` を呼び `TypeError`→catch で握り潰し→RPC 未発火。 +- 修正: `:61` を `String(form.activityYear ?? '').trim()` に変更(1行)。 +- 再検証: UI で活動年度=2027 入力→**status 200・metadata.activity_year=2027**、空欄でも従来どおり 2026 で発行(回帰なし)。 + +### paid 系 BLOCKED — 実テストカード(tok_visa/4242)決済で実証 PASS +発行→**実カード決済**→`invoice.paid`→webhook→台帳スロット paid、を closed-loop で実証(再発行が ~2.3s で CONFLICT に転じる+dev ログに通知): +- **横-01**(入金確定+通知)/ **入-10**(入部 paid 拒否)/ **継-05**(継続 paid 拒否)/ **特-08**(特別 paid 拒否)/ **特-11**(special は当該半期スロットのみ paid=kouki は発行可)— すべて PASS。 +- カード経路(tok_visa)で成功、out_of_band フォールバック不要。 + +### 横-01 / 横-02 / 横-22 — テスト欠落を解消(回帰テスト追加) +- webhook 処理を pure 関数 `processInvoicePaid(ops, verified)`(`packages/api/src/webhook/process.ts`)に**等価抽出**し、route は DI で呼ぶだけに。 +- `process.test.ts`(DI spy・DB 非依存・常時実行)で 横-01(markPaid→notify→record の順序)/横-02(duplicate で副作用なし)/横-22(notify 失敗で record せず reject)/台帳外(objectId null)を検証。**131/131 pass**。 +- 横-01 は上記の実カード E2E でも二重に実証。横-21(署名 400)は HTTP で実証済み。 + +## ✅ 主な PASS ハイライト(実値で確認) + +- **入-04**: 前期発行 → Stripe `amount_due=4000 jpy` / `metadata{fee_type:new, term:zenki, activity_year:2026}`。 +- **継-02**: 継続 → `activity_year=2027`(翌年度)。入部(2026)との +1 対比を実額確認。 +- **特-01**: 特別 → `amount_due=2000` / `variant:special, coverage:zenki`。**特-10**: coverage 選択肢は前期/後期のみ(通期無し)。 +- **払-17**: 既存 paid 行 execute → `already_paid` 短絡・transferId 不変(再送金なし)。**払-21**: 複数受取人 → `multiPayeeRefs` で needs-review・payout 行を作らず UI 警告。 +- **横-17**: status「すべて」で **HTTP 500 ゼロ・console error ゼロ**(USelect 罠は回帰せず)。 +- **横-05**: 請求書 `payment_method_types=[card, customer_balance]`・funding=`jp_bank_transfer`。 +- **横-10/18/19/20/26**: メール確認連結・OAuth state 不一致拒否・CSRF 欠落拒否・トークン単回・ログアウト無効化、すべて実挙動で確認。 + +## ⏸ 残 BLOCKED(10)— 環境制約のみ(バグではない) + +| 必要なもの | 該当シナリオ | +|---|---| +| システム時計=後期 | 入-05, 入-11 | +| env 変更+dev 再起動(PRICE 未設定) | 入-13 | +| DB/Stripe 事前 state(traQ 連結・Customer 分岐) | 入-14, 入-15, 入-16, 入-17, 入-19 | +| finalize 失敗注入(UT で代替可) | 入-18 | +| 実口座振込の着金(カード不可) | 横-06 | + +> 注: 入-16(traQ 無しでも本人 mail_hash 一致で発行可)は入-04 の成功で事実観測済み。入-19①(Customer 新規作成)も入-04 でカバー。paid 拒否系(入-10/継-05/特-08/特-11)は本セッションで実カード決済により解決済み。 + +## 成果物 + +- ハーネス: `scripts/e2e/harness.mjs`(playwright-core。`__Host-`/Secure cookie を localhost http で扱うため curl でトークン取得→`addCookies` 注入。CSRF は固定値注入で double-submit 成立) +- 実行スクリプト: `scripts/e2e/run_join.mjs` / `run_continuation.mjs` / `run_special.mjs` / `run_payout.mjs` / `run_cross.mjs` +- スクショ: `scripts/e2e/shots/`(44 枚) +- **アプリ/仕様コードの変更は一切なし。** Stripe は test mode、実送金・新規 Jomon 書き戻しはゼロ。 diff --git a/scripts/e2e/harness.mjs b/scripts/e2e/harness.mjs new file mode 100644 index 0000000..220d515 --- /dev/null +++ b/scripts/e2e/harness.mjs @@ -0,0 +1,77 @@ +// Shared Playwright harness for Checkin UI verification (read-only checks). +// Usage: import { launch, BASE, newCtx, devLogin, freshEmail, shot } from './harness.mjs' +// +// NOTE: Chromium over http://localhost does NOT persist the app's `Secure` +// `__Host-` cookies from Set-Cookie, so we mint the dev session via curl and +// INJECT the session + CSRF cookies with ctx.addCookies(). CSRF is a stateless +// double-submit (server compares cookie === `x-csrf-token`), so any fixed CSRF +// value works as long as cookie and header match — the in-page oRPC plugin +// reads the (non-httpOnly) CSRF cookie and mirrors it into the header. +import { chromium } from 'playwright-core' +import { mkdirSync } from 'node:fs' +import { execFileSync } from 'node:child_process' + +export const BASE = process.env.E2E_BASE || 'http://localhost:13000' +const EXE = '/home/kaitoyama/.cache/ms-playwright/chromium-1169/chrome-linux/chrome' +export const SHOTS = '/home/kaitoyama/Checkin/scripts/e2e/shots' +const SESSION_COOKIE = '__Host-checkin_session' +const CSRF_COOKIE = '__Host-checkin_csrf' +const CSRF_VALUE = 'e2e-csrf-token-fixed' +mkdirSync(SHOTS, { recursive: true }) + +export async function launch() { + return chromium.launch({ executablePath: EXE, headless: true, args: ['--no-sandbox'] }) +} + +// Fresh browser context (no cookie bleed between scenarios). +export async function newCtx(browser) { + return browser.newContext({ baseURL: BASE }) +} + +// Mint a dev session token via curl (server sets it on a 302 we read from -i). +function mintToken(as, email) { + const u = new URL(BASE + '/dev/login') + u.searchParams.set('as', as) + if (email) u.searchParams.set('email', email) + u.searchParams.set('redirect', '/') + const hdrs = execFileSync('curl', ['-s', '-i', u.toString()], { encoding: 'utf8' }) + const m = hdrs.match(/set-cookie:\s*__Host-checkin_session=([^;]+)/i) + if (!m) throw new Error('dev/login did not return a session cookie (as=' + as + ')') + return m[1] +} + +// Establish an authenticated session in `ctx` for the given identity by injecting +// the session + CSRF cookies. Pass the SAME `ctx` whose pages you will drive. +// as: 'admin' | 'member' | 'user' | 'both'. email used for user/both (fresh per test). +// Returns { token }. For anon, skip calling this. +export async function devLogin(ctx, { as = 'admin', email } = {}) { + const token = mintToken(as, email) + await ctx.addCookies([ + { name: SESSION_COOKIE, value: token, domain: 'localhost', path: '/', httpOnly: true, secure: true, sameSite: 'Lax' }, + { name: CSRF_COOKIE, value: CSRF_VALUE, domain: 'localhost', path: '/', httpOnly: false, secure: true, sameSite: 'Lax' }, + ]) + return { token } +} + +// Unique isct email per scenario to avoid ledger/customer collisions. +export function freshEmail(tag) { + const n = `${tag}-${process.pid}-${Math.floor(performance.now())}` + return `e2e-${n}@m.isct.ac.jp` +} + +export async function shot(page, name) { + const path = `${SHOTS}/${name}.png` + await page.screenshot({ path, fullPage: true }).catch(() => {}) + return path +} + +// Convenience: read auth.me via the in-page orpc client is overkill; just hit the +// public endpoint through fetch within the page context (carries cookies). +export async function authMe(page) { + return page.evaluate(async (base) => { + const r = await fetch(base + '/rpc/auth/me', { + method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}', + }) + return { status: r.status, body: await r.json().catch(() => null) } + }, BASE) +} diff --git a/scripts/e2e/pay.mjs b/scripts/e2e/pay.mjs new file mode 100644 index 0000000..0a05e64 --- /dev/null +++ b/scripts/e2e/pay.mjs @@ -0,0 +1,105 @@ +// pay.mjs — Pay a finalized (open, send_invoice) Stripe invoice in TEST mode. +// +// Strategy (per the task brief): +// 1. GET /v1/invoices/{id} → grab `customer` (cus_…) and assert open/send_invoice. +// 2. POST /v1/payment_methods type=card card[token]=tok_visa → pm_… (4242 Visa). +// 3. POST /v1/payment_methods/{pm}/attach customer={cus}. +// 4. POST /v1/invoices/{id}/pay payment_method={pm} off_session=true → paid. +// If the card path errors, FALL BACK to paid_out_of_band=true (no charge, but +// invoice.paid still fires) and flag which path was used. +// +// READ-ONLY w.r.t. app/spec code. Run standalone: node scripts/e2e/pay.mjs inv_123 +// Or import { payInvoice } from './pay.mjs'. +import { readFileSync } from 'node:fs' +import { execFileSync } from 'node:child_process' + +function envVal(key) { + const t = readFileSync('/home/kaitoyama/Checkin/.env', 'utf8') + const m = t.match(new RegExp('^' + key + '=(.*)$', 'm')) + return m ? m[1].replace(/^['"]|['"]$/g, '') : '' +} +const STRIPE_SECRET_KEY = envVal('STRIPE_SECRET_KEY') + +// Low-level Stripe REST call via curl. `form` is an array of "k=v" strings. +// method: 'GET' | 'POST'. Returns parsed JSON (or {_raw} on parse failure). +function stripe(method, path, form = []) { + const args = ['-s', '-X', method, `https://api.stripe.com/v1/${path}`, + '-u', `${STRIPE_SECRET_KEY}:`] + for (const f of form) { args.push('-d', f) } + const out = execFileSync('curl', args, { encoding: 'utf8' }) + try { return JSON.parse(out) } + catch { return { _raw: out.slice(0, 400) } } +} + +export function getInvoice(invoiceId) { + return stripe('GET', `invoices/${invoiceId}`) +} + +/** + * Pay an open/send_invoice invoice with the test Visa (tok_visa). Falls back to + * paid_out_of_band if the card path errors. Returns: + * { method: 'card'|'out_of_band'|'already_paid'|'error', + * status, invoiceId, customer, paymentMethod?, charge?, error?, invoice } + */ +export function payInvoice(invoiceId) { + const inv = getInvoice(invoiceId) + if (!inv || inv.error || !inv.id) { + return { method: 'error', status: null, invoiceId, error: inv?.error?.message || inv?._raw || 'invoice fetch failed', invoice: inv } + } + if (inv.status === 'paid') { + return { method: 'already_paid', status: 'paid', invoiceId, customer: inv.customer, invoice: inv } + } + const customer = inv.customer + if (!customer) { + return { method: 'error', status: inv.status, invoiceId, error: 'invoice has no customer', invoice: inv } + } + + // --- Card path (tok_visa → 4242, no 3DS) ----------------------------------- + let cardError = null + try { + const pm = stripe('POST', 'payment_methods', ['type=card', 'card[token]=tok_visa']) + if (pm.error || !pm.id) { throw new Error('pm create: ' + (pm.error?.message || JSON.stringify(pm).slice(0, 160))) } + + const attach = stripe('POST', `payment_methods/${pm.id}/attach`, [`customer=${customer}`]) + if (attach.error) { throw new Error('attach: ' + attach.error.message) } + + const paid = stripe('POST', `invoices/${invoiceId}/pay`, [`payment_method=${pm.id}`, 'off_session=true']) + if (paid.error || paid.status !== 'paid') { + throw new Error('pay: ' + (paid.error?.message || `status=${paid.status}`)) + } + return { + method: 'card', status: paid.status, invoiceId, customer, + paymentMethod: pm.id, charge: paid.charge, amountPaid: paid.amount_paid, + currency: paid.currency, invoice: paid, + } + } + catch (e) { + cardError = e.message + } + + // --- Fallback: paid_out_of_band (no charge, invoice.paid still fires) ------- + const oob = stripe('POST', `invoices/${invoiceId}/pay`, ['paid_out_of_band=true']) + if (oob.error || oob.status !== 'paid') { + return { + method: 'error', status: oob.status, invoiceId, customer, + error: `card path failed (${cardError}); out_of_band also failed: ${oob.error?.message || oob.status}`, + invoice: oob, + } + } + return { + method: 'out_of_band', status: oob.status, invoiceId, customer, + cardError, amountPaid: oob.amount_paid, currency: oob.currency, invoice: oob, + } +} + +// CLI: node scripts/e2e/pay.mjs +if (import.meta.url === `file://${process.argv[1]}`) { + const id = process.argv[2] + if (!id) { console.error('usage: node pay.mjs '); process.exit(1) } + const r = payInvoice(id) + console.log(JSON.stringify({ + method: r.method, status: r.status, invoiceId: r.invoiceId, + customer: r.customer, paymentMethod: r.paymentMethod, charge: r.charge, + amountPaid: r.amountPaid, currency: r.currency, error: r.error, cardError: r.cardError, + }, null, 2)) +} diff --git a/scripts/e2e/run_continuation.mjs b/scripts/e2e/run_continuation.mjs new file mode 100644 index 0000000..45a091c --- /dev/null +++ b/scripts/e2e/run_continuation.mjs @@ -0,0 +1,189 @@ +// 継続(部費) 機能 (feeType:'continuation', entry /membership 区分=現役) — +// UI-level real-behavior verification. READ-ONLY: does not modify app/spec code. +// Run from repo root: +// cd /home/kaitoyama/Checkin && node scripts/e2e/run_continuation.mjs +// +// Spec under test: 継続 is ALWAYS ¥4,000 通期 and occupies the NEXT activity year +// (今日は前期=活動年度 2026 ⇒ 継続 = 2027). Issuance API is the same as 入部 +// (membership.issueInvoice, userProc, own mail_hash only). Form 区分 "現役" maps to +// feeType:'continuation'; "新規入部/再入部" maps to 'new'. +import { launch, newCtx, devLogin, freshEmail, authMe, shot, BASE } from './harness.mjs' + +const browser = await launch() +const results = [] +const log = (...a) => console.log(...a) +const rec = (id, verdict, observed, note = '') => { + results.push({ id, verdict, observed, note }) + log(`\n[${id}] ${verdict} :: ${observed}${note ? ' // ' + note : ''}`) +} + +// Call issueInvoice via in-page fetch using the oRPC RPC envelope ({"json":{...}}), +// mirroring the app's $orpc client. Carries injected cookies + CSRF header. +// Returns {status, ok:{invoiceId,hostedInvoiceUrl}|undefined, code, body}. +async function issueInvoice(page, payload, { csrf = true } = {}) { + return page.evaluate(async ({ base, payload, csrf }) => { + const headers = { 'content-type': 'application/json' } + if (csrf) { + const m = document.cookie.match(/(?:^|;\s*)__Host-checkin_csrf=([^;]+)/) + headers['x-csrf-token'] = m ? m[1] : '' + } + const r = await fetch(base + '/rpc/membership/issueInvoice', { + method: 'POST', headers, body: JSON.stringify({ json: payload }), + }) + let parsed + try { parsed = await r.json() } catch { parsed = await r.text().catch(() => null) } + const ok = parsed?.json && parsed.json.invoiceId ? parsed.json : undefined + const code = parsed?.json?.code + return { status: r.status, ok, code, body: parsed } + }, { base: BASE, payload, csrf }) +} + +const collected = {} // invoiceIds we want to inspect in Stripe afterwards + +try { + // --------------------------------------------------------------------------- + // 継-01: fresh user, REAL UI FORM — fill own email + name, select 区分=現役, + // submit → status 200 + hostedInvoiceUrl. (Stripe amount/metadata checked + // out-of-band via the printed invoiceId.) + // 継-07: selecting 区分=現役 issues feeType:'continuation' — proven below by + // (a) the form's select value being 'continuation', (b) the issued + // invoice's metadata fee_type=continuation (checked in Stripe step). + // --------------------------------------------------------------------------- + let invoice01 = null + { + const ctx = await newCtx(browser); const page = await ctx.newPage() + const email = freshEmail('cont01') + await devLogin(ctx, { as: 'user', email }) + await page.goto(BASE + '/membership', { waitUntil: 'networkidle' }) + const me = (await authMe(page)).body?.json + // Fill the real form. + await page.locator('input[type=email]').first().fill(email) + // name input: the text/non-email input. + const nameInput = page.locator('input:not([type=email])').first() + await nameInput.fill('E2E Continuation Test') + // 区分 USelect renders a native ). Drive it by opening the trigger and clicking the option whose label +// matches. `label` is the visible text ('すべて' | 'draft' | 'open' | 'paid' | …). +async function pickFilter(page, label) { + const trigger = page.locator('button', { hasText: /^(すべて|draft|open|paid|uncollectible|void|complete|expired)$/ }).first() + await trigger.click() + await page.waitForTimeout(150) + await page.locator('[role=option]', { hasText: new RegExp(`^${label}$`) }).first().click() +} + +// curl a Nitro route with explicit cookies/headers (bypasses browser __Host- limits). +function curl(args) { + return execFileSync('curl', ['-s', ...args], { encoding: 'utf8' }) +} +// Mint a dev session token directly (for raw HTTP scenarios). +function mintToken(as, email) { + const u = new URL(BASE + '/dev/login') + u.searchParams.set('as', as) + if (email) u.searchParams.set('email', email) + u.searchParams.set('redirect', '/') + const hdrs = curl(['-i', u.toString()]) + const m = hdrs.match(/set-cookie:\s*__Host-checkin_session=([^;]+)/i) + if (!m) throw new Error('dev/login no session cookie') + return m[1] +} + +const browser = await launch() +try { + // =========================================================================== + // 認証 (5.3) + // =========================================================================== + + // 横-07: /verify-email で isct メール送信 → 「確認メールを送信しました」+ ログにリンク + { + const ctx = await newCtx(browser); const page = await ctx.newPage() + await devLogin(ctx, { as: 'member' }) // any session carries CSRF cookie; pub proc + assertCsrf + const email = freshEmail('x07') + const mark = logMark() + await page.goto(BASE + '/verify-email', { waitUntil: 'networkidle' }) + const st = await submitVerifyEmail(page, email) + await page.waitForTimeout(500) + const chunk = logSince(mark) + const link = lastConfirmLink(chunk) + await shot(page, 'cross-07-verify-send') + const ok = st.sentVisible && !!link + rec('横-07', ok ? 'PASS' : 'FAIL', + `sentBanner=${st.sentVisible} magicLinkInLog=${!!link} link=${link ? link.slice(0, 70) + '…' : null}`) + await ctx.close() + } + + // 横-08: /verify-email?redirect=/membership → リンク GET 後の Location が redirect を保持 + // NOTE: link URL 自体には token のみ(redirect は DB 保存)。実挙動は confirm の + // 302 Location を観測して redirect 反映を検証する。 + let confirmLink08 = null + { + const ctx = await newCtx(browser); const page = await ctx.newPage() + await devLogin(ctx, { as: 'member' }) + const email = freshEmail('x08') + const mark = logMark() + await page.goto(BASE + '/verify-email?redirect=/membership', { waitUntil: 'networkidle' }) + const st = await submitVerifyEmail(page, email) + await page.waitForTimeout(500) + confirmLink08 = lastConfirmLink(logSince(mark)) + // Do NOT GET the link here (single-use): consuming it would invalidate 横-10's + // "actually GET the link" check. The link URL carries only ?token=; the + // redirect is stored server-side and is proven (Location=/membership) in 横-10. + await shot(page, 'cross-08-redirect') + const ok = st.sentVisible && !!confirmLink08 + rec('横-08', ok ? 'PASS' : 'FAIL', + `sentBanner=${st.sentVisible} magicLinkInLog=${!!confirmLink08} link=${confirmLink08 ? confirmLink08.slice(0, 70) + '…' : null}`, + 'redirect=/membership で送信→リンク発行(redirect は DB 保存、反映は 横-10 の 302 Location で確認)') + await ctx.close() + } + + // 横-09: 許可ドメイン外 foo@example.com → エラー表示・ログにリンク出ない + { + const ctx = await newCtx(browser); const page = await ctx.newPage() + await devLogin(ctx, { as: 'member' }) + const mark = logMark() + await page.goto(BASE + '/verify-email', { waitUntil: 'networkidle' }) + const st = await submitVerifyEmail(page, 'foo@example.com') + await page.waitForTimeout(500) + const link = lastConfirmLink(logSince(mark)) + // Also capture the visible error banner text. + const errText = await page.locator('.text-error, [role=alert], h3, [class*="title"]') + .filter({ hasText: /許可|allowed|domain|失敗/ }).first().textContent().catch(() => null) + await shot(page, 'cross-09-bad-domain') + const ok = !link && (st.errVisible || !!errText) + rec('横-09', ok ? 'PASS' : 'FAIL', + `errBanner=${st.errVisible || !!errText} errText=${(errText || '').trim().slice(0, 40)} magicLinkInLog=${!!link}`, + ok ? 'メール未送信(リンク無し)' : '') + await ctx.close() + } + + // 横-10: 横-07/08 のリンクを実際に GET → セッションに userId 連結 → auth.me hasUser=true → redirect 先 + // confirmLink08 (redirect=/membership) を使う。anon で GET → Set-Cookie session を拾い、 + // その session で auth.me を叩く。 + let usedLink10 = null + { + if (!confirmLink08) { + rec('横-10', 'BLOCKED', '横-08 のリンク未取得(先行依存)') + } else { + usedLink10 = confirmLink08 + const path = confirmLink08.replace(/^https?:\/\/[^/]+/, '') + const head = curl(['-i', BASE + path]) + const status = (head.match(/HTTP\/[\d.]+ (\d+)/) || [])[1] + const location = (head.match(/^location:\s*(\S+)/im) || [])[1] + const sess = (head.match(/set-cookie:\s*__Host-checkin_session=([^;]+)/i) || [])[1] + // auth.me with that session via curl. + let me = null + if (sess) { + const body = curl([ + '-X', 'POST', BASE + '/rpc/auth/me', + '-H', 'content-type: application/json', + '-H', `cookie: __Host-checkin_session=${sess}`, + '--data', '{}', + ]) + try { me = JSON.parse(body).json } catch { me = body } + } + const ok = status === '302' && location === '/membership' && me?.hasUser === true && me?.authenticated === true + rec('横-10', ok ? 'PASS' : 'FAIL', + `confirmStatus=${status} Location=${location} sessionSet=${!!sess} auth.me=${JSON.stringify(me)}`) + } + } + + // 横-20: 横-10 で使ったリンクを再 GET → 拒否(単回)。不正トークンの confirm も拒否。 + { + if (!usedLink10) { + rec('横-20', 'BLOCKED', '横-10 で消費したリンク未取得') + } else { + const path = usedLink10.replace(/^https?:\/\/[^/]+/, '') + const head = curl(['-i', BASE + path]) // reuse the now-consumed link + const status = (head.match(/HTTP\/[\d.]+ (\d+)/) || [])[1] + const location = (head.match(/^location:\s*(\S+)/im) || [])[1] + const reusedSess = (head.match(/set-cookie:\s*__Host-checkin_session=([^;]+)/i) || [])[1] + // bogus token + const head2 = curl(['-i', BASE + '/verify-email/confirm?token=deadbeefnope']) + const status2 = (head2.match(/HTTP\/[\d.]+ (\d+)/) || [])[1] + const location2 = (head2.match(/^location:\s*(\S+)/im) || [])[1] + const bogusSess = (head2.match(/set-cookie:\s*__Host-checkin_session=([^;]+)/i) || [])[1] + // Rejected = redirected to /?verify=invalid AND no fresh session minted. + const reuseRejected = /verify=invalid/.test(location || '') && !reusedSess + const bogusRejected = /verify=invalid/.test(location2 || '') && !bogusSess + const ok = reuseRejected && bogusRejected + rec('横-20', ok ? 'PASS' : 'FAIL', + `reuse: status=${status} Location=${location} sessionMinted=${!!reusedSess} | bogus: status=${status2} Location=${location2} sessionMinted=${!!bogusSess}`, + ok ? '単回・不正とも拒否(/?verify=invalid, セッション未確立)' : '') + } + } + + // 横-18: traQ OAuth state 不一致 → /login/callback?state=wrong&code=x (state cookie 無し) → 拒否 + { + const head = curl(['-i', BASE + '/login/callback?state=wrong&code=x']) + const status = (head.match(/HTTP\/[\d.]+ (\d+)/) || [])[1] + const location = (head.match(/^location:\s*(\S+)/im) || [])[1] + const sess = (head.match(/set-cookie:\s*__Host-checkin_session=([^;]+)/i) || [])[1] + // Handler redirects to /?login=error and mints no session when state mismatches. + const ok = /login=error/.test(location || '') && !sess + rec('横-18', ok ? 'PASS' : 'FAIL', + `status=${status} Location=${location} sessionMinted=${!!sess}`, + ok ? 'state 不一致 → /?login=error, セッション未確立' : '') + } + + // 横-19: CSRF — 会計セッション付きで x-csrf-token を外す/書き換えて状態変更直叩き → 拒否 + { + const token = mintToken('admin') + // (a) header 無し + const noHdr = curl([ + '-i', '-X', 'POST', BASE + '/rpc/payouts/processApproved', + '-H', 'content-type: application/json', + '-H', `cookie: __Host-checkin_session=${token}; __Host-checkin_csrf=e2e-csrf-token-fixed`, + '--data', '{"json":{}}', + ]) + const noHdrStatus = (noHdr.match(/HTTP\/[\d.]+ (\d+)/) || [])[1] + const noHdrCode = (noHdr.match(/"code":"([A-Z_]+)"/) || [])[1] + // (b) header 不一致 + const badHdr = curl([ + '-i', '-X', 'POST', BASE + '/rpc/payouts/processApproved', + '-H', 'content-type: application/json', + '-H', 'x-csrf-token: MISMATCH', + '-H', `cookie: __Host-checkin_session=${token}; __Host-checkin_csrf=e2e-csrf-token-fixed`, + '--data', '{"json":{}}', + ]) + const badHdrStatus = (badHdr.match(/HTTP\/[\d.]+ (\d+)/) || [])[1] + const badHdrCode = (badHdr.match(/"code":"([A-Z_]+)"/) || [])[1] + const rejected = (s, c) => s === '403' || c === 'FORBIDDEN' || /CSRF/i.test(noHdr + badHdr) + const ok = rejected(noHdrStatus, noHdrCode) && rejected(badHdrStatus, badHdrCode) + rec('横-19', ok ? 'PASS' : 'FAIL', + `noHeader: status=${noHdrStatus} code=${noHdrCode} | mismatch: status=${badHdrStatus} code=${badHdrCode}`, + 'admin session 有・CSRF header 欠落/不一致で processApproved 直叩き') + } + + // 横-26: ログアウト — 会計ログイン → POST /logout → 同 cookie で auth.me → unauthenticated + { + const token = mintToken('admin') + const cookie = `__Host-checkin_session=${token}; __Host-checkin_csrf=e2e-csrf-token-fixed` + // me BEFORE logout + const before = JSON.parse(curl([ + '-X', 'POST', BASE + '/rpc/auth/me', '-H', 'content-type: application/json', + '-H', `cookie: ${cookie}`, '--data', '{}', + ])).json + // POST /logout (Nitro, requires CSRF header == cookie) + const lo = curl([ + '-i', '-X', 'POST', BASE + '/logout', + '-H', 'x-csrf-token: e2e-csrf-token-fixed', + '-H', `cookie: ${cookie}`, + ]) + const loStatus = (lo.match(/HTTP\/[\d.]+ (\d+)/) || [])[1] + // me AFTER logout with the SAME session token (server-side invalidation) + const after = JSON.parse(curl([ + '-X', 'POST', BASE + '/rpc/auth/me', '-H', 'content-type: application/json', + '-H', `cookie: ${cookie}`, '--data', '{}', + ])).json + const ok = before?.admin === true && loStatus === '200' && after?.authenticated === false + rec('横-26', ok ? 'PASS' : 'FAIL', + `before.admin=${before?.admin} logoutStatus=${loStatus} after.authenticated=${after?.authenticated}`, + 'サーバ側セッション無効化(同トークンで再 auth.me が unauthenticated)') + } + + // =========================================================================== + // 会計UI一覧 (5.4) + // =========================================================================== + + // 横-11: 未ログイン / 会員のみで /payments → 会計データ非表示・要会計ログイン + { + // anon + const ctxA = await newCtx(browser); const pA = await ctxA.newPage() + await pA.goto(BASE + '/payments', { waitUntil: 'networkidle' }) + await pA.waitForTimeout(600) + const anonPrompt = await pA.locator('text=会計ログインが必要').count() + const anonTable = await pA.locator('table').count() + const anonLoginBtn = await pA.locator('text=会計でログイン').count() + await shot(pA, 'cross-11-anon') + await ctxA.close() + // member-only + const ctxM = await newCtx(browser); const pM = await ctxM.newPage() + await devLogin(ctxM, { as: 'member' }) + await pM.goto(BASE + '/payments', { waitUntil: 'networkidle' }) + await pM.waitForTimeout(600) + const memPrompt = await pM.locator('text=会計ログインが必要').count() + const memTable = await pM.locator('table').count() + await shot(pM, 'cross-11-member') + await ctxM.close() + const ok = anonPrompt > 0 && anonTable === 0 && anonLoginBtn > 0 && memPrompt > 0 && memTable === 0 + rec('横-11', ok ? 'PASS' : 'FAIL', + `anon: prompt=${anonPrompt > 0} table=${anonTable} loginBtn=${anonLoginBtn > 0} | member: prompt=${memPrompt > 0} table=${memTable}`, + '会計データ非表示・会計ログイン導線のみ(admin 専用)') + } + + // 横-12: 会計で /payments 請求書タブ → listInvoices が表表示(直近 test invoice が出る)、各行 Dashboard リンク + let firstInvoiceDashUrl = null + { + const ctx = await newCtx(browser); const page = await ctx.newPage() + await devLogin(ctx, { as: 'admin' }) + await page.goto(BASE + '/payments', { waitUntil: 'networkidle' }) + await page.waitForTimeout(1500) + // Data rows = rows that carry a Dashboard link (the empty-state row has none). + // NOTE: each row has TWO font-mono.text-xs cells (ID + 支払いID), so counting + // those double-counts; count rows by their first-column ID cell instead. + const dataRows = await page.locator('tbody tr td:first-child.font-mono').count() + const dashLinks = await page.locator('tbody a[target="_blank"], tbody a[href*="dashboard.stripe"]').count() + // capture first row dashboard href for 横-25 + firstInvoiceDashUrl = await page.locator('tbody tr').first().locator('a').first().getAttribute('href').catch(() => null) + await shot(page, 'cross-12-invoices') + const ok = dataRows > 0 && dashLinks === dataRows + rec('横-12', ok ? 'PASS' : 'FAIL', + `dataRows=${dataRows} dashboardLinks=${dashLinks} firstDashHref=${firstInvoiceDashUrl}`, + '直近 test invoice が表に表示・各行にちょうど 1 つの Dashboard リンク') + await ctx.close() + } + + // 横-25: 行の Dashboard リンク URL が test モードで /test/ を含む + { + let href = firstInvoiceDashUrl + if (!href) { + const ctx = await newCtx(browser); const page = await ctx.newPage() + await devLogin(ctx, { as: 'admin' }) + await page.goto(BASE + '/payments', { waitUntil: 'networkidle' }) + await page.waitForTimeout(1500) + href = await page.locator('tbody tr').first().locator('a').first().getAttribute('href').catch(() => null) + await ctx.close() + } + const ok = !!href && href.includes('dashboard.stripe.com') && href.includes('/test/') + rec('横-25', ok ? 'PASS' : (href ? 'FAIL' : 'BLOCKED'), + `dashboardUrl=${href}`, + ok ? 'test モードなので /test/ を含む(sk_test_)' : (href ? '' : '請求書行ゼロ')) + } + + // 横-13: 決済セッションタブ → listCheckoutSessions 結果表示 + { + const ctx = await newCtx(browser); const page = await ctx.newPage() + await devLogin(ctx, { as: 'admin' }) + await page.goto(BASE + '/payments', { waitUntil: 'networkidle' }) + await page.getByRole('tab', { name: '決済セッション' }).click().catch(async () => { + await page.locator('text=決済セッション').first().click() + }) + await page.waitForTimeout(1500) + // The checkout tab is now active: look at its table/empty-state. + const dataRows = await page.locator('tbody tr td.font-mono.text-xs').count() + const emptyMsg = await page.locator('text=表示できる決済セッションがありません').count() + const errBanner = await page.locator('text=一覧の取得に失敗').count() + await shot(page, 'cross-13-checkout') + // PASS if the tab rendered a list (rows) OR a clean empty-state (no error). + const ok = errBanner === 0 && (dataRows > 0 || emptyMsg > 0) + rec('横-13', ok ? 'PASS' : 'FAIL', + `dataRows=${dataRows} emptyState=${emptyMsg > 0} errorBanner=${errBanner > 0}`, + dataRows > 0 ? 'セッション行表示' : 'クリーンな空表示(エラー無し)') + await ctx.close() + } + + // 横-14: status を paid に変更 → status:'paid' で再取得・カーソルリセットで先頭から + { + const ctx = await newCtx(browser); const page = await ctx.newPage() + await devLogin(ctx, { as: 'admin' }) + // capture the outgoing listInvoices request payloads + const reqs = [] + page.on('request', (r) => { + if (r.url().includes('/rpc/payments/listInvoices') && r.method() === 'POST') { + reqs.push(r.postData()) + } + }) + await page.goto(BASE + '/payments', { waitUntil: 'networkidle' }) + await page.waitForTimeout(1200) + // change USelect to paid (button + portal listbox; no native (the coverage one). Enumerate option labels. + const select = page.locator('select').first() + const optionLabels = await select.locator('option').allTextContents().catch(() => []) + const optionValues = await select.evaluate(s => + Array.from(s.options).map(o => o.value)).catch(() => []) + await shot(page, 'special-10-coverage-options') + const hasZenki = optionValues.includes('zenki') + const hasKouki = optionValues.includes('kouki') + const hasTsuki = optionValues.some(v => /tsuki|annual|full|year|通期/.test(v)) + || optionLabels.some(l => l.includes('通期')) + const ok = hasZenki && hasKouki && !hasTsuki && optionValues.length === 2 + rec('特-10', ok ? 'PASS' : 'FAIL', + `optionValues=${JSON.stringify(optionValues)} labels=${JSON.stringify(optionLabels)} 通期=${hasTsuki}`, + '前期(zenki)/後期(kouki) のみ・通期なし') + await ctx.close() + } + + // =========================================================================== + // 特-01 + 特-03: admin が 初見 fresh email・coverage=前期(zenki) を REAL UI FORM で発行 + // → status 200・hosted invoice URL 表示。事前 user 行が無くても発行成功(特-03)。 + // Stripe で amount_due=2000 jpy・metadata を確認(特-01)。 + // =========================================================================== + let inv01 = null + { + const ctx = await newCtx(browser); const page = await ctx.newPage() + await devLogin(ctx, { as: 'admin' }) + const email = freshEmail('sp01') + // Sanity: this fresh person has NO prior user row (特-03 precondition). + const preexisting = userIdForEmail(email) + await page.goto(BASE + '/special-invoice', { waitUntil: 'networkidle' }) + await page.waitForTimeout(400) + // Real form fill: email + name; coverage default = zenki (前期のみ). + await page.locator('input[type=email]').first().fill(email) + await page.locator('input[name="name"], input[placeholder*="太郎"]').first().fill('Special Zenki').catch(() => {}) + const coverageBefore = await page.locator('select').first().inputValue().catch(() => null) + await shot(page, 'special-01-form-before') + const respP = page.waitForResponse( + r => r.url().includes('/rpc/membership/issueSpecialInvoiceByEmail'), { timeout: 30000 }, + ).catch(() => null) + await page.locator('button[type=submit]').first().click().catch(() => {}) + const resp = await respP + let status = null, body = null + if (resp) { status = resp.status(); body = await resp.json().catch(() => null) } + const inv = body?.json + await page.waitForTimeout(1200) + const successAlert = await page.locator('text=特別請求書を発行しました').count() + const urlInput = await page.locator('input[readonly]').first().inputValue().catch(() => null) + const hostedShown = !!(urlInput && urlInput.startsWith('http')) + const idShown = await page.locator('text=請求書 ID').count() + await shot(page, 'special-01-after') + inv01 = { email, invoiceId: inv?.invoiceId, hostedInvoiceUrl: inv?.hostedInvoiceUrl } + collected.sp01 = inv?.invoiceId + const ok01 = status === 200 && typeof inv?.hostedInvoiceUrl === 'string' + && inv.hostedInvoiceUrl.startsWith('http') && successAlert > 0 && hostedShown + rec('特-01', ok01 ? 'PASS' : 'FAIL', + `coverage(form)=${coverageBefore} status=${status} invoiceId=${inv?.invoiceId} hostedShownInUI=${hostedShown} idShown=${idShown > 0} hostedUrl=${inv?.hostedInvoiceUrl ? inv.hostedInvoiceUrl.slice(0, 46) + '…' : inv?.hostedInvoiceUrl}`, + 'Stripe amount/metadata は下の特-01(Stripe) 行で確認') + const ok03 = !preexisting && status === 200 && !!inv?.invoiceId + const afterUserId = userIdForEmail(email) + rec('特-03', ok03 ? 'PASS' : 'FAIL', + `preexistingUserRow=${preexisting ? 'YES(' + preexisting + ')' : 'none'} → issue status=${status} invoiceId=${inv?.invoiceId} → userRowNow=${afterUserId ? 'created(' + afterUserId + ')' : 'none'}`, + '事前 user 行無し → get-or-create して発行成功') + await ctx.close() + } + + // Stripe verification for 特-01: amount_due=2000 jpy + metadata. + { + const si = stripeInvoice(inv01?.invoiceId) + const md = si?.metadata || {} + const ok = si && si.amount_due === 2000 && (si.currency === 'jpy') + && md.fee_type === 'continuation' && md.variant === 'special' + && md.coverage === 'zenki' && md.activity_year === '2026' + rec('特-01(Stripe)', ok ? 'PASS' : 'FAIL', + `invoiceId=${inv01?.invoiceId} amount_due=${si?.amount_due} currency=${si?.currency} status=${si?.status} metadata=${JSON.stringify(md)}`, + 'fee_type=continuation+variant=special が特別/¥2000 を示す(special 専用 priceId)。term キーは special には無い(仕様)。coverage=zenki / activity_year=2026') + } + + // =========================================================================== + // 特-09: 同一 fresh email・同 coverage=zenki を 2回 発行(未払い)→ 2回目 同一 invoiceId。 + // =========================================================================== + { + const ctx = await newCtx(browser); const page = await ctx.newPage() + await devLogin(ctx, { as: 'admin' }) + await page.goto(BASE + '/special-invoice', { waitUntil: 'networkidle' }) + const email = freshEmail('sp09') + const r1 = await rpc(page, 'issueSpecialInvoiceByEmail', { email, name: 'Reuse SP', coverage: 'zenki' }) + const r2 = await rpc(page, 'issueSpecialInvoiceByEmail', { email, name: 'Reuse SP', coverage: 'zenki' }) + await shot(page, 'special-09-reuse') + const i1 = r1.ok, i2 = r2.ok + const ok = r1.status === 200 && r2.status === 200 + && !!i1?.invoiceId && i1.invoiceId === i2?.invoiceId + rec('特-09', ok ? 'PASS' : 'FAIL', + `1st status=${r1.status} invoiceId=${i1?.invoiceId} | 2nd status=${r2.status} invoiceId=${i2?.invoiceId} sameInvoiceId=${i1?.invoiceId === i2?.invoiceId}`, + 'open invoice 再利用(dedup 不変条件 = invoiceId)') + await ctx.close() + } + + // =========================================================================== + // 特-02 (部分検証): 同一 fresh email に coverage=zenki と coverage=kouki を別々に発行 + // → 両方 status 200・別 invoiceId(前期/後期スロットが別で両立)。 + // ※「前期 paid 済みの上で後期追加」の完全形は paid state 必要 → BLOCKED 注記。 + // =========================================================================== + { + const ctx = await newCtx(browser); const page = await ctx.newPage() + await devLogin(ctx, { as: 'admin' }) + await page.goto(BASE + '/special-invoice', { waitUntil: 'networkidle' }) + const email = freshEmail('sp02') + const rZ = await rpc(page, 'issueSpecialInvoiceByEmail', { email, name: 'Both SP', coverage: 'zenki' }) + const rK = await rpc(page, 'issueSpecialInvoiceByEmail', { email, name: 'Both SP', coverage: 'kouki' }) + await shot(page, 'special-02-both-halves') + const iZ = rZ.ok, iK = rK.ok + collected.sp02_zenki = iZ?.invoiceId + collected.sp02_kouki = iK?.invoiceId + const distinct = !!iZ?.invoiceId && !!iK?.invoiceId && iZ.invoiceId !== iK.invoiceId + const ok = rZ.status === 200 && rK.status === 200 && distinct + rec('特-02', ok ? 'PASS' : 'FAIL', + `zenki status=${rZ.status} invoiceId=${iZ?.invoiceId} | kouki status=${rK.status} invoiceId=${iK?.invoiceId} distinct=${distinct}`, + '前期/後期スロットが別なので両立(別 invoiceId)。"前期 paid 済みの上で後期追加" の完全形は paid state 必要 ⇒ その部分は BLOCKED') + await ctx.close() + } + + // =========================================================================== + // 特-04: 認可 — ① as:user で /special-invoice を開く → 会計データ出さず「要会計ログイン」 + // ② as:user セッションで POST issueSpecialInvoiceByEmail 直叩き → 認可エラー + // ③ 利用者 UI(/membership) に特別の導線が無いこと。 + // =========================================================================== + { + const ctx = await newCtx(browser); const page = await ctx.newPage() + const email = freshEmail('sp04user') + await devLogin(ctx, { as: 'user', email }) + // ① UI gate. + await page.goto(BASE + '/special-invoice', { waitUntil: 'networkidle' }) + await page.waitForTimeout(500) + const me = (await authMe(page)).body?.json + const hasLoginPrompt = await page.locator('text=会計ログインが必要です').count() + const hasForm = await page.locator('input[type=email]').count() + const hasSubmit = await page.locator('button[type=submit]').count() + await shot(page, 'special-04-user-gate') + // ② direct RPC. + const r = await rpc(page, 'issueSpecialInvoiceByEmail', { email, name: 'Sneaky', coverage: 'zenki' }) + // ③ /membership には特別の導線が無いこと. + await page.goto(BASE + '/membership', { waitUntil: 'networkidle' }) + await page.waitForTimeout(400) + const membershipBody = await page.locator('body').innerText().catch(() => '') + const hasSpecialLink = await page.locator('a[href="/special-invoice"]').count() + const mentionsSpecial = membershipBody.includes('特別') + await shot(page, 'special-04-membership-no-link') + const uiOk = me?.admin === false && hasLoginPrompt > 0 && hasForm === 0 && hasSubmit === 0 + const rpcOk = r.status === 401 || r.status === 403 || r.code === 'FORBIDDEN' || r.code === 'UNAUTHORIZED' + const navOk = hasSpecialLink === 0 && !mentionsSpecial + const ok = uiOk && rpcOk && navOk + rec('特-04', ok ? 'PASS' : 'FAIL', + `[UI] admin=${me?.admin} loginPrompt=${hasLoginPrompt > 0} form=${hasForm} submit=${hasSubmit} | [RPC] status=${r.status} code=${r.code} msg=${r.body?.json?.message} | [nav] /membership specialLink=${hasSpecialLink} mentions特別=${mentionsSpecial}`) + await ctx.close() + } + + // =========================================================================== + // 特-06: admin が 許可ドメイン外メール (foo@example.com) で ByEmail 発行 + // → ドメインガードで拒否(junk person を作らない)。 + // =========================================================================== + { + const ctx = await newCtx(browser); const page = await ctx.newPage() + await devLogin(ctx, { as: 'admin' }) + await page.goto(BASE + '/special-invoice', { waitUntil: 'networkidle' }) + const badEmail = `e2e-sp06-${process.pid}@example.com` + const r = await rpc(page, 'issueSpecialInvoiceByEmail', { email: badEmail, name: 'Junk', coverage: 'zenki' }) + // Confirm no junk user row was created. + const junkRow = userIdForEmail(badEmail) + await shot(page, 'special-06-domain-guard') + const ok = (r.status === 400 || r.code === 'BAD_REQUEST') && !junkRow + rec('特-06', ok ? 'PASS' : 'FAIL', + `status=${r.status} code=${r.code} msg=${r.body?.json?.message} junkUserRowCreated=${junkRow ? 'YES(' + junkRow + ')' : 'no'}`, + 'ドメインガードで拒否・person 行は作られない') + await ctx.close() + } + + // =========================================================================== + // 特-07: admin が activityYear=2027 を指定して発行。 + // 観測: (A) UI フォーム経由 — 活動年度に 2027 を入力して発行ボタン → リクエストが + // 1件も飛ばず「発行に失敗しました」を表示(client-side bug)。 + // (B) API 直叩き(envelope)— activityYear:2027 を渡すと status 200 で発行成功、 + // Stripe metadata.activity_year=2027 を確認(サーバは正しい)。 + // → UI 経由は FAIL(活動年度を入れると必ず失敗)、API 直叩きは期待どおり。 + // =========================================================================== + let inv07 = null + { + // (A) Realistic UI form path: type a year, submit, observe what happens. + const ctx = await newCtx(browser); const page = await ctx.newPage() + await devLogin(ctx, { as: 'admin' }) + await page.goto(BASE + '/special-invoice', { waitUntil: 'networkidle' }) + await page.waitForTimeout(400) + const uiEmail = freshEmail('sp07ui') + const uiReqs = [] + page.on('request', r => { if (r.url().includes('issueSpecialInvoiceByEmail')) uiReqs.push(r.postData()) }) + await page.locator('input[type=email]').first().fill(uiEmail) + const numInput = page.locator('input[type=number]').first() + await numInput.click(); await numInput.type('2027', { delay: 20 }) + await shot(page, 'special-07-form-2027') + await page.locator('button[type=submit]').first().click().catch(() => {}) + await page.waitForTimeout(2500) + const uiErr = await page.locator('text=発行に失敗').count() + const uiOk = await page.locator('text=特別請求書を発行しました').count() + await shot(page, 'special-07-ui-after') + const uiRequestFired = uiReqs.length > 0 + + // (B) API path (envelope): same activityYear=2027 must succeed + carry metadata. + const apiEmail = freshEmail('sp07api') + const r = await rpc(page, 'issueSpecialInvoiceByEmail', { email: apiEmail, name: 'Year2027', coverage: 'zenki', activityYear: 2027 }) + inv07 = { email: apiEmail, invoiceId: r.ok?.invoiceId } + collected.sp07 = r.ok?.invoiceId + const si = stripeInvoice(r.ok?.invoiceId) + const md = si?.metadata || {} + const apiOk = r.status === 200 && !!r.ok?.invoiceId && md.activity_year === '2027' + && md.variant === 'special' && si?.amount_due === 2000 + // Overall verdict reflects the USER-FACING UI behavior (the assigned scope): + // the UI path fails, so 特-07 is FAIL at the UI level even though the API is correct. + const verdict = (!uiRequestFired && uiErr > 0 && apiOk) ? 'FAIL' : (uiOk > 0 && apiOk ? 'PASS' : 'FAIL') + rec('特-07', verdict, + `[UI] typed活動年度=2027 → requestFired=${uiRequestFired} errAlert=${uiErr > 0} successAlert=${uiOk > 0} | [API] status=${r.status} invoiceId=${r.ok?.invoiceId} amount_due=${si?.amount_due} metadata=${JSON.stringify(md)}`, + 'UI で活動年度を入力すると RPC が一切送られず「発行に失敗しました」。原因: UInput type=number の v-model が form.activityYear を数値にし、onSubmit の form.activityYear.trim() が TypeError → catch されて汎用エラー表示・送信されない(apps/web/app/pages/special-invoice.vue:61,239)。API 直叩きでは activity_year=2027 で正常発行(サーバは正しい)') + await ctx.close() + } + + // =========================================================================== + // 特-05: userId 指定でメール不一致 → 拒否(API 試行)。 + // fresh user を dev login で作り userId を取得(Customer 未作成)、admin で + // 別 isct メールを指定 → mail_hash 不一致 → BAD_REQUEST 'email does not match'. + // =========================================================================== + { + // 1) create a fresh user row (no Stripe Customer yet) via dev login. + const targetEmail = freshEmail('sp05target') + const otherEmail = freshEmail('sp05other') + const uctx = await newCtx(browser) + await devLogin(uctx, { as: 'user', email: targetEmail }) + await uctx.close() + const userId = userIdForEmail(targetEmail) + // 2) admin calls issueSpecialInvoice({userId, email: a DIFFERENT isct email}). + const ctx = await newCtx(browser); const page = await ctx.newPage() + await devLogin(ctx, { as: 'admin' }) + await page.goto(BASE + '/special-invoice', { waitUntil: 'networkidle' }) + let r = null + if (userId) { + r = await rpc(page, 'issueSpecialInvoice', { userId, email: otherEmail, name: 'Mismatch', coverage: 'zenki' }) + } + await shot(page, 'special-05-userid-mismatch') + const ok = !!userId && (r?.status === 400 || r?.code === 'BAD_REQUEST') + && /does not match/i.test(r?.body?.json?.message || '') + rec('特-05', ok ? 'PASS' : (userId ? 'FAIL' : 'BLOCKED'), + userId + ? `userId=${userId} suppliedEmail= status=${r?.status} code=${r?.code} msg=${r?.body?.json?.message}` + : 'could not resolve a fresh userId from MySQL (mail_hash lookup failed)', + 'userId の person と指定 email の mail_hash 不一致 → 別の email/Customer に誤リンクを防ぐため拒否') + await ctx.close() + } + + // =========================================================================== + // 特-08 / 特-11: BLOCKED — 実支払い + invoice.paid webhook で paid 化が必要。 + // =========================================================================== + rec('特-08', 'BLOCKED', + '同半期 paid → 再発行拒否を観測するには、対象 invoice を実 Stripe 決済 + invoice.paid webhook で paid 化する必要がある(UI/open では paid state を作れない)', + '実支払い + webhook があれば検証可能') + rec('特-11', 'BLOCKED', + 'special paid → 該当半期スロット paid 化 + 通知 を観測するには、実 Stripe 決済 + invoice.paid webhook が必要', + '実支払い + webhook があれば検証可能') + + log('\n\n===== INVOICE IDS FOR STRIPE CHECK (JSON) =====') + log(JSON.stringify(collected)) + + log('\n\n===== SUMMARY =====') + log('| ID | 判定 | 観測 |') + for (const r of results) log(`| ${r.id} | ${r.verdict} | ${r.observed}${r.note ? ' // ' + r.note : ''} |`) +} catch (e) { + console.error('RUN FAIL', e.stack) +} finally { + await browser.close() +} From 590f5801f7fb698e46abaf30d7bfebf0bb82b5a6 Mon Sep 17 00:00:00 2001 From: kaitoyama Date: Wed, 24 Jun 2026 20:36:40 +0900 Subject: [PATCH 18/30] =?UTF-8?q?feat:=20forward-auth=20mode=20(NeoShowcas?= =?UTF-8?q?e=20Soft)=20=E2=80=94=20trust=20X-Forwarded-User?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit To deploy behind NeoShowcase "Soft" member-auth without wiring our own traQ OAuth, trust the reverse proxy's `X-Forwarded-User` as the traQ identity when `CHECKIN_TRUST_FORWARD_AUTH=1`. - session: `applyForwardedIdentity` (pure) merges the forwarded traQ identity (and accountant flag via the allow-list) onto the cookie session; the isct user identity (userId/mailHash) still rides on the email-verification cookie. - buildRequestContext: when enabled, read X-Forwarded-User (fallback X-Showcase-User) and apply it over the resolved cookie session. - /login: when enabled, redirect to the platform's `/_oauth/login?redirect=...` instead of our traQ OAuth, so unauthenticated users get prompted to log in. - config/runtimeConfig: `trustForwardAuth` (env CHECKIN_TRUST_FORWARD_AUTH). Only trust the header where the app is reachable solely via the trusted proxy. Default (flag off) keeps the existing traQ OAuth behavior. 4 unit tests added. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01ACKUEYT79nkgEKUjBWjnZM --- apps/web/nuxt.config.ts | 3 +++ apps/web/server/routes/login.get.ts | 7 +++++++ apps/web/server/utils/auth-config.ts | 1 + apps/web/server/utils/auth.ts | 21 ++++++++++++++++++++- packages/api/src/auth/auth.test.ts | 27 ++++++++++++++++++++++++++- packages/api/src/auth/config.ts | 8 ++++++++ packages/api/src/auth/session.ts | 26 ++++++++++++++++++++++++++ 7 files changed, 91 insertions(+), 2 deletions(-) diff --git a/apps/web/nuxt.config.ts b/apps/web/nuxt.config.ts index 681821a..e6b5e71 100644 --- a/apps/web/nuxt.config.ts +++ b/apps/web/nuxt.config.ts @@ -14,6 +14,9 @@ export default defineNuxtConfig({ sessionTtlSec: process.env.SESSION_TTL ?? '2592000', // Accountant (admin) allow-list accountantTraqIds: process.env.CHECKIN_ACCOUNTANT_TRAQ_IDS ?? '', + // NeoShowcase "Soft" member-auth: trust the proxy's X-Forwarded-User header + // as the traQ identity ('1' to enable). Only safe behind the trusted proxy. + trustForwardAuth: process.env.CHECKIN_TRUST_FORWARD_AUTH ?? '', // traQ OAuth (client registered out-of-band; URLs/scope come from env) traqClientId: process.env.TRAQ_OAUTH_CLIENT_ID ?? '', traqClientSecret: process.env.TRAQ_OAUTH_CLIENT_SECRET ?? '', diff --git a/apps/web/server/routes/login.get.ts b/apps/web/server/routes/login.get.ts index e317193..7c3d364 100644 --- a/apps/web/server/routes/login.get.ts +++ b/apps/web/server/routes/login.get.ts @@ -6,6 +6,13 @@ export default defineEventHandler((event) => { const query = getQuery(event) const redirect = sanitizeRedirect(typeof query.redirect === 'string' ? query.redirect : undefined) + // NeoShowcase "Soft" member-auth: prompt login via the platform's forward-auth + // endpoint instead of our own traQ OAuth. After it authenticates, the proxy + // adds X-Forwarded-User and buildRequestContext derives the traQ identity. + if (config.trustForwardAuth) { + return sendRedirect(event, `/_oauth/login?redirect=${encodeURIComponent(redirect)}`) + } + const state = generateToken(16) const { verifier, challenge } = generatePkce() setOAuthCookies(event, { state, verifier, redirect }) diff --git a/apps/web/server/utils/auth-config.ts b/apps/web/server/utils/auth-config.ts index cc221cc..14933ed 100644 --- a/apps/web/server/utils/auth-config.ts +++ b/apps/web/server/utils/auth-config.ts @@ -32,6 +32,7 @@ export function resolveAuthConfig(): AuthConfig { emailVerificationTtlSec: num(rc.emailVerificationTtlSec, 1800), sessionTtlSec: num(rc.sessionTtlSec, 2592000), accountantTraqIds: csv(rc.accountantTraqIds), + trustForwardAuth: String(rc.trustForwardAuth ?? '') === '1', traq: { clientId: String(rc.traqClientId ?? ''), clientSecret: String(rc.traqClientSecret ?? ''), diff --git a/apps/web/server/utils/auth.ts b/apps/web/server/utils/auth.ts index 606013e..d946278 100644 --- a/apps/web/server/utils/auth.ts +++ b/apps/web/server/utils/auth.ts @@ -1,5 +1,6 @@ import type { H3Event } from 'h3' import { + applyForwardedIdentity, type Context, createAuthHelpers, createJomonClient, @@ -66,6 +67,17 @@ export function clearOAuthCookies(event: H3Event): void { deleteCookie(event, OAUTH_REDIRECT_COOKIE, opts) } +/** + * The traQ ID asserted by the trusted reverse proxy (NeoShowcase "Soft" + * member-auth). `X-Forwarded-User` is the current header; `X-Showcase-User` is + * kept for compatibility. Returns null when absent (not Soft-authenticated). + */ +export function forwardedTraqId(event: H3Event): string | null { + const raw = getHeader(event, 'x-forwarded-user') || getHeader(event, 'x-showcase-user') + const id = raw?.trim() + return id ? id : null +} + /** Compute double-submit CSRF validity from the cookie and `x-csrf-token` header. */ export function isCsrfValid(event: H3Event): boolean { const cookie = getCookie(event, CSRF_COOKIE) @@ -92,7 +104,14 @@ export async function buildRequestContext(event: H3Event): Promise { // Lazy: the `stub` driver is key-free; live drivers only validate creds when a // payout procedure actually reaches out to Jomon. const jomon = createJomonClient(jomonConfig) - const session = await resolveSession(db, getCookie(event, SESSION_COOKIE)) + // Restore the cookie session (isct user identity), then — under NeoShowcase + // "Soft" member-auth — let the trusted proxy's X-Forwarded-User assert the traQ + // identity (and accountant flag). The isct user (userId/mailHash) still rides + // on the cookie session. (deploy: NeoShowcase Soft auth) + const cookieSession = await resolveSession(db, getCookie(event, SESSION_COOKIE)) + const session = config.trustForwardAuth + ? applyForwardedIdentity(cookieSession, forwardedTraqId(event), config.accountantTraqIds) + : cookieSession const helpers = createAuthHelpers(session, isCsrfValid(event)) return { db, diff --git a/packages/api/src/auth/auth.test.ts b/packages/api/src/auth/auth.test.ts index 64f6de1..3ef853f 100644 --- a/packages/api/src/auth/auth.test.ts +++ b/packages/api/src/auth/auth.test.ts @@ -3,7 +3,7 @@ import { normalizeEmail, deriveMailHash, safeEqual } from './crypto' import { sanitizeRedirect } from './redirect' import { isAllowedEmailDomain, isAccountant } from './config' import { createAuthHelpers } from './context' -import type { SessionIdentity } from './session' +import { applyForwardedIdentity, type SessionIdentity } from './session' const SECRET = 'test-secret' @@ -108,3 +108,28 @@ describe('context: auth helpers (dual identity)', () => { expect(() => createAuthHelpers(user, true).assertCsrf()).not.toThrow() }) }) + +describe('session: applyForwardedIdentity (NeoShowcase Soft auth)', () => { + const cookieUser: SessionIdentity = { traqId: null, isAdmin: false, userId: 'u1', mailHash: 'h1' } + + it('no forwarded user → returns the cookie session unchanged', () => { + expect(applyForwardedIdentity(cookieUser, null, ['alice'])).toBe(cookieUser) + expect(applyForwardedIdentity(null, null, ['alice'])).toBeNull() + }) + + it('forwarded user becomes the traQ identity, keeping the cookie isct user', () => { + const s = applyForwardedIdentity(cookieUser, 'bob', ['alice']) + expect(s).toEqual({ traqId: 'bob', isAdmin: false, userId: 'u1', mailHash: 'h1' }) + }) + + it('forwarded user in the allow-list is an accountant', () => { + const s = applyForwardedIdentity(cookieUser, 'alice', ['alice']) + expect(s).toEqual({ traqId: 'alice', isAdmin: true, userId: 'u1', mailHash: 'h1' }) + }) + + it('forwarded user with no cookie session → traQ-only identity', () => { + expect(applyForwardedIdentity(null, 'alice', ['alice'])).toEqual({ + traqId: 'alice', isAdmin: true, userId: null, mailHash: null, + }) + }) +}) diff --git a/packages/api/src/auth/config.ts b/packages/api/src/auth/config.ts index cbfe039..4896e63 100644 --- a/packages/api/src/auth/config.ts +++ b/packages/api/src/auth/config.ts @@ -31,6 +31,14 @@ export interface AuthConfig { sessionTtlSec: number /** traQ IDs (usernames) granted accountant (admin) access. */ accountantTraqIds: string[] + /** + * Trust the reverse proxy's `X-Forwarded-User` header as the authenticated + * traQ identity (NeoShowcase "Soft" member-auth). When true, the traQ identity + * comes from the proxy rather than our own traQ OAuth. Enable ONLY where the + * app is reachable solely via the trusted proxy, which overwrites/strips any + * client-supplied value — otherwise the header is spoofable. + */ + trustForwardAuth: boolean traq: TraqOAuthConfig mailer: MailerConfig } diff --git a/packages/api/src/auth/session.ts b/packages/api/src/auth/session.ts index 212dbce..b6721a6 100644 --- a/packages/api/src/auth/session.ts +++ b/packages/api/src/auth/session.ts @@ -97,6 +97,32 @@ export async function resolveSession(db: Database, token: string | undefined): P } } +/** + * Merge a reverse-proxy-forwarded traQ identity (NeoShowcase "Soft" member-auth's + * `X-Forwarded-User`) into the cookie session. When `forwardedTraqId` is set it + * becomes authoritative for the traQ identity — including the accountant flag via + * the allow-list — while the isct user identity (`userId`/`mailHash`) still comes + * from the email-verification cookie session. With no forwarded user the cookie + * session is returned unchanged (anonymous, or an email-only user). The caller + * must only invoke this when the proxy is trusted ({@link AuthConfig.trustForwardAuth}). + * Pure — no I/O. (session spec: §デュアル・アイデンティティ) + */ +export function applyForwardedIdentity( + cookieSession: SessionIdentity | null, + forwardedTraqId: string | null, + accountantTraqIds: string[], +): SessionIdentity | null { + if (!forwardedTraqId) { + return cookieSession + } + return { + traqId: forwardedTraqId, + isAdmin: accountantTraqIds.includes(forwardedTraqId), + userId: cookieSession?.userId ?? null, + mailHash: cookieSession?.mailHash ?? null, + } +} + /** * Attach an isct user to an EXISTING session (keeping the same token/cookie). * Used when an isct email confirmation happens on a live traQ member session: From 5163cc96b9e2f8d37ea1982e858875dc01a998ce Mon Sep 17 00:00:00 2001 From: kaitoyama Date: Wed, 24 Jun 2026 20:36:40 +0900 Subject: [PATCH 19/30] docs: NeoShowcase dev deploy guide for Checkin + Jomon (Soft forward-auth) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step-by-step for deploying both as 1 Runtime app each, using NeoShowcase Soft member-auth (X-Forwarded-User) instead of self-auth, built-in MariaDB, no Swift (Jomon falls back to local storage), no Dockerfile for Checkin (Command/ Buildpack), and Checkin→Jomon Bearer for service calls. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01ACKUEYT79nkgEKUjBWjnZM --- DEPLOY-NEOSHOWCASE.md | 138 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 DEPLOY-NEOSHOWCASE.md diff --git a/DEPLOY-NEOSHOWCASE.md b/DEPLOY-NEOSHOWCASE.md new file mode 100644 index 0000000..02f05d3 --- /dev/null +++ b/DEPLOY-NEOSHOWCASE.md @@ -0,0 +1,138 @@ +# NeoShowcase へ Jomon + Checkin を dev デプロイする手順 + +> traP の PaaS「NeoShowcase」(https://ns.trap.jp) に **Checkin** と **Jomon(フォーク `kaitoyama/Jomon`)** を +> それぞれ 1 Runtime アプリとしてデプロイし、Checkin→Jomon の払い戻し連携を dev で動かす手順。 +> 構成: 各アプリ=1 コンテナ(Checkin=Nuxt SSR が UI+API+webhook、Jomon=Go が UI(client/dist)+`/api` を配信)。 + +## 方針サマリ(実装済み) + +- **認証は NeoShowcase の Soft member-auth に寄せる**(自前 traQ OAuth は使わない)。proxy が付ける `X-Forwarded-User` を**両アプリが信頼**して traQ identity にする。**実装済み**(Checkin: `CHECKIN_TRUST_FORWARD_AUTH=1`、Jomon: `TRUST_FORWARD_AUTH=1`)。 +- **Swift は無い** → Jomon は **production ビルド(`!debug`, Bearer 有効)**で動かし、**Swift env が無ければ LocalStorage にフォールバック**(実装済み)。画像は揮発するが払い戻しフローに画像は不要。 +- **Docker(Dockerfile 記述)を避ける** → Checkin は **Runtime Command / Buildpack**。Jomon は client もビルドするため**既存 root `Dockerfile` を流用**(新規記述ではない)。 +- **dev のメールリンクは traQ bot で投げ込む** → Checkin `MAILER_DRIVER=log`(リンクをログ出力→bot が拾って traQ へ)。 + +## 認証の仕組み(Soft forward-auth) + +member auth は **両アプリ Soft**(Off でも Hard でもなく Soft)。理由: +- **Soft は非強制** → traQ 認証していない Stripe webhook と Checkin→Jomon の Bearer 呼び出しは**そのまま通る**(各アプリが署名/Bearer で自前認証)。Hard だと proxy がこれらを弾く。 +- **Soft は認証済みブラウザに `X-Forwarded-User` を付与** → アプリがそれを traQ identity として採用。 + +| 経路 | 認証 | +|---|---| +| ブラウザ利用者(会計/会員) | **Soft forward-auth**: `/_oauth/login` 後に proxy が `X-Forwarded-User` を付与。Checkin は会計許可リスト照合、Jomon は admin リスト照合 | +| 集金の利用者(isct 学生) | **isct メール magic link**(traQ とは別軸の本人キー `mail_hash`)。Soft 非強制なので非部員でもアクセス可 | +| Stripe webhook | **署名検証**(traQ 不要、Soft で素通り) | +| Checkin → Jomon | **Bearer サービストークン**(traQ 不要、Soft で素通り。ヘッダが付かないので forward-auth ではなく Bearer 経路) | + +> 各アプリは `X-Forwarded-User` を**`TRUST_FORWARD_AUTH=1`/`CHECKIN_TRUST_FORWARD_AUTH=1` のときだけ信頼**する(proxy 経由のみ到達する前提でのスプーフ対策)。 + +--- + +## 0. 共通の前提 + +- 各アプリは NeoShowcase の **ビルトイン MariaDB**(「Use MariaDB」)→ `NS_MARIADB_USER/PASSWORD/HOSTNAME/PORT/DATABASE` が env 発行。 +- **member auth は両方 Soft**。 +- **ファイルシステムは再起動で初期化**。永続データは MariaDB。Jomon 画像(LocalStorage)は揮発前提。 +- **Runtime 上限: 1 CPU / 180MiB**。Go は余裕、Nuxt Node は低負荷なら可(OOM 注意、自動シャットダウン Blocking 推奨)。 +- アクセス URL は `*.trap.show` / `*.trap.games`。証明書は Let's Encrypt 制限(50/week)ありドメイン慎重に。 + +--- + +## 1. Jomon のデプロイ(フォーク `kaitoyama/Jomon`) + +### 1-1. リポジトリ登録 +- `kaitoyama/Jomon` は**個人 GitHub リポ**(traP-jp org でない)→「Add New App」で: + - public なら「**認証を使用しない**」+`https://github.com/kaitoyama/Jomon.git`/private なら「**SSH公開鍵認証**」+表示鍵を Deploy keys に登録 + - 即時ビルドしたいなら GitHub Webhook に `https://ns.trap.jp/api/webhook/github`(無ければ定期ポーリング) +- **ブランチ/ref = `local/checkin-dev-env`**(forward-auth+Bearer+Swift フォールバックが載ったブランチ) + +### 1-2. ビルド設定 +- **Runtime → Dockerfile**、Dockerfile Name = `Dockerfile`(root。`!debug`=forward-auth/Bearer 有効、server+client/dist を 1 コンテナ配信)、Context = `.` + +### 1-3. アクセスURL +- 例 `https://jomon-dev.trap.show`、**HTTP Port = `1323`**(`main.go` 固定)、Path Prefix = `/`、**member auth = Soft** + +### 1-4. 環境変数 +| env | 値 | 備考 | +|---|---|---| +| `MARIADB_USERNAME/PASSWORD/HOSTNAME/DATABASE` | NS_MARIADB_* の値 | Jomon は `MARIADB_*` を読む(`model/db.go`)。NS の値をコピー、または ENTRYPOINT で `NS_MARIADB_*`→`MARIADB_*` に変換 | +| **`TRUST_FORWARD_AUTH`** | `1` | **Soft の `X-Forwarded-User` を信頼**(forward-auth 有効化) | +| **`SERVICE_TOKEN`** | 長い乱数 | **Checkin の `JOMON_API_TOKEN` と同値**。Bearer 共有秘密 | +| **`SERVICE_USER_TRAP_ID`** | 例 `checkin` | **Jomon に admin 登録した trap_id**。書き戻しが `IsAdmin` 要求+実行者として記録 | +| `WEBHOOK_SECRET`/`WEBHOOK_CHANNEL_ID`/`WEBHOOK_ID` | traQ 通知用 | dev は空で可 | +| ~~`TRAQ_CLIENT_ID`~~ | — | **不要**(UI 認証は forward-auth、サービスは Bearer。自前 OAuth を使わない) | +| ~~`OS_*`(Swift)~~ | — | **不要**(未設定なら LocalStorage にフォールバック。`UPLOAD_DIR` 任意) | + +- DB は **起動時 auto-migrate**(`model.Migrate()`)=マイグレーション手順不要。 +- **`SERVICE_USER_TRAP_ID` を Jomon admin に登録**(admin 管理 or `administrators` テーブル INSERT)。未登録だと書き戻しが 403。Jomon UI を使う人間の会計も admin にするなら同様。 + +--- + +## 2. Checkin のデプロイ(`traPtitech/Checkin`) + +### 2-1. リポジトリ登録 +- `traPtitech/Checkin` は traP-jp org → **自動同期**(何もしない)、push 即ビルド。 +- **ブランチ/ref = `claude/checkin-auth-collection`** + +### 2-2. ビルド設定(Dockerfile 不要) +**A. Runtime Command(推奨)** +- Base Image: `node:22-alpine` +- Build Command: `corepack enable && pnpm install --frozen-lockfile && pnpm build` +- Entrypoint(`NS_MARIADB_*`→`DATABASE_URL` 生成→migrate→起動): + ```sh + sh -lc 'export DATABASE_URL="mysql://${NS_MARIADB_USER}:${NS_MARIADB_PASSWORD}@${NS_MARIADB_HOSTNAME}:${NS_MARIADB_PORT}/${NS_MARIADB_DATABASE}"; pnpm db:migrate && node apps/web/.output/server/index.mjs' + ``` +- 単一イメージなので **drizzle-kit(devDep) が runtime に残り migrate が通る**(Command 推奨の理由)。 + +**B. Runtime Buildpack**(Base Image も不要) +- Context `.`。ただし Paketo は devDep を prune するので `pnpm db:migrate` が落ちうる → root `package.json` に `"start"` を足し **migration は初回 SSH で実行**、または `drizzle-kit` を `dependencies` に移す。 + +> Nuxt は `PORT`/`NITRO_PORT` を尊重(既定 3000)。NeoShowcase の HTTP Port をそれに合わせる。 + +### 2-3. アクセスURL +- 例 `https://checkin-dev.trap.show`、HTTP Port = entrypoint の PORT(既定 3000)、Path Prefix = `/`、**member auth = Soft** + +### 2-4. 環境変数 +| env | 値 | 備考 | +|---|---|---| +| `DATABASE_URL` | (entrypoint で生成) | ビルトイン MariaDB | +| **`CHECKIN_TRUST_FORWARD_AUTH`** | `1` | **Soft の `X-Forwarded-User` を traQ identity に**(forward-auth 有効化) | +| `MAIL_HASH_SECRET` | 不変の長い乱数 | 本人キー導出。運用中変えない | +| `APP_ORIGIN` | `https://checkin-dev.trap.show` | 自サイト URL(リンク/Account Link 戻り先) | +| `CHECKIN_ACCOUNTANT_TRAQ_IDS` | 会計の traQ ID(カンマ区切り) | `X-Forwarded-User` と照合して admin 判定 | +| `MAILER_DRIVER` | `log` | リンクをログ出力→bot が traQ へ中継(dev) | +| `STRIPE_SECRET_KEY`/`STRIPE_WEBHOOK_SECRET`/`STRIPE_CONNECT_WEBHOOK_SECRET`/`PRICE_*`(4種) | Stripe test キー | 集金・払い戻し | +| `JOMON_API_BASE_URL` | `https://jomon-dev.trap.show` | Jomon の URL | +| `JOMON_API_VERSION` | `v1` | | +| `JOMON_API_TOKEN` | **Jomon の `SERVICE_TOKEN` と同値** | Bearer サービストークン | +| ~~`TRAQ_OAUTH_*`~~ | — | **不要**(`/login` は `/_oauth/login` に転送、自前 OAuth を使わない) | + +> `CHECKIN_DEV_LOGIN` は設定しない(本番ビルドで 404 だが念のため)。 + +--- + +## 3. 相互配線とデプロイ後の確認 + +1. `SERVICE_TOKEN`(Jomon) == `JOMON_API_TOKEN`(Checkin) を同じ乱数に。 +2. `JOMON_API_BASE_URL`(Checkin) = Jomon のホスト。 +3. `SERVICE_USER_TRAP_ID` を Jomon admin に登録。 +4. 両アプリ member auth = **Soft**、`*_TRUST_FORWARD_AUTH=1`。 +5. Stripe Dashboard の webhook 宛先: + - `https://checkin-dev.trap.show/webhook/invoice-paid`(→ `STRIPE_WEBHOOK_SECRET`) + - `https://checkin-dev.trap.show/webhook/account-updated`(→ `STRIPE_CONNECT_WEBHOOK_SECRET`) +6. 疎通確認: + - Checkin `/` →(未ログインなら)`/login`→`/_oauth/login` で Soft 認証→会計なら `/payments`・`/payouts` が見える。 + - `/membership` 発行→test card 支払い→`invoice.paid` 通知。 + - 会計 `/payouts`「Jomon 取込」→ Jomon が **Bearer を検証**して accepted 申請を返す→取込/onboarding/送金。 + - Jomon UI は `/_oauth/login` 後に `X-Forwarded-User` で認証される。 + +--- + +## 4. 注意・落とし穴 + +- **forward-auth は `*_TRUST_FORWARD_AUTH=1` のときだけ有効**。proxy 経由のみ到達する NeoShowcase 前提(直アクセス可能な環境で有効化しない)。 +- **`NS_MARIADB_*` → アプリ期待名へのマッピング**: Checkin は entrypoint で `DATABASE_URL` 生成。Jomon は `MARIADB_*` を読むので値コピー or ENTRYPOINT 変換。 +- **member auth は Soft**(Off だとブラウザの traQ identity が取れない/Hard だと webhook・Bearer が弾かれる)。 +- **再起動でディスク揮発**: Jomon 画像(LocalStorage)は消える。払い戻しに無関係。 +- **Runtime 180MiB**: Nuxt Node OOM 時は自動シャットダウン(Blocking)+低負荷で対処。 +- **実装状況**: forward-auth/Swift フォールバック(Jomon `local/checkin-dev-env`)と forward-auth(Checkin `claude/checkin-auth-collection`)は実装・コミット済み。Checkin は Dockerfile 不要(Command/Buildpack)。 From 8ef36f74c6d8b991cc4a5f77867a2b7beb02bbfa Mon Sep 17 00:00:00 2001 From: kaitoyama Date: Wed, 24 Jun 2026 21:00:29 +0900 Subject: [PATCH 20/30] =?UTF-8?q?docs:=20NeoShowcase=20deploy=20=E2=80=94?= =?UTF-8?q?=20NUXT=5F=20env,=20admin=20seed=20SQL,=20troubleshooting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Checkin env: switch to NUXT_-prefixed names (runtimeConfig is baked at build; NUXT_* overrides at runtime) and export NUXT_DATABASE_URL in the entrypoint. - Jomon: concrete `administrators` seed SQL (fresh prod DB has no admin → 403). - Troubleshooting: the two real startup panics (MARIADB_* unset → DB connection refused; local-storage dir missing → fixed in Jomon b41808e, rebuild needed). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01ACKUEYT79nkgEKUjBWjnZM --- DEPLOY-NEOSHOWCASE.md | 55 ++++++++++++++++++++++++++++++++----------- 1 file changed, 41 insertions(+), 14 deletions(-) diff --git a/DEPLOY-NEOSHOWCASE.md b/DEPLOY-NEOSHOWCASE.md index 02f05d3..30aa1b9 100644 --- a/DEPLOY-NEOSHOWCASE.md +++ b/DEPLOY-NEOSHOWCASE.md @@ -64,7 +64,12 @@ member auth は **両アプリ Soft**(Off でも Hard でもなく Soft)。 | ~~`OS_*`(Swift)~~ | — | **不要**(未設定なら LocalStorage にフォールバック。`UPLOAD_DIR` 任意) | - DB は **起動時 auto-migrate**(`model.Migrate()`)=マイグレーション手順不要。 -- **`SERVICE_USER_TRAP_ID` を Jomon admin に登録**(admin 管理 or `administrators` テーブル INSERT)。未登録だと書き戻しが 403。Jomon UI を使う人間の会計も admin にするなら同様。 +- **起動後に1回だけ admin をシード**。fresh な production DB は admin が空で、書き戻しが 403 になる。Adminer(https://adminer.ns.trap.jp)か SSH で: + ```sql + INSERT INTO administrators (trap_id) VALUES ('checkin'), ('<あなたのtraQ_ID>'); + ``` + - `checkin` = `SERVICE_USER_TRAP_ID`(Checkin の書き戻し用)。`<あなた>` = Jomon UI を admin で使う人。 + - スキーマは初回起動の auto-migrate で作られるので、**起動後**に INSERT。 --- @@ -80,8 +85,9 @@ member auth は **両アプリ Soft**(Off でも Hard でもなく Soft)。 - Build Command: `corepack enable && pnpm install --frozen-lockfile && pnpm build` - Entrypoint(`NS_MARIADB_*`→`DATABASE_URL` 生成→migrate→起動): ```sh - sh -lc 'export DATABASE_URL="mysql://${NS_MARIADB_USER}:${NS_MARIADB_PASSWORD}@${NS_MARIADB_HOSTNAME}:${NS_MARIADB_PORT}/${NS_MARIADB_DATABASE}"; pnpm db:migrate && node apps/web/.output/server/index.mjs' + sh -lc 'export DATABASE_URL="mysql://${NS_MARIADB_USER}:${NS_MARIADB_PASSWORD}@${NS_MARIADB_HOSTNAME}:${NS_MARIADB_PORT}/${NS_MARIADB_DATABASE}"; export NUXT_DATABASE_URL="$DATABASE_URL"; pnpm db:migrate && node apps/web/.output/server/index.mjs' ``` +- `pnpm db:migrate`(drizzle-kit) は `DATABASE_URL` を、Nuxt サーバは runtimeConfig 上書きの **`NUXT_DATABASE_URL`** を読むので**両方 export**する。 - 単一イメージなので **drizzle-kit(devDep) が runtime に残り migrate が通る**(Command 推奨の理由)。 **B. Runtime Buildpack**(Base Image も不要) @@ -92,20 +98,25 @@ member auth は **両アプリ Soft**(Off でも Hard でもなく Soft)。 ### 2-3. アクセスURL - 例 `https://checkin-dev.trap.show`、HTTP Port = entrypoint の PORT(既定 3000)、Path Prefix = `/`、**member auth = Soft** -### 2-4. 環境変数 +### 2-4. 環境変数(**すべて `NUXT_` 接頭辞**) + +Checkin は値を Nuxt runtimeConfig から読む。runtimeConfig は**ビルド時に焼かれる**ので、ランタイムで確実に効かせるには **`NUXT_` 接頭辞**で渡す(無印だとビルド時の空値が焼かれて効かない)。`DATABASE_URL`/`NUXT_DATABASE_URL` は entrypoint が生成。 + | env | 値 | 備考 | |---|---|---| -| `DATABASE_URL` | (entrypoint で生成) | ビルトイン MariaDB | -| **`CHECKIN_TRUST_FORWARD_AUTH`** | `1` | **Soft の `X-Forwarded-User` を traQ identity に**(forward-auth 有効化) | -| `MAIL_HASH_SECRET` | 不変の長い乱数 | 本人キー導出。運用中変えない | -| `APP_ORIGIN` | `https://checkin-dev.trap.show` | 自サイト URL(リンク/Account Link 戻り先) | -| `CHECKIN_ACCOUNTANT_TRAQ_IDS` | 会計の traQ ID(カンマ区切り) | `X-Forwarded-User` と照合して admin 判定 | -| `MAILER_DRIVER` | `log` | リンクをログ出力→bot が traQ へ中継(dev) | -| `STRIPE_SECRET_KEY`/`STRIPE_WEBHOOK_SECRET`/`STRIPE_CONNECT_WEBHOOK_SECRET`/`PRICE_*`(4種) | Stripe test キー | 集金・払い戻し | -| `JOMON_API_BASE_URL` | `https://jomon-dev.trap.show` | Jomon の URL | -| `JOMON_API_VERSION` | `v1` | | -| `JOMON_API_TOKEN` | **Jomon の `SERVICE_TOKEN` と同値** | Bearer サービストークン | -| ~~`TRAQ_OAUTH_*`~~ | — | **不要**(`/login` は `/_oauth/login` に転送、自前 OAuth を使わない) | +| **`NUXT_TRUST_FORWARD_AUTH`** | `1` | **Soft の `X-Forwarded-User` を traQ identity に**(forward-auth 有効化) | +| `NUXT_MAIL_HASH_SECRET` | 不変の長い乱数(`openssl rand -hex 32`) | 本人キー導出。運用中変えない | +| `NUXT_APP_ORIGIN` | `https://checkin-dev.trap.show` | 自サイト URL(リンク/Account Link 戻り先) | +| `NUXT_ACCOUNTANT_TRAQ_IDS` | 会計の traQ ID(カンマ区切り) | `X-Forwarded-User` と照合して admin 判定 | +| `NUXT_MAILER_DRIVER` | `log` | リンクをログ出力→bot が traQ へ中継(dev) | +| `NUXT_JOMON_API_BASE_URL` | `https://jomon-dev.trap.show` | Jomon の URL | +| `NUXT_JOMON_API_VERSION` | `v1` | **未設定だと stub にフォールバック**して実 Jomon を叩かない | +| `NUXT_JOMON_API_TOKEN` | **Jomon の `SERVICE_TOKEN` と同値** | Bearer サービストークン | +| `NUXT_STRIPE_SECRET_KEY` | `sk_test_…` | | +| `NUXT_STRIPE_WEBHOOK_SECRET` | `whsec_…` | invoice-paid 用 | +| `NUXT_STRIPE_CONNECT_WEBHOOK_SECRET` | `whsec_…` | account-updated 用 | +| `NUXT_PRICE_SHINKI_ZENKI` / `NUXT_PRICE_SHINKI_KOUKI` / `NUXT_PRICE_KEIZOKU_STANDARD` / `NUXT_PRICE_KEIZOKU_SPECIAL` | `price_…` ×4 | 新規前期¥4,000(通期)/新規後期¥2,000/継続¥4,000/特別¥2,000 | +| ~~`NUXT_TRAQ_*`~~ | — | **不要**(`/login` は `/_oauth/login` に転送、自前 OAuth を使わない) | > `CHECKIN_DEV_LOGIN` は設定しない(本番ビルドで 404 だが念のため)。 @@ -136,3 +147,19 @@ member auth は **両アプリ Soft**(Off でも Hard でもなく Soft)。 - **再起動でディスク揮発**: Jomon 画像(LocalStorage)は消える。払い戻しに無関係。 - **Runtime 180MiB**: Nuxt Node OOM 時は自動シャットダウン(Blocking)+低負荷で対処。 - **実装状況**: forward-auth/Swift フォールバック(Jomon `local/checkin-dev-env`)と forward-auth(Checkin `claude/checkin-auth-collection`)は実装・コミット済み。Checkin は Dockerfile 不要(Command/Buildpack)。 + +--- + +## 5. トラブルシュート(実機で出たもの) + +### Jomon `panic: dial tcp [::1]:3306: connect: connection refused`(`main.go`) +- **原因**: `MARIADB_HOSTNAME` 等が未設定で localhost(:3306) を見ている。 +- **対処**: `MARIADB_USERNAME/PASSWORD/HOSTNAME/DATABASE` を NeoShowcase の `NS_MARIADB_*` の値で設定(§1-4)。`NS_MARIADB_*` は Adminer のログイン情報か SSH `env` で確認。手コピーを避けたいなら ENTRYPOINT 変換ラッパ化(要相談)。 + +### Jomon `panic: dir doesn't exist`(`router/service.go` `newImageRepository`) +- **原因**: Swift 無しフォールバックの LocalStorage が `./uploads` を**作らず**参照していた(`NewLocalStorage` は既存ディレクトリ必須)。**修正済み**(`local/checkin-dev-env` commit `b41808e`:`os.MkdirAll` でディレクトリ作成)。 +- **対処**: Jomon を**最新の `local/checkin-dev-env` で再ビルド**(push 即ビルド未設定なら NeoShowcase で手動同期/再ビルド)。 + +### Checkin の env が効かない(設定したのに空扱い) +- **原因**: 無印 env はビルド時の空値が焼かれている。 +- **対処**: **`NUXT_` 接頭辞**で設定(§2-4)。特に `NUXT_JOMON_API_VERSION=v1` を忘れると stub にフォールバックして実 Jomon を叩かない。 From f465b6e83d0f38264fa32bc7f17bfbd716a03f1b Mon Sep 17 00:00:00 2001 From: kaitoyama Date: Wed, 24 Jun 2026 21:09:03 +0900 Subject: [PATCH 21/30] =?UTF-8?q?docs:=20troubleshoot=20Jomon=20"ClientID:?= =?UTF-8?q?=20cannot=20be=20blank"=20(client=20=E2=86=92=20/=5Foauth/login?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under Soft member-auth the Jomon Vue client drove its own traQ OAuth (genpkce) and failed with a blank ClientID. Documented the fix (client now redirects to the platform forward-auth login; member-auth stays Soft so Bearer still works). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01ACKUEYT79nkgEKUjBWjnZM --- DEPLOY-NEOSHOWCASE.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/DEPLOY-NEOSHOWCASE.md b/DEPLOY-NEOSHOWCASE.md index 30aa1b9..677fc3a 100644 --- a/DEPLOY-NEOSHOWCASE.md +++ b/DEPLOY-NEOSHOWCASE.md @@ -160,6 +160,10 @@ Checkin は値を Nuxt runtimeConfig から読む。runtimeConfig は**ビルド - **原因**: Swift 無しフォールバックの LocalStorage が `./uploads` を**作らず**参照していた(`NewLocalStorage` は既存ディレクトリ必須)。**修正済み**(`local/checkin-dev-env` commit `b41808e`:`os.MkdirAll` でディレクトリ作成)。 - **対処**: Jomon を**最新の `local/checkin-dev-env` で再ビルド**(push 即ビルド未設定なら NeoShowcase で手動同期/再ビルド)。 +### Jomon UI で `{"message":"ClientID: cannot be blank."}` +- **原因**: Jomon の Vue クライアントが**自前 traQ OAuth**(`GET /api/auth/genpkce`)を起動するが、Soft 運用で `TRAQ_CLIENT_ID` を設定していないため。 +- **対処**: **修正済み**(`local/checkin-dev-env` commit `408644d`)。クライアントのログイン導線を NeoShowcase の **`/_oauth/login?redirect=…`** 転送に変更。未ログイン者は強制 traQ 認証→`X-Forwarded-User` で `/api/users/me` 成功=**全ページ要ログイン**を維持。**最新ブランチで再ビルド**すれば解消。member auth は **Soft のまま**(Hard にすると Checkin→Jomon の Bearer が弾かれる)。 + ### Checkin の env が効かない(設定したのに空扱い) - **原因**: 無印 env はビルド時の空値が焼かれている。 - **対処**: **`NUXT_` 接頭辞**で設定(§2-4)。特に `NUXT_JOMON_API_VERSION=v1` を忘れると stub にフォールバックして実 Jomon を叩かない。 From 7a4221664658c9d80ec3ea4459de871a6e0265dd Mon Sep 17 00:00:00 2001 From: kaitoyama Date: Wed, 24 Jun 2026 22:17:14 +0900 Subject: [PATCH 22/30] fix: logout clears NeoShowcase forward-auth state, not just the session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under Soft forward-auth the traQ identity comes from the proxy's X-Forwarded-User, not our session — so POST /logout destroying the session left the user logged in (the header re-asserted the identity on the next request). When trustForwardAuth is on, /logout now returns a redirect to the platform logout (/_oauth/logout) and the client follows it with a full-page navigation (the proxy auth cookie is HttpOnly and only dropped that way). Non-forward-auth deployments are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01ACKUEYT79nkgEKUjBWjnZM --- apps/web/app/composables/useCsrf.ts | 14 ++++++++++++-- apps/web/server/routes/logout.post.ts | 9 +++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/apps/web/app/composables/useCsrf.ts b/apps/web/app/composables/useCsrf.ts index 59addd5..3a641ad 100644 --- a/apps/web/app/composables/useCsrf.ts +++ b/apps/web/app/composables/useCsrf.ts @@ -26,14 +26,24 @@ export function useCsrf() { return token } - /** Destroy the session via `POST /logout`, then refresh auth state and go home. */ + /** + * Destroy the session via `POST /logout`. Normally then refresh auth state and + * go home. Under NeoShowcase forward-auth the server returns a `redirect` to the + * platform logout (`/_oauth/logout`): we must follow it with a full-page + * navigation, since the proxy's auth cookie is HttpOnly and only dropped that + * way — clearing our session alone would leave the X-Forwarded-User identity. + */ async function logout(): Promise { const token = await ensureCsrfToken() - await $fetch('/logout', { + const res = await $fetch<{ ok: boolean, redirect?: string }>('/logout', { method: 'POST', credentials: 'same-origin', headers: token ? { 'x-csrf-token': token } : {}, }) + if (res?.redirect) { + window.location.assign(res.redirect) + return + } await useAuthMe().refresh() await navigateTo('/') } diff --git a/apps/web/server/routes/logout.post.ts b/apps/web/server/routes/logout.post.ts index 39d391b..1ced6da 100644 --- a/apps/web/server/routes/logout.post.ts +++ b/apps/web/server/routes/logout.post.ts @@ -7,5 +7,14 @@ export default defineEventHandler(async (event) => { } await destroySession(useDatabase(), getCookie(event, SESSION_COOKIE)) clearSessionCookie(event) + + // Under NeoShowcase "Soft" forward-auth the traQ identity comes from the + // proxy's X-Forwarded-User, NOT our session — so clearing the session alone + // leaves the user logged in (the header re-asserts the identity on the next + // request). Tell the client to also drop the platform forward-auth state via + // the proxy's logout endpoint. (deploy: NeoShowcase Soft auth) + if (resolveAuthConfig().trustForwardAuth) { + return { ok: true, redirect: '/_oauth/logout?redirect=/' } + } return { ok: true } }) From 0101d6e8aba4339dd929486b50eb4fe7993f6d29 Mon Sep 17 00:00:00 2001 From: kaitoyama Date: Wed, 24 Jun 2026 23:11:11 +0900 Subject: [PATCH 23/30] docs: Jomon admin auto-seed via INITIAL_ADMIN_TRAP_IDS (no manual SQL) Jomon now seeds administrators from INITIAL_ADMIN_TRAP_IDS (+ SERVICE_USER_TRAP_ID) at startup, so the manual `INSERT INTO administrators` is no longer required. Documented the env and kept the SQL as an optional fallback. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01ACKUEYT79nkgEKUjBWjnZM --- DEPLOY-NEOSHOWCASE.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/DEPLOY-NEOSHOWCASE.md b/DEPLOY-NEOSHOWCASE.md index 677fc3a..74b1783 100644 --- a/DEPLOY-NEOSHOWCASE.md +++ b/DEPLOY-NEOSHOWCASE.md @@ -61,15 +61,14 @@ member auth は **両アプリ Soft**(Off でも Hard でもなく Soft)。 | **`SERVICE_USER_TRAP_ID`** | 例 `checkin` | **Jomon に admin 登録した trap_id**。書き戻しが `IsAdmin` 要求+実行者として記録 | | `WEBHOOK_SECRET`/`WEBHOOK_CHANNEL_ID`/`WEBHOOK_ID` | traQ 通知用 | dev は空で可 | | ~~`TRAQ_CLIENT_ID`~~ | — | **不要**(UI 認証は forward-auth、サービスは Bearer。自前 OAuth を使わない) | +| **`INITIAL_ADMIN_TRAP_IDS`** | `<あなたのtraQ_ID>`(カンマ区切り可) | **起動時に admin 自動シード**。`SERVICE_USER_TRAP_ID` も自動で admin になる | +| ~~`TRAQ_CLIENT_ID`~~ | — | **不要**(UI 認証は forward-auth、サービスは Bearer。自前 OAuth を使わない) | | ~~`OS_*`(Swift)~~ | — | **不要**(未設定なら LocalStorage にフォールバック。`UPLOAD_DIR` 任意) | - DB は **起動時 auto-migrate**(`model.Migrate()`)=マイグレーション手順不要。 -- **起動後に1回だけ admin をシード**。fresh な production DB は admin が空で、書き戻しが 403 になる。Adminer(https://adminer.ns.trap.jp)か SSH で: - ```sql - INSERT INTO administrators (trap_id) VALUES ('checkin'), ('<あなたのtraQ_ID>'); - ``` - - `checkin` = `SERVICE_USER_TRAP_ID`(Checkin の書き戻し用)。`<あなた>` = Jomon UI を admin で使う人。 - - スキーマは初回起動の auto-migrate で作られるので、**起動後**に INSERT。 +- **admin シードは env で自動**: `INITIAL_ADMIN_TRAP_IDS`(カンマ区切り)+ `SERVICE_USER_TRAP_ID` を起動時に administrators 登録(冪等)。**手動 SQL は不要**。 + - 最低限 `INITIAL_ADMIN_TRAP_IDS=<あなたのtraQ_ID>` を入れれば、自分が Jomon admin・`checkin`(書き戻し用)も自動で admin。 + - 手動でやる場合(任意): Adminer(https://adminer.ns.trap.jp)か SSH で `INSERT INTO administrators (trap_id) VALUES ('checkin'), ('<あなた>');`(テーブルは初回起動の auto-migrate 後)。 --- From 31b725d537db3aabda277b16efdaf654e8705f06 Mon Sep 17 00:00:00 2001 From: kaitoyama Date: Thu, 25 Jun 2026 00:00:43 +0900 Subject: [PATCH 24/30] feat: pay out to traQ-only recipients (no isct email required) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Jomon refunds are keyed by traQ ID; previously a payee who had never done isct email verification was "unresolved" and never paid, because a person row could only be born from a mail_hash. Make a person identifiable by EITHER mail_hash OR traq_id: - schema: `users.mail_hash` is now nullable (unique; migration 0008). A payout-only recipient has a traq_id and no mail_hash. - identity: `getOrCreateUserByTraqId` mints a payout-only row; `setUserMailHash` race-safely fills a null mail_hash; `resolveUserForEmailVerify` unifies email verification — create / link a member / MERGE a payout-only row gaining its email / refuse to auto-merge two separate rows (conflict) — keeping one row per person. - payout: `resolvePayee` auto-creates the traQ-only row instead of leaving it unresolved → the recipient just needs Connect onboarding (no membership). - connect: the Express account is labelled with whichever non-PII key exists (traq_id when there is no mail_hash). - billing stays mail_hash-gated: `requireUser`/session need mail_hash, and the special-invoice path rejects a payout-only target — traQ-only rows can't be billed. DB-backed tests cover the merge matrix and the payout auto-create. All gates green (typecheck/lint/build, 142 tests). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01ACKUEYT79nkgEKUjBWjnZM --- .../server/routes/verify-email/confirm.get.ts | 64 +- packages/api/src/auth/identity.test.ts | 108 +++- packages/api/src/auth/identity.ts | 136 ++++- packages/api/src/payouts/execute.test.ts | 23 +- packages/api/src/payouts/execute.ts | 13 +- packages/api/src/router.ts | 8 + packages/api/src/stripe/connect.ts | 18 +- packages/db/drizzle/0008_sticky_shiva.sql | 1 + packages/db/drizzle/meta/0008_snapshot.json | 556 ++++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/schema.ts | 7 +- 11 files changed, 884 insertions(+), 57 deletions(-) create mode 100644 packages/db/drizzle/0008_sticky_shiva.sql create mode 100644 packages/db/drizzle/meta/0008_snapshot.json diff --git a/apps/web/server/routes/verify-email/confirm.get.ts b/apps/web/server/routes/verify-email/confirm.get.ts index 8e3f0c1..dcd5027 100644 --- a/apps/web/server/routes/verify-email/confirm.get.ts +++ b/apps/web/server/routes/verify-email/confirm.get.ts @@ -2,27 +2,23 @@ import { attachUserToSession, consumeEmailVerification, createSession, - getOrCreateUserByMailHash, - linkTraqId, resolveSession, + resolveUserForEmailVerify, sanitizeRedirect, } from '@checkin/api' /** * GET /verify-email/confirm — consume a magic-link token (single-use, expiry, - * reuse-safe) and get-or-create the person row. + * reuse-safe) and resolve the person row, fusing it with the authenticated traQ + * identity (forward-auth header under NeoShowcase Soft, else the traQ-OAuth cookie + * session) when present. `resolveUserForEmailVerify` handles every case — create, + * link a member, MERGE a payout-only (traq_id, no mail_hash) row, or refuse to + * auto-merge two separate rows — so a Jomon refund recipient who later verifies + * their isct email keeps a single person row. (identity: §traQ ID 連結 / merge) * - * If the request already carries a traQ **member** session that has no user yet, - * we first try to `linkTraqId` the authenticated traQ ID onto the confirmed user - * row. Only when that link is coherent (`linked`/`exists`, i.e. the traq_id truly - * belongs to this user) do we attach `user_id` onto the SAME session, so the - * member ends up with both identities on one session. On `conflict` (the traq_id - * is owned by a DIFFERENT user) we do NOT attach to the traQ session — that would - * fabricate a dual identity the DB disagrees with. Instead we mint a FRESH plain - * user session so the person is logged in as their own isct identity, and warn for - * admin attention. Otherwise (no traQ session) we mint a plain user session as - * before. Then navigate to the stored same-site redirect. - * (session spec: §連結済みは両アイデンティティを持つ) + * Session: when a coherent traQ-member cookie session exists (legacy OAuth), we + * attach `user_id` onto it in place; otherwise (forward-auth, or a conflict) we + * mint a fresh user session. Then navigate to the stored same-site redirect. */ export default defineEventHandler(async (event) => { const config = resolveAuthConfig() @@ -38,33 +34,25 @@ export default defineEventHandler(async (event) => { return sendRedirect(event, '/?verify=invalid') } - const user = await getOrCreateUserByMailHash(db, consumed.mailHash) - - // An existing traQ member session without a linked user → connect them in place. + // The authenticated traQ identity: the forward-auth header (NeoShowcase Soft) + // when enabled, else the traQ-OAuth cookie session. const sessionToken = getCookie(event, SESSION_COOKIE) const existing = await resolveSession(db, sessionToken) - if (sessionToken && existing?.traqId && !existing.userId) { - // Link BEFORE attaching: only fuse the two identities onto the live traQ - // session when the traq_id legitimately belongs to this user. A `conflict` - // (the traq_id is owned by someone else) must NOT be attached, or the session - // would carry a traQ linkage the DB attributes to a different person. - const result = await linkTraqId(db, user.id, existing.traqId) - if (result === 'conflict') { - console.warn(`linkTraqId conflict on verify-email confirm: traqId=${existing.traqId} userId=${user.id}`) - // Don't fabricate a dual identity. Mint a fresh plain user session so the - // person is logged in as their own isct identity (no false traQ linkage). - const conflictToken = await createSession(db, { userId: user.id }, config.sessionTtlSec) - setSessionCookie(event, conflictToken, config.sessionTtlSec) - return sendRedirect(event, sanitizeRedirect(consumed.redirect)) - } - // Coherent linkage (linked/exists) → attach the user onto the SAME session so - // it carries both identities. Same cookie/token — no need to reset it. - await attachUserToSession(db, sessionToken, user.id) - return sendRedirect(event, sanitizeRedirect(consumed.redirect)) + const traqId = (config.trustForwardAuth ? forwardedTraqId(event) : null) ?? existing?.traqId ?? null + + const { userId, linkage } = await resolveUserForEmailVerify(db, { mailHash: consumed.mailHash, traqId }) + if (linkage === 'conflict') { + console.warn(`identity conflict on verify-email confirm: traqId=${traqId} userId=${userId}`) } - // No traQ session (isct-only): establish a fresh user session as before. - const newToken = await createSession(db, { userId: user.id }, config.sessionTtlSec) - setSessionCookie(event, newToken, config.sessionTtlSec) + // Attach onto an existing traQ-member cookie session in place when coherent; + // otherwise (forward-auth has no such cookie, or a conflict) mint a fresh session. + if (sessionToken && existing?.traqId && !existing.userId && linkage !== 'conflict') { + await attachUserToSession(db, sessionToken, userId) + } + else { + const newToken = await createSession(db, { userId }, config.sessionTtlSec) + setSessionCookie(event, newToken, config.sessionTtlSec) + } return sendRedirect(event, sanitizeRedirect(consumed.redirect)) }) diff --git a/packages/api/src/auth/identity.test.ts b/packages/api/src/auth/identity.test.ts index 0447c63..89cc9d7 100644 --- a/packages/api/src/auth/identity.test.ts +++ b/packages/api/src/auth/identity.test.ts @@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto' import { eq } from 'drizzle-orm' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { createDatabase, schema, type Database } from '@checkin/db' -import { getUserByTraqId, linkTraqId } from './identity' +import { getOrCreateUserByMailHash, getOrCreateUserByTraqId, getUserByMailHash, getUserByTraqId, linkTraqId, resolveUserForEmailVerify } from './identity' import { attachUserToSession, createSession, destroySession, resolveSession } from './session' /** @@ -172,3 +172,109 @@ describe('session: dual-identity resolution', () => { expect(await resolveSession(db, undefined)).toBeNull() }) }) + +describe('identity: getOrCreateUserByTraqId (payout-only rows)', () => { + it('creates a traq-only row (mail_hash NULL), idempotently', async () => { + if (!available) { + return + } + const traqId = `traq_${tag}_${randomUUID().slice(0, 6)}` + const a = await getOrCreateUserByTraqId(db, traqId) + userIds.push(a.id) + expect(a.traqId).toBe(traqId) + expect(a.mailHash).toBeNull() + expect(a.payoutOnboardingStatus).toBe('none') + // Idempotent: same row back, no duplicate. + const b = await getOrCreateUserByTraqId(db, traqId) + expect(b.id).toBe(a.id) + }) +}) + +describe('identity: resolveUserForEmailVerify (merge matrix)', () => { + const freshMail = () => `hash_${tag}_${randomUUID().slice(0, 8)}` + const freshTraq = () => `traq_${tag}_${randomUUID().slice(0, 6)}` + + it('no traqId → isct-only get-or-create by mail_hash', async () => { + if (!available) { + return + } + const mailHash = freshMail() + const r = await resolveUserForEmailVerify(db, { mailHash, traqId: null }) + userIds.push(r.userId) + expect(r.linkage).toBe('isct-only') + expect((await getUserByMailHash(db, mailHash))?.id).toBe(r.userId) + }) + + it('case 1: neither row exists → creates a fully-linked (mail + traq) row', async () => { + if (!available) { + return + } + const mailHash = freshMail() + const traqId = freshTraq() + const r = await resolveUserForEmailVerify(db, { mailHash, traqId }) + userIds.push(r.userId) + expect(r.linkage).toBe('created') + const row = await getUserByTraqId(db, traqId) + expect(row?.id).toBe(r.userId) + expect(row?.mailHash).toBe(mailHash) + }) + + it('case 2: a mail-only member gains the traq_id (linked)', async () => { + if (!available) { + return + } + const mailHash = freshMail() + const traqId = freshTraq() + const m = await getOrCreateUserByMailHash(db, mailHash) + userIds.push(m.id) + const r = await resolveUserForEmailVerify(db, { mailHash, traqId }) + expect(r.userId).toBe(m.id) + expect(r.linkage).toBe('linked') + expect((await getUserByTraqId(db, traqId))?.id).toBe(m.id) + }) + + it('case 3: a payout-only row (traq, no mail) gets its mail_hash MERGED', async () => { + if (!available) { + return + } + const traqId = freshTraq() + const mailHash = freshMail() + const t = await getOrCreateUserByTraqId(db, traqId) + userIds.push(t.id) + const r = await resolveUserForEmailVerify(db, { mailHash, traqId }) + expect(r.userId).toBe(t.id) + expect(r.linkage).toBe('merged') + expect((await getUserByTraqId(db, traqId))?.mailHash).toBe(mailHash) + }) + + it('case 4: already fully linked → no-op (already)', async () => { + if (!available) { + return + } + const mailHash = freshMail() + const traqId = freshTraq() + const r1 = await resolveUserForEmailVerify(db, { mailHash, traqId }) + userIds.push(r1.userId) + const r2 = await resolveUserForEmailVerify(db, { mailHash, traqId }) + expect(r2.userId).toBe(r1.userId) + expect(r2.linkage).toBe('already') + }) + + it('case 5: two separate rows for one person → conflict, no auto-merge', async () => { + if (!available) { + return + } + const mailHash = freshMail() + const traqId = freshTraq() + const m = await getOrCreateUserByMailHash(db, mailHash) // mail row, no traq + userIds.push(m.id) + const t = await getOrCreateUserByTraqId(db, traqId) // traq row, no mail + userIds.push(t.id) + const r = await resolveUserForEmailVerify(db, { mailHash, traqId }) + expect(r.linkage).toBe('conflict') + expect(r.userId).toBe(m.id) // resolves to the billing (mail) row + // Neither row mutated — no FK-risky auto-merge. + expect((await getUserByTraqId(db, traqId))?.mailHash).toBeNull() + expect((await getUserByMailHash(db, mailHash))?.traqId).toBeNull() + }) +}) diff --git a/packages/api/src/auth/identity.ts b/packages/api/src/auth/identity.ts index d9b4466..e70c6b0 100644 --- a/packages/api/src/auth/identity.ts +++ b/packages/api/src/auth/identity.ts @@ -4,7 +4,8 @@ import { schema, type Database } from '@checkin/db' export interface UserRow { id: string - mailHash: string + /** Null for a payout-only recipient (identified by traq_id, never email-verified). */ + mailHash: string | null } /** @@ -15,7 +16,8 @@ export interface UserRow { */ export interface BillingUserRow { id: string - mailHash: string + /** Null for a payout-only recipient (identified by traq_id, never email-verified). */ + mailHash: string | null traqId: string | null stripeCustomerId: string | null stripeConnectedAccountId: string | null @@ -245,3 +247,133 @@ export async function getOrCreateUserByMailHash(db: Database, mailHash: string): } return row } + +/** + * Get the person row for a `traq_id`, creating a payout-only row (mail_hash NULL) + * if absent. For Jomon refunds whose payee has never done isct email verification: + * a person is identified by EITHER mail_hash or traq_id, so we mint a traq-keyed + * row to anchor the connected account + onboarding. Idempotent and race-safe via + * the unique `traq_id` constraint. (add-traq-only-payout-recipient) + */ +export async function getOrCreateUserByTraqId(db: Database, traqId: string): Promise { + await db + .insert(schema.users) + .values({ id: randomUUID(), traqId }) + .onDuplicateKeyUpdate({ set: { traqId } }) + + const row = await getUserByTraqId(db, traqId) + if (!row) { + throw new Error('failed to get-or-create user by traq_id') + } + return row +} + +/** + * Race-safe compare-and-set of a person's `mail_hash`: only sets it when the + * column is still NULL (a payout-only row gaining an isct identity). Mirrors + * {@link linkTraqId}. + * - `'set'` — this call filled the column. + * - `'exists'` — the row already carries the SAME mail_hash (idempotent). + * - `'conflict'` — that mail_hash is owned by another row (unique violation), or + * this row holds a DIFFERENT mail_hash. Caller must NOT merge. + * (add-traq-only-payout-recipient) + */ +export async function setUserMailHash( + db: Database, + userId: string, + mailHash: string, +): Promise<'set' | 'exists' | 'conflict'> { + let result: unknown + try { + result = await db + .update(schema.users) + .set({ mailHash }) + .where(and( + eq(schema.users.id, userId), + isNull(schema.users.mailHash), + )) + } + catch (err) { + if (isDuplicateKeyError(err)) { + return 'conflict' + } + throw err + } + + const affected = (result as { affectedRows?: number })?.affectedRows ?? 0 + const claimed = Array.isArray(result) + ? ((result[0] as { affectedRows?: number })?.affectedRows ?? 0) + : affected + if (claimed > 0) { + return 'set' + } + + const existing = await getUserById(db, userId) + return existing?.mailHash === mailHash ? 'exists' : 'conflict' +} + +/** How an email-verification resolved the person row (for logging/diagnostics). */ +export type EmailVerifyLinkage = 'isct-only' | 'created' | 'linked' | 'merged' | 'already' | 'conflict' + +/** + * Resolve the person row for an isct email verification, fusing it with the + * authenticated traQ identity (forward-auth header or traQ-OAuth session) when + * present, so payouts to this traQ ID resolve without a membership payment. + * + * Cases (rowX = by traq_id, rowH = by mail_hash): + * - no traqId → get-or-create by mail_hash (isct-only). + * - rowX & rowH same row → already fully linked. + * - rowX & rowH differ → CONFLICT: two rows for one person; do NOT auto-merge + * (FK move is risky). Resolve to the billing row (rowH). + * - rowX only (mail null)→ MERGE: set mail_hash onto the payout-only row. + * - rowX with other mail → CONFLICT: traq_id bound to a different mail; fall back + * to the caller's own mail_hash row. + * - rowH / neither → get-or-create by mail_hash, then link the traq_id. + * Never fabricates a dual identity the DB disagrees with. (add-traq-only-payout-recipient) + */ +export async function resolveUserForEmailVerify( + db: Database, + input: { mailHash: string, traqId: string | null }, +): Promise<{ userId: string, linkage: EmailVerifyLinkage }> { + const { mailHash, traqId } = input + + if (!traqId) { + const u = await getOrCreateUserByMailHash(db, mailHash) + return { userId: u.id, linkage: 'isct-only' } + } + + const [rowX, rowH] = await Promise.all([ + getUserByTraqId(db, traqId), + getUserByMailHash(db, mailHash), + ]) + + if (rowX && rowH) { + if (rowX.id === rowH.id) { + return { userId: rowX.id, linkage: 'already' } + } + // Two separate rows for one person — refuse to auto-merge; prefer billing row. + return { userId: rowH.id, linkage: 'conflict' } + } + + if (rowX) { + if (rowX.mailHash === mailHash) { + return { userId: rowX.id, linkage: 'already' } + } + if (rowX.mailHash === null) { + const r = await setUserMailHash(db, rowX.id, mailHash) + return { userId: rowX.id, linkage: r === 'conflict' ? 'conflict' : 'merged' } + } + // traq_id is bound to a DIFFERENT mail_hash — don't link; use own isct row. + const u = await getOrCreateUserByMailHash(db, mailHash) + return { userId: u.id, linkage: 'conflict' } + } + + // No row carries this traq_id: create-or-find by mail_hash, then link the traq. + const created = rowH === null + const u = await getOrCreateUserByMailHash(db, mailHash) + const link = await linkTraqId(db, u.id, traqId) + if (link === 'conflict') { + return { userId: u.id, linkage: 'conflict' } + } + return { userId: u.id, linkage: created ? 'created' : 'linked' } +} diff --git a/packages/api/src/payouts/execute.test.ts b/packages/api/src/payouts/execute.test.ts index c53339d..6047634 100644 --- a/packages/api/src/payouts/execute.test.ts +++ b/packages/api/src/payouts/execute.test.ts @@ -3,7 +3,7 @@ import { eq } from 'drizzle-orm' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { createDatabase, schema, type Database } from '@checkin/db' import { deriveMailHash } from '../auth/crypto' -import { setPayoutOnboardingStatus } from '../auth/identity' +import { getUserByTraqId, setPayoutOnboardingStatus } from '../auth/identity' import type { StripeClient } from '../stripe/client' import { StubJomonClient } from '../jomon/stub' import { JomonWriteBackUnsupportedError } from '../jomon/http' @@ -131,22 +131,31 @@ describe('upsertPayoutByJomonRef (idempotent ingestion)', () => { }) describe('processApprovedPayouts (orchestration)', () => { - it('leaves an unidentifiable (unlinked traQ ID) payee as pending and does not pay out', async () => { + it('auto-creates a payout-only user for an unlinked traQ ID and parks it as onboarding_waiting', async () => { if (!available) { return } - const jomon = new StubJomonClient([req(`jmn-unres-${tag}`, `nobody-${tag}`)]) + const traqId = `nobody-${tag}` + const jomon = new StubJomonClient([req(`jmn-unres-${tag}`, traqId)]) const summary = await processApprovedPayouts( { db, stripe: fakeStripe(), jomon }, config, ) - expect(summary.unresolved).toBe(1) + // No longer "unresolved": a payout-only user (traq_id, no mail_hash) is minted, + // so a Jomon refund recipient who never did isct email verification can be paid. + expect(summary.unresolved).toBe(0) + expect(summary.onboardingWaiting).toBe(1) expect(summary.paid).toBe(0) + const minted = await getUserByTraqId(db, traqId) + expect(minted).not.toBeNull() + expect(minted!.mailHash).toBeNull() + userIds.push(minted!.id) + const row = await getPayoutByJomonRef(db, `jmn-unres-${tag}`) - expect(row?.userId).toBeNull() - expect(row?.status).toBe('pending') - // No write-back for an unresolved request. + expect(row?.userId).toBe(minted!.id) + expect(row?.status).toBe('onboarding_waiting') + // Not paid (onboarding not done) → no write-back. expect(jomon.writeBacks).toHaveLength(0) }) diff --git a/packages/api/src/payouts/execute.ts b/packages/api/src/payouts/execute.ts index 13a3890..4d4ce21 100644 --- a/packages/api/src/payouts/execute.ts +++ b/packages/api/src/payouts/execute.ts @@ -1,5 +1,5 @@ import type { Database } from '@checkin/db' -import { getUserById, getUserByTraqId } from '../auth/identity' +import { getOrCreateUserByTraqId, getUserById, getUserByTraqId } from '../auth/identity' import type { StripeClient } from '../stripe/client' import { createAccountOnboardingLink, getOrCreateConnectedAccount } from '../stripe/connect' import { createTransfer } from '../stripe/transfers' @@ -309,6 +309,7 @@ async function advancePayout( userId: user.id, stripeConnectedAccountId: user.stripeConnectedAccountId, mailHash: user.mailHash, + traqId: user.traqId, }) const onboardingUrl = await createAccountOnboardingLink(deps.stripe, { accountId, @@ -435,14 +436,20 @@ async function tryWriteBack( } } -/** Resolve the payee for a request: by fresh traQ ID, else by the linked userId. */ +/** + * Resolve the payee for a request: by fresh traQ ID, else by the linked userId. + * A traQ ID with no person row yet mints a payout-only row (mail_hash NULL) — a + * Jomon refund recipient need not have done isct email verification to be paid. + * (add-traq-only-payout-recipient) + */ async function resolvePayee( deps: PayoutDeps, req: JomonTransferRequest, row: PayoutRow, ) { if (req.payeeTraqId) { - return getUserByTraqId(deps.db, req.payeeTraqId) + return (await getUserByTraqId(deps.db, req.payeeTraqId)) + ?? (await getOrCreateUserByTraqId(deps.db, req.payeeTraqId)) } // No traQ ID (resume of an existing row): use the already-linked payee if any. if (row.userId) { diff --git a/packages/api/src/router.ts b/packages/api/src/router.ts index c40e86b..02dbf1d 100644 --- a/packages/api/src/router.ts +++ b/packages/api/src/router.ts @@ -362,6 +362,7 @@ export const appRouter = { userId: row.id, stripeConnectedAccountId: row.stripeConnectedAccountId, mailHash: row.mailHash, + traqId: row.traqId, }) // refresh/return land on app-origin pages (the UI is a later change). @@ -477,6 +478,13 @@ async function issueSpecialForRow( { userId: row.id, activityYear, halves: halvesForCoverage(params.coverage) }, { createDraft: async () => { + // A special invoice bills a person via their isct email; a payout-only + // recipient (traq_id, no mail_hash) has no email identity to bill. + if (!row.mailHash) { + throw new ORPCError('BAD_REQUEST', { + message: 'target user has no isct email identity (payout-only)', + }) + } // Reuse the linked Customer; otherwise an email is required to create one. if (!row.stripeCustomerId && !params.email) { throw new ORPCError('BAD_REQUEST', { diff --git a/packages/api/src/stripe/connect.ts b/packages/api/src/stripe/connect.ts index 225c316..40f0f55 100644 --- a/packages/api/src/stripe/connect.ts +++ b/packages/api/src/stripe/connect.ts @@ -13,8 +13,10 @@ export interface ResolveConnectedAccountInput { * the column fresh from the DB before deciding to create, to stay race-safe. */ stripeConnectedAccountId: string | null - /** Non-PII label for log/dashboard readability; never used as a lookup key. */ - mailHash: string + /** Non-PII labels for log/dashboard readability; never used as lookup keys. A + * payout-only recipient has no mail_hash, so traq_id labels the account instead. */ + mailHash: string | null + traqId?: string | null } /** @@ -45,10 +47,18 @@ export async function getOrCreateConnectedAccount( return existing } - // 2. Create an Express account (Stripe-managed hosted onboarding). + // 2. Create an Express account (Stripe-managed hosted onboarding). Label it with + // whichever non-PII key we have (a payout-only recipient has only traq_id). + const metadata: Record = {} + if (input.mailHash) { + metadata.mail_hash = input.mailHash + } + if (input.traqId) { + metadata.traq_id = input.traqId + } const created = await stripe.sdk.accounts.create({ type: 'express', - metadata: { mail_hash: input.mailHash }, + metadata, }) // Conditional compare-and-set: only link if the column is still NULL. The diff --git a/packages/db/drizzle/0008_sticky_shiva.sql b/packages/db/drizzle/0008_sticky_shiva.sql new file mode 100644 index 0000000..d31fe23 --- /dev/null +++ b/packages/db/drizzle/0008_sticky_shiva.sql @@ -0,0 +1 @@ +ALTER TABLE `users` MODIFY COLUMN `mail_hash` varchar(64); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0008_snapshot.json b/packages/db/drizzle/meta/0008_snapshot.json new file mode 100644 index 0000000..90a5238 --- /dev/null +++ b/packages/db/drizzle/meta/0008_snapshot.json @@ -0,0 +1,556 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "74654375-27ef-412f-b9bd-c802c9100a3f", + "prevId": "5f9f129b-bdb5-4917-9eda-008fc38c009f", + "tables": { + "email_verifications": { + "name": "email_verifications", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mail_hash": { + "name": "mail_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect": { + "name": "redirect", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "email_verifications_id": { + "name": "email_verifications_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "email_verifications_token_hash_unique": { + "name": "email_verifications_token_hash_unique", + "columns": [ + "token_hash" + ] + } + }, + "checkConstraint": {} + }, + "membership_slots": { + "name": "membership_slots", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "activity_year": { + "name": "activity_year", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "half": { + "name": "half", + "type": "enum('zenki','kouki')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "charge_group": { + "name": "charge_group", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('open','paid')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "membership_slots_user_id_users_id_fk": { + "name": "membership_slots_user_id_users_id_fk", + "tableFrom": "membership_slots", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "membership_slots_id": { + "name": "membership_slots_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "membership_slots_user_year_half_uq": { + "name": "membership_slots_user_year_half_uq", + "columns": [ + "user_id", + "activity_year", + "half" + ] + } + }, + "checkConstraint": {} + }, + "payouts": { + "name": "payouts", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "jomon_ref": { + "name": "jomon_ref", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "amount": { + "name": "amount", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "currency": { + "name": "currency", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('pending','onboarding_waiting','processing','paid','failed')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "stripe_transfer_id": { + "name": "stripe_transfer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "jomon_written_back_at": { + "name": "jomon_written_back_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "payouts_user_id_users_id_fk": { + "name": "payouts_user_id_users_id_fk", + "tableFrom": "payouts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "payouts_id": { + "name": "payouts_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "payouts_jomon_ref_unique": { + "name": "payouts_jomon_ref_unique", + "columns": [ + "jomon_ref" + ] + } + }, + "checkConstraint": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id_hash": { + "name": "id_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "traq_id": { + "name": "traq_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sessions_id": { + "name": "sessions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "sessions_id_hash_unique": { + "name": "sessions_id_hash_unique", + "columns": [ + "id_hash" + ] + } + }, + "checkConstraint": {} + }, + "stripe_events": { + "name": "stripe_events", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "received_at": { + "name": "received_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "stripe_events_id": { + "name": "stripe_events_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "stripe_events_event_id_unique": { + "name": "stripe_events_event_id_unique", + "columns": [ + "event_id" + ] + } + }, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mail_hash": { + "name": "mail_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "traq_id": { + "name": "traq_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripe_connected_account_id": { + "name": "stripe_connected_account_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payout_onboarding_status": { + "name": "payout_onboarding_status", + "type": "enum('none','requested','done')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'none'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "users_mail_hash_unique": { + "name": "users_mail_hash_unique", + "columns": [ + "mail_hash" + ] + }, + "users_traq_id_unique": { + "name": "users_traq_id_unique", + "columns": [ + "traq_id" + ] + }, + "users_stripe_connected_account_id_unique": { + "name": "users_stripe_connected_account_id_unique", + "columns": [ + "stripe_connected_account_id" + ] + } + }, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 2530ae6..4b036e8 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1782186295450, "tag": "0007_high_vermin", "breakpoints": true + }, + { + "idx": 8, + "version": "5", + "when": 1782312508235, + "tag": "0008_sticky_shiva", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 4fac596..4f33079 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -10,8 +10,11 @@ import { mysqlTable, varchar, timestamp, mysqlEnum, int, boolean, unique } from */ export const users = mysqlTable('users', { id: varchar('id', { length: 36 }).primaryKey(), - // hex HMAC-SHA256 → 64 chars. Unique: one row per person. - mailHash: varchar('mail_hash', { length: 64 }).notNull().unique(), + // hex HMAC-SHA256 → 64 chars. Unique: one row per person. NULLABLE: a person is + // identified by EITHER mail_hash OR traq_id — a payout-only recipient (Jomon + // refund, never did isct email verification) has a traq_id but no mail_hash. + // MariaDB allows multiple NULLs under a unique index. (add-traq-only-payout-recipient) + mailHash: varchar('mail_hash', { length: 64 }).unique(), // Authenticated traQ ID (non-PII secondary key). Added by add-traq-member-auth: // Jomon hands refunds keyed by traQ ID, so we link the authenticated traQ ID // (from an OAuth session, never form input) to this person row to resolve From a48edee10edc1c84c0b025fc21c083995231ef94 Mon Sep 17 00:00:00 2001 From: kaitoyama Date: Thu, 25 Jun 2026 09:29:11 +0900 Subject: [PATCH 25/30] feat: record manual bank-transfer payouts (no Stripe Connect required) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit オンボーディング未完了の受取人に会計が手で銀行振込したケースを 「手動振込済み」として記録できるようにする。 - DB: payouts に payout_method/manual_paid_at/manual_paid_note/manual_paid_by を追加 - 確定: Stripe transfer を発行せず、claim と同じ claimable 述語の単一原子的 UPDATE で paid 確定(paid/processing は拒否、Stripe 実行と競合しても二重支払い不可) - Jomon: 既存 write-back を再利用し manual note で settled を書き戻し(transfer_id 非依存) - API: payouts.markManuallyPaid(adminProc + CSRF、実行者はセッションから解決) - UI: /payouts 各行「手動振込済みにする」(参照メモ+確認モーダル、手動振込バッジ+実行者/日時) OpenSpec change add-manual-bank-payout を実装・sync・archive 済み。 Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/app/pages/payouts.vue | 208 +++++- .../.openspec.yaml | 2 + .../design.md | 99 +++ .../proposal.md | 26 + .../specs/accountant-ui/spec.md | 29 + .../specs/payout-execution/spec.md | 47 ++ .../tasks.md | 31 + openspec/specs/accountant-ui/spec.md | 26 + openspec/specs/payout-execution/spec.md | 36 +- packages/api/src/jomon/jomon.test.ts | 21 + packages/api/src/payouts/execute.test.ts | 308 ++++++++- packages/api/src/payouts/execute.ts | 99 +++ packages/api/src/payouts/store.ts | 70 ++ packages/api/src/router.ts | 41 ++ .../db/drizzle/0009_unique_shinobi_shaw.sql | 5 + packages/db/drizzle/meta/0009_snapshot.json | 598 ++++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/schema.ts | 18 + 18 files changed, 1663 insertions(+), 8 deletions(-) create mode 100644 openspec/changes/archive/2026-06-25-add-manual-bank-payout/.openspec.yaml create mode 100644 openspec/changes/archive/2026-06-25-add-manual-bank-payout/design.md create mode 100644 openspec/changes/archive/2026-06-25-add-manual-bank-payout/proposal.md create mode 100644 openspec/changes/archive/2026-06-25-add-manual-bank-payout/specs/accountant-ui/spec.md create mode 100644 openspec/changes/archive/2026-06-25-add-manual-bank-payout/specs/payout-execution/spec.md create mode 100644 openspec/changes/archive/2026-06-25-add-manual-bank-payout/tasks.md create mode 100644 packages/db/drizzle/0009_unique_shinobi_shaw.sql create mode 100644 packages/db/drizzle/meta/0009_snapshot.json diff --git a/apps/web/app/pages/payouts.vue b/apps/web/app/pages/payouts.vue index 53096c1..641aa17 100644 --- a/apps/web/app/pages/payouts.vue +++ b/apps/web/app/pages/payouts.vue @@ -6,7 +6,7 @@ definePageMeta({ layout: 'default' }) const { $orpc } = useNuxtApp() const { data: me } = useAuthMe() -const { formatAmount } = useFormatters() +const { formatAmount, formatDateTime } = useFormatters() // Server `adminProc` is the real authz; this gate is for navigation/UX only. const admin = computed(() => me.value?.admin ?? false) @@ -162,6 +162,81 @@ async function onExecute(jomonRef: string) { } } +// --- Per-row manual bank transfer (markManuallyPaid) -------------------------- +// Statuses on which the action is offered (mirrors the server's CLAIMABLE set); +// `paid`/`processing` rows never see the action (spec §paid/processing 非表示). +const MANUAL_PAYABLE: PayoutStatus[] = ['pending', 'onboarding_waiting', 'failed'] +function canMarkManuallyPaid(s: string): boolean { + return (MANUAL_PAYABLE as string[]).includes(s) +} + +const markingPaid = ref>(new Set()) +const markPaidResult = ref>>({}) +const markPaidError = ref>({}) + +// Confirmation dialog state: which row's confirm is open + its reference note. +// Cancelling closes the dialog WITHOUT calling the API. (spec §確認なしには確定しない) +const confirmOpen = ref(false) +const confirmRef = ref(null) +const confirmNote = ref('') + +function openMarkPaidConfirm(jomonRef: string) { + // Do not re-open while an in-flight settle for this row is running. + if (markingPaid.value.has(jomonRef)) { + return + } + confirmRef.value = jomonRef + confirmNote.value = '' + markPaidError.value = withoutKey(markPaidError.value, jomonRef) + confirmOpen.value = true +} + +function cancelMarkPaidConfirm() { + // Explicit cancel: never sends, just clears the dialog. + confirmOpen.value = false + confirmRef.value = null + confirmNote.value = '' +} + +async function onMarkManuallyPaid(jomonRef: string, note: string) { + if (markingPaid.value.has(jomonRef)) { + return + } + markingPaid.value = new Set(markingPaid.value).add(jomonRef) + markPaidError.value = withoutKey(markPaidError.value, jomonRef) + try { + const trimmed = note.trim() + const result = await $orpc.payouts.markManuallyPaid({ + jomonRef, + // Omit an empty note so the optional field stays undefined server-side. + ...(trimmed ? { note: trimmed } : {}), + }) + markPaidResult.value = { ...markPaidResult.value, [jomonRef]: result as unknown as Record } + await loadList() + } + catch (e) { + markPaidError.value = { ...markPaidError.value, [jomonRef]: errorMessageFor(e) } + } + finally { + const next = new Set(markingPaid.value) + next.delete(jomonRef) + markingPaid.value = next + } +} + +// Confirm in the dialog: capture the ref/note, close the dialog, then send. +async function confirmMarkPaid() { + const jomonRef = confirmRef.value + if (!jomonRef) { + return + } + const note = confirmNote.value + confirmOpen.value = false + confirmRef.value = null + confirmNote.value = '' + await onMarkManuallyPaid(jomonRef, note) +} + // --- Per-row onboarding link (createOnboardingLink) --------------------------- const onboardingLinking = ref>(new Set()) const onboardingUrl = ref>({}) @@ -361,6 +436,9 @@ onMounted(() => { + @@ -399,6 +477,42 @@ onMounted(() => { {{ row.status }} + @@ -420,6 +534,21 @@ onMounted(() => { > {{ row.status === 'failed' ? '再試行' : '実行' }} + + + 手動振込済みにする + diff --git a/openspec/changes/archive/2026-06-25-add-manual-bank-payout/.openspec.yaml b/openspec/changes/archive/2026-06-25-add-manual-bank-payout/.openspec.yaml new file mode 100644 index 0000000..fab62b4 --- /dev/null +++ b/openspec/changes/archive/2026-06-25-add-manual-bank-payout/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-24 diff --git a/openspec/changes/archive/2026-06-25-add-manual-bank-payout/design.md b/openspec/changes/archive/2026-06-25-add-manual-bank-payout/design.md new file mode 100644 index 0000000..71f0628 --- /dev/null +++ b/openspec/changes/archive/2026-06-25-add-manual-bank-payout/design.md @@ -0,0 +1,99 @@ +## Context + +払い戻し(payout-execution)は Stripe Connect 一本道で、`pending → onboarding_waiting → processing → paid/failed` の状態機械(`packages/api/src/payouts/`)で動く。`paid` 確定は `createTransfer`(Stripe)成功時のみで、確定後に `JomonClient.writeBackResult` で Jomon へ settled を書き戻す(`jomon_written_back_at` で再試行管理)。 + +オンボーディングを完了できない受取人は `onboarding_waiting`(または本人未連結で `pending`)のまま止まり、会計が銀行から手で振り込んでも「支払い済み」として記録できない。Jomon の振込依頼も未処理のまま残る。本 change は、Stripe を経由しない**手動振込での `paid` 確定**を、既存の冪等・原子性・Jomon 書き戻しの作法を保ったまま追加する。 + +関連既存資産: +- 原子的クレーム `claimPayoutForExecution`(`store.ts:138`)= `CLAIMABLE_STATUSES = ['pending','onboarding_waiting','failed']` への条件付き UPDATE。`processing`/`paid` は対象外。 +- Jomon 書き戻し `tryWriteBack`/`reattemptWriteBack`(`execute.ts:404-437`)= 成功時のみ `jomon_written_back_at` を記録、失敗は隔離して後で書き戻しのみ再試行。 +- v1 書き戻し(`jomon/http.ts:227`)は `PUT .../states/repaid/{trapId}` に `repaid_at` を送るだけで、`stripeTransferId` に**依存しない**。 +- 会計 UI `apps/web/app/pages/payouts.vue` の行操作(`onExecute` 等、per-row busy `Set` パターン)。 + +## Goals / Non-Goals + +**Goals:** +- Stripe transfer を発行せずに、会計操作で payout を `paid` に確定する原子的パスを追加する。 +- 対象は `pending` / `onboarding_waiting` / `failed` のみ。`paid` / `processing` は拒否(確定済み・実行中の上書き/二重支払いをしない)。 +- DB に監査情報(`payout_method`・`manual_paid_at`・`manual_paid_note`・`manual_paid_by`)を記録する。 +- 既存の `paid` と同様に Jomon へ settled を書き戻す(`stripe_transfer_id` 非依存、`manual_paid_note` で代替)。 +- 会計 UI の各行に「手動振込済みにする」操作(参照メモ入力+確認ダイアログ)を追加する。 + +**Non-Goals:** +- Stripe 送金フロー自体の変更(onboarding ゲート・transfer 実行・冪等キーはそのまま)。 +- Jomon 側 API の拡張(v2 の書き戻し未対応は従来どおり;手動 paid でも v2 は書き戻し失敗→再試行待ちのまま)。 +- 手動振込の金額編集(金額は既存 payout 行の `amount` を使う。誤りは別途の運用で対応)。 +- 複数受取人申請(multiPayee)の手動払い:payout 行が存在しないため対象外(従来どおり needs-review)。 + +## Decisions + +### D1: `payouts` に監査カラムを追加(後方互換) +`packages/db/src/schema.ts` の `payouts` に以下を追加し、drizzle マイグレーションを生成する: +- `payout_method` `mysqlEnum('payout_method', ['stripe_connect','manual_bank'])` `.notNull().default('stripe_connect')` — 既存行・既存 Stripe フローは既定値で不変。 +- `manual_paid_at` `timestamp` nullable — 手動 paid の確定時刻。 +- `manual_paid_note` `varchar(255)` nullable — 振込参照番号などの自由記述メモ。 +- `manual_paid_by` `varchar(36)` nullable, `references(() => users.id)` — 実行した会計の `users.id`。 + +`PayoutRow` DTO(`store.ts:6`)と `PAYOUT_COLUMNS` に少なくとも `payoutMethod` を追加し、UI が手動振込行をバッジ表示できるようにする(`manualPaidNote`/`manualPaidAt` も DTO に載せて表示に使う)。 + +**代替案**: 別テーブル `manual_payouts` に外出し。→ 1 payout = 1 行の状態機械を二重管理になり冪等・原子性が複雑化するため不採用。payout 行に直接持たせる。 + +### D2: 単一の原子的条件付き UPDATE で確定(`processing` を経由しない) +`store.ts` に専用関数を追加: +``` +markPayoutManuallyPaid(db, { jomonRef, note, byUserId }): Promise + UPDATE payouts + SET status='paid', payout_method='manual_bank', + manual_paid_at=NOW(), manual_paid_note=?, manual_paid_by=? + WHERE jomon_ref=? AND status IN ('pending','onboarding_waiting','failed') + // returns affectedRows === 1 +``` +`claimPayoutForExecution` と**同じ `CLAIMABLE_STATUSES` 述語**を使うので、Stripe 実行フローと手動確定が同一 `jomon_ref` に競合しても、先に WHERE を満たして UPDATE した方だけが勝ち、もう一方は `affectedRows=0` で短絡する。`stripe_transfer_id` は触らない(手動 paid は null のまま)。 + +**代替案**: 既存の `claimPayoutForExecution`(→`processing`)で claim してから `setPayoutStatus('paid')` の二段。→ 手動確定は claim と settle の間に外部 I/O が無いため、二段にする利点が無く、`processing` の中間状態を晒すだけ。単一 UPDATE が最小で同等に安全。不採用。 + +### D3: オーケストレーション関数 `markPayoutManuallyPaid`(Jomon 取込に依存しない) +`execute.ts`(または `payouts/manual.ts`)に追加。`processApproved`/`execute` と違い **Jomon の approved 一覧を pull しない**(依頼が approved から外れていても・受取人に connected account が無くても確定できることが要件の核心)。ローカル行だけで動く: +1. `getPayoutByJomonRef`。無ければエラー(NOT_FOUND)。 +2. `status === 'paid'` → `already_paid` 相当で短絡(書き戻し未了なら `reattemptWriteBack` のみ)。`status === 'processing'` → `needs_review`(Stripe 実行中の上書き禁止)。 +3. `markPayoutManuallyPaid(db, ...)` を実行。`affectedRows=0`(競合で負け)なら再読込して短絡(勝者が `paid` 済みなら書き戻し再試行のみ)。 +4. 勝ったら `tryWriteBack(deps, jomonRef, { status: 'paid', message: note })` を呼ぶ(既存の隔離・再試行ロジックを再利用)。 +5. `PayoutStepResult` を返す(`outcome: 'paid'`)。 + +本人特定の不変条件は維持: 既に `user_id` のある行はそのまま(再リンクしない)。`user_id` が null(オンボーディング未着手)でも手動確定は許可する。 + +### D4: Jomon 書き戻しは既存の型・経路をそのまま再利用 +`JomonWriteBackResult`(`jomon/types.ts:44`)は `status:'paid'|'failed'` + optional `stripeTransferId` + optional `message` で、手動 paid は `{ status:'paid', message: note }`(`stripeTransferId` 省略)で表現できる。v1 ドライバは `repaid_at` のみ送り `stripeTransferId` 非依存なので**型変更不要**。`message` に手動振込の参照メモを載せる。v2 は従来どおり未対応(書き戻し失敗→`jomon_written_back_at` 未設定で再試行待ち、`paid` は保持)。 + +### D5: 新 oRPC route `payouts.markManuallyPaid` +`router.ts` の `payouts` に追加。`adminProc` + `context.assertCsrf()`(状態変更)。 +``` +input: z.object({ jomonRef: z.string().min(1), note: z.string().max(255).optional() }) +``` +実行者(`manual_paid_by`)はクライアント入力ではなく**サーバのセッションから解決**する(なりすまし防止)。`context.session.userId` を優先し、null の場合は `context.session.traqId` から `getUserByTraqId` で会計の `users.id` を解決して用いる(traQ-only admin で `session.userId` が露出しないケースの保険)。解決できなければ `manual_paid_by` は null(監査は `manual_paid_at`+`note` で担保)。 + +### D6: 会計 UI に行操作を追加 +`payouts.vue`: +- per-row busy `Set`(`markingPaid`)と結果/エラー Record を `onExecute` と同じ流儀で追加。 +- 「手動振込済みにする」ボタンは `row.status` が `pending`/`onboarding_waiting`/`failed` のときだけ表示(`paid`/`processing` では非表示)。 +- クリックで参照メモ入力(`UInput`/`UTextarea`)+確認ダイアログ(`UModal` もしくは `overlay.confirm` 相当)を出し、確定時のみ `$orpc.payouts.markManuallyPaid({ jomonRef, note })` を呼ぶ。誤確定防止のため確認必須。 +- 成功後 `loadList()` で再取得。一覧に `payout_method`(手動振込バッジ)・参照メモを反映。実行中は二重発火を防ぐ。 + +## Risks / Trade-offs + +- **[Stripe 実行と手動確定の競合で二重支払い]** → D2 の単一原子的 UPDATE が `claimPayoutForExecution` と同じ `CLAIMABLE_STATUSES` 述語を共有。先に確定した側だけが勝ち、もう一方は `affectedRows=0` で短絡。Stripe 側は加えて冪等キー `payout:${ref}` が最後の砦。 +- **[既に Stripe で paid 済みを手動上書き]** → WHERE が `paid`/`processing` を除外するため UPDATE が当たらず不可。route 側でも事前に `paid`/`processing` を弾く。 +- **[誤った金額/受取人で手動 paid]** → 金額は既存 payout 行の `amount` を使用(編集不可)。実行は会計の責任で、参照メモ+実行者+日時を監査として残す。`user_id` 不変条件は維持。 +- **[v1 書き戻しが trapId を要するが user 未連結]** → 書き戻しは既存どおり失敗を隔離(warning ログ+`jomon_written_back_at` 未設定で再試行待ち)。payout のローカル `paid` は保持されるので業務は進む。v2 は従来どおり書き戻し未対応。 +- **[`manual_paid_by` が null になりうる]** → traQ-only admin で userId 未解決のときは null 許容。`manual_paid_at`+`note` で最低限の監査は残る。 + +## Migration Plan + +1. `schema.ts` にカラム追加 → `pnpm --filter @checkin/db ...`(既存の drizzle 生成手順)でマイグレーション生成。すべて nullable もしくは default 付きなので既存行は無変更(`payout_method` は `stripe_connect` 既定)。 +2. API(store/orchestration/router)→ UI の順で実装。 +3. ロールバック: 追加カラムは加算的。切り戻す場合は逆マイグレーションでカラム削除(手動 paid 済みデータは失われるため、本番投入後は前進修正を基本とする)。 + +## Open Questions + +- 一覧に手動振込専用カラム(参照メモ・実行者・日時)を常時表示するか、`payout_method` バッジ+詳細展開に留めるか → UI 実装時に最小(バッジ+メモ表示)で開始し、必要なら拡張。 +- 手動 paid の取り消し(誤確定の是正)操作を将来用意するか → 本 change では Non-Goal。必要になれば別 change。 diff --git a/openspec/changes/archive/2026-06-25-add-manual-bank-payout/proposal.md b/openspec/changes/archive/2026-06-25-add-manual-bank-payout/proposal.md new file mode 100644 index 0000000..56900e9 --- /dev/null +++ b/openspec/changes/archive/2026-06-25-add-manual-bank-payout/proposal.md @@ -0,0 +1,26 @@ +## Why + +Stripe Connect のオンボーディングを完了できない受取人には、会計が実際に銀行から手で振り込む運用が既に発生している。しかし現状の払い戻しは Stripe Connect 一本道で、手動振込を「支払い済み(settled)」として記録する手段が無い。結果、その payout は `pending` / `onboarding_waiting` / `failed` のまま永久に残り、Jomon 側の振込依頼も未処理のまま残る。会計が手で振り込んだ事実をシステムに反映できるようにする。 + +## What Changes + +- **手動振込での確定パス(payout-execution)**: Stripe transfer を発行せずに、会計の操作で payout を `paid` に確定する専用パスを追加する。対象は `pending` / `onboarding_waiting` / `failed` のみで、`paid` / `processing` は対象外。送金実行と同じく原子的クレームで確定し、Stripe 送金との競合・二重確定を防ぐ。 +- **監査用フィールド(DB)**: `payouts` に `payout_method`(`stripe_connect` / `manual_bank`)、手動 paid 日時、参照メモ、実行者(会計ユーザー)を追加する。既存行は `stripe_connect` 既定で後方互換。 +- **Jomon 書き戻し(payout-execution)**: 手動 paid でも既存の Stripe `paid` と同様に Jomon へ settled を書き戻す。`stripe_transfer_id` が無いため、手動振込である旨/参照メモで代替する。書き戻しは再試行可能で、確定済みの `paid` を再送金・上書きしない。 +- **会計 UI(accountant-ui)**: `/payouts` の各行に「手動振込済みにする」操作(参照メモ入力+確認ダイアログ)を追加する。`paid` / `processing` の行には出さない。新 oRPC route `payouts.markManuallyPaid({ jomonRef, note })`(`adminProc` / CSRF)を追加する。 + +## Capabilities + +### New Capabilities + + +### Modified Capabilities +- `payout-execution`: 手動振込での `paid` 確定パス(対象状態の限定・原子的クレーム・Stripe 送金を行わない)と、それを含む Jomon 書き戻し・`payout_method`/実行者の記録という要件を追加する。 +- `accountant-ui`: `/payouts` 各行に「手動振込済みにする」操作(参照メモ入力・確認ダイアログ・`paid`/`processing` では非表示)を追加する。 + +## Impact + +- **DB**: `packages/db/src/schema.ts` の `payouts` テーブルにカラム追加(`payout_method`・`manual_paid_at`・`manual_paid_note`・`manual_paid_by`)+ drizzle マイグレーション。 +- **API / ドメイン**: `packages/api/src/payouts/`(手動確定ロジック・atomic claim 拡張)、`packages/api/src/payouts/store.ts`(確定クエリ)、`packages/api/src/router.ts`(新 route `payouts.markManuallyPaid`)。Jomon 書き戻し(`packages/api/src/jomon/*`)は手動 paid を settled として扱えるようにする。 +- **フロント**: `apps/web/app/pages/payouts.vue`(行操作・モーダル・確認)。 +- **互換性**: 既存 payout・既存フローは不変(`payout_method` 既定 `stripe_connect`)。BREAKING なし。 diff --git a/openspec/changes/archive/2026-06-25-add-manual-bank-payout/specs/accountant-ui/spec.md b/openspec/changes/archive/2026-06-25-add-manual-bank-payout/specs/accountant-ui/spec.md new file mode 100644 index 0000000..da71343 --- /dev/null +++ b/openspec/changes/archive/2026-06-25-add-manual-bank-payout/specs/accountant-ui/spec.md @@ -0,0 +1,29 @@ +# accountant-ui + +## ADDED Requirements + +### Requirement: 払い戻しの手動振込済み操作(`/payouts`) + +`/payouts` は会計のみに対し、各払い戻し行に「手動振込済みにする」操作を提供 SHALL。これは Stripe Connect のオンボーディングを完了できない受取人へ会計が銀行から手で振り込んだケースを記録するための操作である。操作は `pending` / `onboarding_waiting` / `failed` の行にのみ表示し、`paid` / `processing` の行には表示しては SHALL NOT。 + +操作は参照メモ(振込参照番号など)を入力させ、確定前に確認ダイアログ(誤確定・二重支払い防止)を表示する MUST。確定時は `payouts.markManuallyPaid({ jomonRef, note })`(`adminProc` / CSRF 検証)を呼ぶ MUST。成功後は一覧を再取得し、実行中は同操作の二重発火を防ぐ MUST。確定済みの行は `paid`・手動振込(`payout_method=manual_bank`)として一覧に反映され、参照メモ・実行者・手動 paid 日時を確認できる MUST。 + +#### Scenario: 未払い行を手動振込済みにする + +- **WHEN** 会計が `pending` / `onboarding_waiting` / `failed` の行で「手動振込済みにする」を選び、参照メモを入力して確認ダイアログで確定する +- **THEN** `payouts.markManuallyPaid({ jomonRef, note })` が呼ばれ、成功すると一覧が再取得され、その行が `paid`(手動振込)として表示される + +#### Scenario: paid / processing には操作を出さない + +- **WHEN** 会計が `paid` または `processing` の行を見る +- **THEN** 「手動振込済みにする」操作は表示されない + +#### Scenario: 確認なしには確定しない + +- **WHEN** 会計が「手動振込済みにする」を開いて確認ダイアログをキャンセルする +- **THEN** `payouts.markManuallyPaid` は呼ばれず、状態は変化しない + +#### Scenario: 実行中の二重操作防止 + +- **WHEN** ある行の手動振込確定が実行中 +- **THEN** 同じ操作は二重に発火せず、完了後に一覧が再取得される diff --git a/openspec/changes/archive/2026-06-25-add-manual-bank-payout/specs/payout-execution/spec.md b/openspec/changes/archive/2026-06-25-add-manual-bank-payout/specs/payout-execution/spec.md new file mode 100644 index 0000000..4aa1fcc --- /dev/null +++ b/openspec/changes/archive/2026-06-25-add-manual-bank-payout/specs/payout-execution/spec.md @@ -0,0 +1,47 @@ +# payout-execution + +## ADDED Requirements + +### Requirement: 手動振込での paid 確定(Stripe 送金を行わない) + +システムは、Stripe Connect のオンボーディングを完了できない受取人に会計が銀行から手で振り込んだ場合に、当該 payout を Stripe transfer を発行せずに `paid` へ確定する手動確定パスを提供 SHALL。対象は `pending` / `onboarding_waiting` / `failed` の payout のみとし、`paid` / `processing` は対象外として拒否 MUST(確定済みや実行中の上書きをしない)。 + +確定は原子的に行う SHALL:単一の条件付き UPDATE で `status` を `paid` にし、同時に `payout_method` を `manual_bank` に、`manual_paid_at`・`manual_paid_note`・`manual_paid_by`(実行した会計のユーザー)を記録する。条件は `status IN ('pending','onboarding_waiting','failed')` を満たすときのみ成功とし、満たさない(既に `paid` / `processing`)場合は更新を行わず短絡 MUST。これにより Stripe 送金フロー(`processing` クレーム)との競合や二重確定で二重に支払われては SHALL NOT。手動確定では `stripe_transfer_id` を設定しては SHALL NOT。 + +手動確定の対象は受取人(`user_id`)が解決済みである必要は無い MUST NOT 制約とはしないが、本人特定の不変条件(確定済み `user_id` を別人に再リンクしない)は維持する SHALL。 + +#### Scenario: 未払いの payout を手動振込済みにできる + +- **WHEN** 会計が `pending` / `onboarding_waiting` / `failed` のいずれかの payout を、参照メモ付きで「手動振込済み」にする +- **THEN** Stripe transfer を発行せず payout が `paid` になり、`payout_method=manual_bank`・`manual_paid_at`・`manual_paid_note`・`manual_paid_by` が記録される(`stripe_transfer_id` は空のまま) + +#### Scenario: paid / processing は手動確定できない + +- **WHEN** 会計が `paid` または `processing` の payout を手動振込済みにしようとする +- **THEN** 確定は拒否され、状態は変化せず、二重支払いは起きない + +#### Scenario: Stripe 送金との競合で二重に支払わない + +- **WHEN** 同一 `jomon_ref` に対し、手動確定と Stripe 送金フローのクレームが同時並行で走る +- **THEN** 原子的更新により一方だけが `paid` を確定し、他方は短絡して支払い(送金)を行わない + +## MODIFIED Requirements + +### Requirement: 結果を Jomon に書き戻す(再試行可能・再送金しない) + +システムは、確定結果(Stripe 送金で `paid` / 手動振込で `paid` / 失敗)を `JomonClient` を通じて Jomon に書き戻す SHALL。書き戻し成否を記録(`jomon_written_back_at`)し、`paid` 済みだが書き戻し未了の payout は、再実行時に**書き戻しのみ再試行**して送金や手動確定を再実行しては SHALL NOT。手動振込で `paid` になった payout は `stripe_transfer_id` を持たないため、書き戻しは `stripe_transfer_id` の存在を前提とせず、手動振込である旨(および `manual_paid_note`)で代替して settled を書き戻す SHALL。v1 は `PUT .../states/repaid/{trapId}` を用いる。**v2 は書き戻し API が無いため未対応として失敗(警告ログ)**し、`jomon_written_back_at` は未設定のまま将来の Jomon 追加に備える(`paid` は確定済みとして保持)。 + +#### Scenario: v1 は送金済みを書き戻す + +- **WHEN** v1 で payout が(Stripe 送金で)`paid` になる +- **THEN** `PUT .../states/repaid/{trapId}` で「送金済み(repaid_at)」が書き戻され、書き戻し済みが記録される + +#### Scenario: v1 は手動振込済みを書き戻す + +- **WHEN** v1 で payout が手動振込で `paid` になる(`stripe_transfer_id` 無し) +- **THEN** `stripe_transfer_id` を要求せず、`PUT .../states/repaid/{trapId}` で settled(repaid_at)が書き戻され、書き戻し済みが記録される + +#### Scenario: 書き戻し失敗は再試行され、再送金はしない + +- **WHEN** 確定(送金または手動振込)は成功したが Jomon 書き戻しが失敗(v2 未対応含む)し、後で再実行する +- **THEN** 送金も手動確定も再実行されず、書き戻しのみ再試行される(v2 は対応追加まで失敗し続けるが二重支払いは起きない) diff --git a/openspec/changes/archive/2026-06-25-add-manual-bank-payout/tasks.md b/openspec/changes/archive/2026-06-25-add-manual-bank-payout/tasks.md new file mode 100644 index 0000000..d076bd2 --- /dev/null +++ b/openspec/changes/archive/2026-06-25-add-manual-bank-payout/tasks.md @@ -0,0 +1,31 @@ +## 1. DB スキーマとマイグレーション + +- [x] 1.1 `packages/db/src/schema.ts` の `payouts` に `payout_method`(enum `['stripe_connect','manual_bank']`、`notNull().default('stripe_connect')`)、`manual_paid_at`(timestamp nullable)、`manual_paid_note`(varchar(255) nullable)、`manual_paid_by`(varchar(36) nullable、`references(() => users.id)`)を追加し、各カラムにコメントを付す +- [x] 1.2 drizzle マイグレーションを生成(既存の DB パッケージ手順に従う)。既存行が無変更で通る(default/nullable)ことを確認 + +## 2. ストア層(原子的確定) + +- [x] 2.1 `packages/api/src/payouts/store.ts` の `PayoutRow` と `PAYOUT_COLUMNS` に `payoutMethod`(および表示用に `manualPaidNote`・`manualPaidAt`)を追加 +- [x] 2.2 `markPayoutManuallyPaid(db, { jomonRef, note, byUserId })` を追加:単一の条件付き UPDATE で `status='paid'`・`payout_method='manual_bank'`・`manual_paid_at=NOW()`・`manual_paid_note`・`manual_paid_by` をセットし、`WHERE jomon_ref=? AND status IN ('pending','onboarding_waiting','failed')`。`affectedRows === 1` を真として返す(`claimPayoutForExecution` と同じ affectedRows 抽出)。`stripe_transfer_id` は触らない + +## 3. オーケストレーション(手動 paid 確定) + +- [x] 3.1 `packages/api/src/payouts/`(`execute.ts` か新 `manual.ts`)に `markPayoutManuallyPaid(deps, { jomonRef, note, byUserId })` を実装:(1) `getPayoutByJomonRef`、無ければ NOT_FOUND、(2) `paid` は短絡(書き戻し未了なら `reattemptWriteBack` のみ)/`processing` は `needs_review`、(3) store の原子的確定、(4) 負け(affectedRows=0)は再読込して短絡、(5) 勝ちは `tryWriteBack(deps, jomonRef, { status: 'paid', message: note })`、(6) `PayoutStepResult`(`outcome: 'paid'`)を返す +- [x] 3.2 Jomon の approved 一覧を pull しないこと(ローカル行のみで動く)と、`user_id` 不変条件の維持(null は許可、別人への再リンクはしない)を担保 + +## 4. API ルート + +- [x] 4.1 `packages/api/src/router.ts` の `payouts` に `markManuallyPaid` を追加:`adminProc` + `context.assertCsrf()`、入力 `z.object({ jomonRef: z.string().min(1), note: z.string().max(255).optional() })` +- [x] 4.2 実行者を**サーバセッションから解決**:`context.session.userId` を優先、null なら `context.session.traqId` から `getUserByTraqId` で会計の `users.id` を解決し `byUserId` として渡す(解決不能なら null)。`list` の status enum 等の既存型と整合させる + +## 5. 会計 UI(`/payouts`) + +- [x] 5.1 `apps/web/app/pages/payouts.vue` に per-row busy `Set`(`markingPaid`)と結果/エラー Record を `onExecute` と同じ流儀で追加し、`onMarkManuallyPaid(jomonRef, note)` で `$orpc.payouts.markManuallyPaid` を呼び `loadList()` 再取得 +- [x] 5.2 「手動振込済みにする」ボタンを `row.status` が `pending`/`onboarding_waiting`/`failed` のときだけ表示(`paid`/`processing` では非表示)。参照メモ入力+確認ダイアログ(確定時のみ送信)を実装。実行中は二重発火防止 +- [x] 5.3 一覧に `payout_method`(手動振込バッジ)と参照メモを反映し、手動 paid 行が判別できるようにする + +## 6. テストと検証 + +- [x] 6.1 ストア/オーケストレーションの単体テスト:claimable 状態からの手動 paid 成功、`paid`/`processing` 拒否、Stripe claim との競合で二重に paid にならない、書き戻し失敗の隔離・再試行、`user_id` null でも確定できる +- [x] 6.2 Jomon v1 書き戻しが手動 paid(`stripeTransferId` 無し)で `repaid` を送ること、v2 は未対応で `paid` 保持・再試行待ちになることを確認 +- [x] 6.3 `pnpm lint` / `pnpm typecheck` / `pnpm build` / `pnpm test` がグリーン。可能なら実サーバ+MariaDB(docker compose) で UI からの手動振込済み操作を E2E 確認 diff --git a/openspec/specs/accountant-ui/spec.md b/openspec/specs/accountant-ui/spec.md index b8d5470..9d55b54 100644 --- a/openspec/specs/accountant-ui/spec.md +++ b/openspec/specs/accountant-ui/spec.md @@ -105,3 +105,29 @@ - **WHEN** ある行の操作が実行中 - **THEN** 同じ操作は二重に発火せず、完了後に一覧が再取得される + +### Requirement: 払い戻しの手動振込済み操作(`/payouts`) + +`/payouts` は会計のみに対し、各払い戻し行に「手動振込済みにする」操作を提供 SHALL。これは Stripe Connect のオンボーディングを完了できない受取人へ会計が銀行から手で振り込んだケースを記録するための操作である。操作は `pending` / `onboarding_waiting` / `failed` の行にのみ表示し、`paid` / `processing` の行には表示しては SHALL NOT。 + +操作は参照メモ(振込参照番号など)を入力させ、確定前に確認ダイアログ(誤確定・二重支払い防止)を表示する MUST。確定時は `payouts.markManuallyPaid({ jomonRef, note })`(`adminProc` / CSRF 検証)を呼ぶ MUST。成功後は一覧を再取得し、実行中は同操作の二重発火を防ぐ MUST。確定済みの行は `paid`・手動振込(`payout_method=manual_bank`)として一覧に反映され、参照メモ・実行者・手動 paid 日時を確認できる MUST。 + +#### Scenario: 未払い行を手動振込済みにする + +- **WHEN** 会計が `pending` / `onboarding_waiting` / `failed` の行で「手動振込済みにする」を選び、参照メモを入力して確認ダイアログで確定する +- **THEN** `payouts.markManuallyPaid({ jomonRef, note })` が呼ばれ、成功すると一覧が再取得され、その行が `paid`(手動振込)として表示される + +#### Scenario: paid / processing には操作を出さない + +- **WHEN** 会計が `paid` または `processing` の行を見る +- **THEN** 「手動振込済みにする」操作は表示されない + +#### Scenario: 確認なしには確定しない + +- **WHEN** 会計が「手動振込済みにする」を開いて確認ダイアログをキャンセルする +- **THEN** `payouts.markManuallyPaid` は呼ばれず、状態は変化しない + +#### Scenario: 実行中の二重操作防止 + +- **WHEN** ある行の手動振込確定が実行中 +- **THEN** 同じ操作は二重に発火せず、完了後に一覧が再取得される diff --git a/openspec/specs/payout-execution/spec.md b/openspec/specs/payout-execution/spec.md index ea9ead5..f9e7e9e 100644 --- a/openspec/specs/payout-execution/spec.md +++ b/openspec/specs/payout-execution/spec.md @@ -104,19 +104,47 @@ Jomon で承認済みの振込依頼を取り込み、[[connect-onboarding]] で - **WHEN** 送金実行が失敗する - **THEN** payout は `failed` になり、二重送金は起きない +### Requirement: 手動振込での paid 確定(Stripe 送金を行わない) + +システムは、Stripe Connect のオンボーディングを完了できない受取人に会計が銀行から手で振り込んだ場合に、当該 payout を Stripe transfer を発行せずに `paid` へ確定する手動確定パスを提供 SHALL。対象は `pending` / `onboarding_waiting` / `failed` の payout のみとし、`paid` / `processing` は対象外として拒否 MUST(確定済みや実行中の上書きをしない)。 + +確定は原子的に行う SHALL:単一の条件付き UPDATE で `status` を `paid` にし、同時に `payout_method` を `manual_bank` に、`manual_paid_at`・`manual_paid_note`・`manual_paid_by`(実行した会計のユーザー)を記録する。条件は `status IN ('pending','onboarding_waiting','failed')` を満たすときのみ成功とし、満たさない(既に `paid` / `processing`)場合は更新を行わず短絡 MUST。これにより Stripe 送金フロー(`processing` クレーム)との競合や二重確定で二重に支払われては SHALL NOT。手動確定では `stripe_transfer_id` を設定しては SHALL NOT。 + +手動確定の対象は受取人(`user_id`)が解決済みである必要は無い MUST NOT 制約とはしないが、本人特定の不変条件(確定済み `user_id` を別人に再リンクしない)は維持する SHALL。 + +#### Scenario: 未払いの payout を手動振込済みにできる + +- **WHEN** 会計が `pending` / `onboarding_waiting` / `failed` のいずれかの payout を、参照メモ付きで「手動振込済み」にする +- **THEN** Stripe transfer を発行せず payout が `paid` になり、`payout_method=manual_bank`・`manual_paid_at`・`manual_paid_note`・`manual_paid_by` が記録される(`stripe_transfer_id` は空のまま) + +#### Scenario: paid / processing は手動確定できない + +- **WHEN** 会計が `paid` または `processing` の payout を手動振込済みにしようとする +- **THEN** 確定は拒否され、状態は変化せず、二重支払いは起きない + +#### Scenario: Stripe 送金との競合で二重に支払わない + +- **WHEN** 同一 `jomon_ref` に対し、手動確定と Stripe 送金フローのクレームが同時並行で走る +- **THEN** 原子的更新により一方だけが `paid` を確定し、他方は短絡して支払い(送金)を行わない + ### Requirement: 結果を Jomon に書き戻す(再試行可能・再送金しない) -システムは、送金結果(送金済み / 失敗)を `JomonClient` を通じて Jomon に書き戻す SHALL。書き戻し成否を記録(`jomon_written_back_at`)し、送金成功済みだが書き戻し未了の payout は、再実行時に**書き戻しのみ再試行**して送金は再実行しては SHALL NOT。v1 は `PUT .../states/repaid/{trapId}` を用いる。**v2 は書き戻し API が無いため未対応として失敗(警告ログ)**し、`jomon_written_back_at` は未設定のまま将来の Jomon 追加に備える(送金の `paid` は確定済みとして保持)。 +システムは、確定結果(Stripe 送金で `paid` / 手動振込で `paid` / 失敗)を `JomonClient` を通じて Jomon に書き戻す SHALL。書き戻し成否を記録(`jomon_written_back_at`)し、`paid` 済みだが書き戻し未了の payout は、再実行時に**書き戻しのみ再試行**して送金や手動確定を再実行しては SHALL NOT。手動振込で `paid` になった payout は `stripe_transfer_id` を持たないため、書き戻しは `stripe_transfer_id` の存在を前提とせず、手動振込である旨(および `manual_paid_note`)で代替して settled を書き戻す SHALL。v1 は `PUT .../states/repaid/{trapId}` を用いる。**v2 は書き戻し API が無いため未対応として失敗(警告ログ)**し、`jomon_written_back_at` は未設定のまま将来の Jomon 追加に備える(`paid` は確定済みとして保持)。 #### Scenario: v1 は送金済みを書き戻す -- **WHEN** v1 で payout が `paid` になる +- **WHEN** v1 で payout が(Stripe 送金で)`paid` になる - **THEN** `PUT .../states/repaid/{trapId}` で「送金済み(repaid_at)」が書き戻され、書き戻し済みが記録される +#### Scenario: v1 は手動振込済みを書き戻す + +- **WHEN** v1 で payout が手動振込で `paid` になる(`stripe_transfer_id` 無し) +- **THEN** `stripe_transfer_id` を要求せず、`PUT .../states/repaid/{trapId}` で settled(repaid_at)が書き戻され、書き戻し済みが記録される + #### Scenario: 書き戻し失敗は再試行され、再送金はしない -- **WHEN** 送金は成功したが Jomon 書き戻しが失敗(v2 未対応含む)し、後で再実行する -- **THEN** 送金は再実行されず、書き戻しのみ再試行される(v2 は対応追加まで失敗し続けるが二重送金は起きない) +- **WHEN** 確定(送金または手動振込)は成功したが Jomon 書き戻しが失敗(v2 未対応含む)し、後で再実行する +- **THEN** 送金も手動確定も再実行されず、書き戻しのみ再試行される(v2 は対応追加まで失敗し続けるが二重支払いは起きない) ### Requirement: バッチの個別分離と failed の再試行ポリシー diff --git a/packages/api/src/jomon/jomon.test.ts b/packages/api/src/jomon/jomon.test.ts index 019db0c..001a79e 100644 --- a/packages/api/src/jomon/jomon.test.ts +++ b/packages/api/src/jomon/jomon.test.ts @@ -252,6 +252,19 @@ describe('JomonV1Client (/api/applications, strict, fail-safe)', () => { await client.writeBackResult('app_1:alice', { status: 'failed', message: 'declined' }) expect(spy).not.toHaveBeenCalled() }) + + it('writes back a MANUAL paid (no stripeTransferId) — still sends repaid_at', async () => { + // A manual bank transfer settles `paid` with no Stripe transfer id; v1 + // write-back only needs `repaid_at`, so the absence of stripeTransferId must + // NOT prevent the repaid write-back. (add-manual-bank-payout D4) + const spy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('', { status: 200 })) + await client.writeBackResult('app_1:alice', { status: 'paid', message: 'bank ref 999' }) + expect(spy).toHaveBeenCalledTimes(1) + const [url, init] = spy.mock.calls[0] as [string, RequestInit] + expect(url).toBe('https://jomon.test/api/applications/app_1/states/repaid/alice') + expect(init.method).toBe('PUT') + expect(JSON.parse(init.body as string)).toMatchObject({ repaid_at: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/) }) + }) }) describe('JomonV2Client (/api/applications, uuid->name, strict)', () => { @@ -305,4 +318,12 @@ describe('JomonV2Client (/api/applications, uuid->name, strict)', () => { await expect(client.writeBackResult('tgt_1', { status: 'paid', stripeTransferId: 'tr_x' })) .rejects.toBeInstanceOf(JomonWriteBackUnsupportedError) }) + + it('manual paid (no stripeTransferId) write-back is also UNSUPPORTED — kept for retry', async () => { + // A manual-bank paid result has no stripeTransferId; v2 still has no write-back + // API, so it throws Unsupported. The orchestration keeps the payout `paid` and + // leaves jomon_written_back_at NULL for retry. (add-manual-bank-payout D4) + await expect(client.writeBackResult('tgt_1', { status: 'paid', message: 'bank ref 999' })) + .rejects.toBeInstanceOf(JomonWriteBackUnsupportedError) + }) }) diff --git a/packages/api/src/payouts/execute.test.ts b/packages/api/src/payouts/execute.test.ts index 6047634..ccf6d16 100644 --- a/packages/api/src/payouts/execute.test.ts +++ b/packages/api/src/payouts/execute.test.ts @@ -8,8 +8,8 @@ import type { StripeClient } from '../stripe/client' import { StubJomonClient } from '../jomon/stub' import { JomonWriteBackUnsupportedError } from '../jomon/http' import type { JomonTransferRequest, JomonWriteBackResult } from '../jomon/types' -import { executePayout, processApprovedPayouts, type PayoutExecuteConfig } from './execute' -import { getPayoutByJomonRef, upsertPayoutByJomonRef } from './store' +import { executePayout, markPayoutManuallyPaid, processApprovedPayouts, type PayoutExecuteConfig } from './execute' +import { getPayoutByJomonRef, settlePayoutManually, upsertPayoutByJomonRef } from './store' /** * DB-backed tests for the payout store + orchestration. They run against the @@ -484,3 +484,307 @@ describe('processApprovedPayouts (orchestration)', () => { expect(badRow?.jomonWrittenBackAt).toBeNull() }) }) + +describe('markPayoutManuallyPaid (manual bank transfer settle)', () => { + /** Seed a payout row at a chosen status without touching Stripe/Jomon. */ + async function seedRow( + ref: string, + status: 'pending' | 'onboarding_waiting' | 'failed' | 'paid' | 'processing', + extra: Partial<{ userId: string | null, stripeTransferId: string | null, jomonWrittenBackAt: Date | null }> = {}, + ): Promise { + refs.push(ref) + await upsertPayoutByJomonRef(db, { jomonRef: ref, amount: 4000, currency: 'jpy' }) + await db.update(schema.payouts) + .set({ status, ...extra }) + .where(eq(schema.payouts.jomonRef, ref)) + } + + it.each(['pending', 'onboarding_waiting', 'failed'] as const)( + 'settles a %s payout as paid (manual_bank) WITHOUT a Stripe transfer and writes back', + async (status) => { + if (!available) { + return + } + const ref = `jmn-manual-${status}-${tag}` + await seedRow(ref, status, { userId: null }) + const jomon = new StubJomonClient() + + // transferOk:false proves no Stripe transfer is attempted (it would throw). + const result = await markPayoutManuallyPaid( + { db, stripe: fakeStripe({ transferOk: false }), jomon }, + { jomonRef: ref, note: 'bank ref 12345', byUserId: null }, + ) + expect(result.outcome).toBe('paid') + expect(result.status).toBe('paid') + + const row = await getPayoutByJomonRef(db, ref) + expect(row?.status).toBe('paid') + expect(row?.payoutMethod).toBe('manual_bank') + expect(row?.manualPaidNote).toBe('bank ref 12345') + expect(row?.manualPaidAt).toBeInstanceOf(Date) + // No Stripe transfer id for a manual payout. + expect(row?.stripeTransferId).toBeNull() + // Settled result written back to Jomon as paid, with the note, no transfer id. + expect(jomon.writeBacks).toEqual([ + { jomonRef: ref, result: { status: 'paid', message: 'bank ref 12345' } }, + ]) + }, + ) + + it('works when userId is null and never relinks a payee', async () => { + if (!available) { + return + } + const ref = `jmn-manual-nulluser-${tag}` + await seedRow(ref, 'onboarding_waiting', { userId: null }) + const jomon = new StubJomonClient() + + const result = await markPayoutManuallyPaid( + { db, stripe: fakeStripe({ transferOk: false }), jomon }, + { jomonRef: ref, byUserId: null }, + ) + expect(result.outcome).toBe('paid') + + const row = await getPayoutByJomonRef(db, ref) + expect(row?.status).toBe('paid') + // userId stays null — manual settle does not invent/relink a payee. + expect(row?.userId).toBeNull() + }) + + it('rejects a paid payout (short-circuits as already_paid, no double pay)', async () => { + if (!available) { + return + } + const ref = `jmn-manual-paid-${tag}` + // Already paid via Stripe, write-back recorded. + await seedRow(ref, 'paid', { stripeTransferId: 'tr_stripe', jomonWrittenBackAt: new Date() }) + const jomon = new StubJomonClient() + + const result = await markPayoutManuallyPaid( + { db, stripe: fakeStripe({ transferOk: false }), jomon }, + { jomonRef: ref, note: 'should not apply' }, + ) + expect(result.outcome).toBe('already_paid') + + const row = await getPayoutByJomonRef(db, ref) + // Untouched: still a Stripe payout, no manual fields, no re-write-back. + expect(row?.payoutMethod).toBe('stripe_connect') + expect(row?.stripeTransferId).toBe('tr_stripe') + expect(row?.manualPaidAt).toBeNull() + expect(jomon.writeBacks).toHaveLength(0) + }) + + it('rejects a processing payout (needs_review, never overwrites an in-flight Stripe run)', async () => { + if (!available) { + return + } + const ref = `jmn-manual-proc-${tag}` + await seedRow(ref, 'processing') + const jomon = new StubJomonClient() + + const result = await markPayoutManuallyPaid( + { db, stripe: fakeStripe({ transferOk: false }), jomon }, + { jomonRef: ref, note: 'x' }, + ) + expect(result.outcome).toBe('needs_review') + expect(result.status).toBe('processing') + + const row = await getPayoutByJomonRef(db, ref) + // Unchanged — the manual path must not touch an in-flight Stripe execution. + expect(row?.status).toBe('processing') + expect(row?.payoutMethod).toBe('stripe_connect') + expect(jomon.writeBacks).toHaveLength(0) + }) + + it('throws NOT_FOUND-style error when the payout row is missing', async () => { + if (!available) { + return + } + const jomon = new StubJomonClient() + await expect(markPayoutManuallyPaid( + { db, stripe: fakeStripe(), jomon }, + { jomonRef: `jmn-manual-missing-${tag}`, note: 'x' }, + )).rejects.toThrow(/payout not found/i) + }) + + it('never double-pays when racing a Stripe claim: the manual settle loses and short-circuits', async () => { + if (!available) { + return + } + const ref = `jmn-manual-race-${tag}` + await seedRow(ref, 'onboarding_waiting', { userId: null }) + const jomon = new StubJomonClient() + + // Simulate the Stripe side winning the claim FIRST: the store's conditional + // UPDATE shares the claimable predicate, so once the row is `paid` the manual + // settle's WHERE no longer matches (affectedRows=0) and it short-circuits. + await db.update(schema.payouts) + .set({ status: 'paid', stripeTransferId: 'tr_stripe_won', jomonWrittenBackAt: new Date() }) + .where(eq(schema.payouts.jomonRef, ref)) + + const result = await markPayoutManuallyPaid( + { db, stripe: fakeStripe({ transferOk: false }), jomon }, + { jomonRef: ref, note: 'late manual' }, + ) + // The manual path read `onboarding_waiting` first, then the store UPDATE lost + // the race; re-read shows `paid` ⇒ already_paid, NOT a second payment. + expect(result.outcome).toBe('already_paid') + + const row = await getPayoutByJomonRef(db, ref) + expect(row?.payoutMethod).toBe('stripe_connect') + expect(row?.stripeTransferId).toBe('tr_stripe_won') + expect(row?.manualPaidAt).toBeNull() + }) + + it('store settlePayoutManually returns false (no-op) for paid/processing rows', async () => { + if (!available) { + return + } + const paidRef = `jmn-store-paid-${tag}` + const procRef = `jmn-store-proc-${tag}` + await seedRow(paidRef, 'paid', { stripeTransferId: 'tr_x' }) + await seedRow(procRef, 'processing') + + expect(await settlePayoutManually(db, { jomonRef: paidRef })).toBe(false) + expect(await settlePayoutManually(db, { jomonRef: procRef })).toBe(false) + + // A claimable row settles (returns true) and records the audit fields. + const okRef = `jmn-store-ok-${tag}` + await seedRow(okRef, 'failed') + expect(await settlePayoutManually(db, { jomonRef: okRef, note: 'n', byUserId: null })).toBe(true) + const okRow = await getPayoutByJomonRef(db, okRef) + expect(okRow?.status).toBe('paid') + expect(okRow?.payoutMethod).toBe('manual_bank') + expect(okRow?.manualPaidNote).toBe('n') + }) + + it('isolates a write-back failure: the payout stays paid and is left for retry', async () => { + if (!available) { + return + } + const ref = `jmn-manual-wbfail-${tag}` + await seedRow(ref, 'pending') + + class FlakyJomon extends StubJomonClient { + override async writeBackResult(): Promise { + throw new Error('jomon write-back boom') + } + } + const jomon = new FlakyJomon() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const result = await markPayoutManuallyPaid( + { db, stripe: fakeStripe({ transferOk: false }), jomon }, + { jomonRef: ref, note: 'wb fail' }, + ) + // Settle succeeded; the write-back failure is isolated (logged, not thrown). + expect(result.outcome).toBe('paid') + expect(warn).toHaveBeenCalled() + warn.mockRestore() + + const row = await getPayoutByJomonRef(db, ref) + expect(row?.status).toBe('paid') + expect(row?.payoutMethod).toBe('manual_bank') + // Write-back unrecorded ⇒ retried later (never re-settled). + expect(row?.jomonWrittenBackAt).toBeNull() + }) + + it('retries ONLY the write-back for an already manual-paid row whose write-back never recorded', async () => { + if (!available) { + return + } + const ref = `jmn-manual-wbretry-${tag}` + // Already manually paid, but write-back not recorded (jomonWrittenBackAt NULL). + await seedRow(ref, 'paid', { jomonWrittenBackAt: null }) + await db.update(schema.payouts) + .set({ payoutMethod: 'manual_bank', manualPaidNote: 'prior note', manualPaidAt: new Date() }) + .where(eq(schema.payouts.jomonRef, ref)) + const jomon = new StubJomonClient() + + const result = await markPayoutManuallyPaid( + { db, stripe: fakeStripe({ transferOk: false }), jomon }, + { jomonRef: ref, note: 'ignored on re-run' }, + ) + expect(result.outcome).toBe('already_paid') + + // Re-attempted write-back exactly once: NO stripeTransferId (manual row), and + // the STORED note is re-sent as `message` (the retry must not drop the note). + expect(jomon.writeBacks).toEqual([ + { jomonRef: ref, result: { status: 'paid', message: 'prior note' } }, + ]) + const row = await getPayoutByJomonRef(db, ref) + expect(row?.jomonWrittenBackAt).toBeInstanceOf(Date) + // The note is NOT overwritten on an already-paid short-circuit. + expect(row?.manualPaidNote).toBe('prior note') + }) + + it('preserves the manual note across a failed-then-retried write-back', async () => { + if (!available) { + return + } + const ref = `jmn-manual-wbnote-${tag}` + await seedRow(ref, 'pending') + + // A stub whose write-back fails on the FIRST call (the initial settle) and + // succeeds on the second (the retry). Records every attempted payload. + class FailFirstJomon extends StubJomonClient { + attempts = 0 + override async writeBackResult(jomonRef: string, result: JomonWriteBackResult): Promise { + this.attempts += 1 + if (this.attempts === 1) { + throw new Error('jomon write-back boom (first attempt)') + } + return super.writeBackResult(jomonRef, result) + } + } + const jomon = new FailFirstJomon() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + // 1. Initial settle: paid succeeds, but the write-back fails (isolated). + const first = await markPayoutManuallyPaid( + { db, stripe: fakeStripe({ transferOk: false }), jomon }, + { jomonRef: ref, note: 'bank ref ABCDEF', byUserId: null }, + ) + expect(first.outcome).toBe('paid') + const afterFirst = await getPayoutByJomonRef(db, ref) + expect(afterFirst?.status).toBe('paid') + expect(afterFirst?.jomonWrittenBackAt).toBeNull() // unrecorded ⇒ retried later + expect(jomon.writeBacks).toHaveLength(0) // first attempt threw, recorded nothing + + // 2. Re-run on the already-paid row: retries ONLY the write-back, re-sending + // the STORED note (proving the retry path does not drop it). + const second = await markPayoutManuallyPaid( + { db, stripe: fakeStripe({ transferOk: false }), jomon }, + { jomonRef: ref, note: 'a different note on re-run' }, + ) + expect(second.outcome).toBe('already_paid') + expect(jomon.writeBacks).toEqual([ + { jomonRef: ref, result: { status: 'paid', message: 'bank ref ABCDEF' } }, + ]) + warn.mockRestore() + + const row = await getPayoutByJomonRef(db, ref) + expect(row?.jomonWrittenBackAt).toBeInstanceOf(Date) + expect(row?.manualPaidNote).toBe('bank ref ABCDEF') + }) + + it('exposes manualPaidBy on the row DTO for the UI', async () => { + if (!available) { + return + } + const ref = `jmn-manual-by-${tag}` + // Seed a real payee so the FK on manual_paid_by is satisfied. + const payee = await makePayee('manualby', 'none') + await seedRow(ref, 'failed') + const jomon = new StubJomonClient() + + await markPayoutManuallyPaid( + { db, stripe: fakeStripe({ transferOk: false }), jomon }, + { jomonRef: ref, note: 'n', byUserId: payee.id }, + ) + const row = await getPayoutByJomonRef(db, ref) + expect(row?.payoutMethod).toBe('manual_bank') + expect(row?.manualPaidBy).toBe(payee.id) + expect(row?.manualPaidAt).toBeInstanceOf(Date) + }) +}) diff --git a/packages/api/src/payouts/execute.ts b/packages/api/src/payouts/execute.ts index 4d4ce21..d6180c4 100644 --- a/packages/api/src/payouts/execute.ts +++ b/packages/api/src/payouts/execute.ts @@ -9,6 +9,7 @@ import { nextPayoutStatus, type PayoutStatus } from './status' import { claimPayoutForExecution, getPayoutByJomonRef, + settlePayoutManually, setPayoutJomonWrittenBackAt, setPayoutStatus, setPayoutUserId, @@ -229,6 +230,92 @@ export async function executePayout( return advancePayout(deps, config, req, { allowFailedRetry: true }) } +/** Inputs to record a manual bank transfer as a settled `paid` payout. */ +export interface MarkPayoutManuallyPaidInput { + jomonRef: string + /** Free-text reference note for the manual transfer (e.g. bank ref number). */ + note?: string + /** The accountant (`users.id`) recording it — resolved server-side, may be null. */ + byUserId?: string | null +} + +/** + * Record a MANUAL bank transfer as a settled `paid` payout — WITHOUT issuing a + * Stripe transfer. For a payee who cannot complete Connect onboarding, the + * accountant pays them by hand and records that fact here. Unlike + * {@link executePayout}/{@link processApprovedPayouts}, this does NOT pull Jomon's + * approved list: it operates on the LOCAL payout row only (the request may no + * longer be approved, or the payee may have no connected account — that is the + * whole point). (add-manual-bank-payout D3; spec §手動振込での paid 確定) + * + * Atomic, idempotent, and race-safe against the Stripe execution claim: + * 1. Load the local row; throw if missing (router maps to NOT_FOUND). + * 2. Already `paid` ⇒ short-circuit (retry ONLY the write-back if not yet done). + * `processing` ⇒ `needs_review` — never overwrite an in-flight Stripe run. + * 3. The store's single conditional UPDATE settles it ONLY from a claimable + * state, sharing `claimPayoutForExecution`'s predicate. If it loses the race + * (`affectedRows === 0`), re-read: now `paid` ⇒ `already_paid` (+ write-back + * retry), else `needs_review`. Never double-pay. + * 4. On win, write the settled result back to Jomon via {@link tryWriteBack} + * with `{ status: 'paid', message: note }` — NO `stripeTransferId` (a manual + * payout has none; v1 write-back does not require it). + * + * `user_id` immutability is preserved: this never sets or relinks the payee + * (null is allowed — onboarding may never have started). (spec §本人特定の不変条件) + */ +export async function markPayoutManuallyPaid( + deps: PayoutDeps, + input: MarkPayoutManuallyPaidInput, +): Promise { + const { jomonRef, note, byUserId } = input + + // 1. Operate on the LOCAL row only — do NOT pull Jomon's approved list. + const row = await getPayoutByJomonRef(deps.db, jomonRef) + if (!row) { + throw new Error(`payout not found for jomon_ref: ${jomonRef}`) + } + + // 2. Terminal `paid` ⇒ never re-settle. Retry ONLY the write-back if it never + // recorded (decoupled from settlement, like the Stripe path). + if (row.status === 'paid') { + if (row.jomonWrittenBackAt === null) { + await reattemptWriteBack(deps, row) + } + return { jomonRef, outcome: 'already_paid', status: 'paid' } + } + // `processing` ⇒ a Stripe execution is mid-flight; do NOT overwrite it. Leave + // it to the winner / an admin (stale `processing` after a crash = 要対応). + if (row.status === 'processing') { + return { jomonRef, outcome: 'needs_review', status: 'processing' } + } + + // 3. Atomically settle from a claimable state. Shares the Stripe claim's + // predicate, so a concurrent Stripe execution and this manual settle can + // never both win — the loser short-circuits without paying twice. + const won = await settlePayoutManually(deps.db, { jomonRef, note, byUserId }) + if (!won) { + const fresh = await getPayoutByJomonRef(deps.db, jomonRef) + // The other side won and already paid ⇒ short-circuit (retry write-back if needed). + if (fresh?.status === 'paid') { + if (fresh.jomonWrittenBackAt === null) { + await reattemptWriteBack(deps, fresh) + } + return { jomonRef, outcome: 'already_paid', status: 'paid' } + } + // Otherwise mid-flight (`processing`) or unexpected ⇒ leave it. Never pay. + return { jomonRef, outcome: 'needs_review', status: fresh?.status ?? row.status } + } + + // 4. Won the settle — write the (manual) settled result back to Jomon. No + // stripeTransferId: a manual payout has none, and v1 write-back does not + // require it (it only sends `repaid_at`). The note rides along as `message`. + // A write-back failure (incl. v2 unsupported) is isolated by tryWriteBack: + // the payout stays `paid`, the write-back is retried later, never re-paid. + await tryWriteBack(deps, jomonRef, { status: 'paid', message: note }) + + return { jomonRef, outcome: 'paid', status: 'paid' } +} + /** Options that gate how `advancePayout` treats certain statuses. */ interface AdvanceOptions { /** @@ -400,8 +487,20 @@ async function advancePayout( * write-back has not yet been recorded. Never re-transfers. A failure (incl. v2 * unsupported) is isolated by {@link tryWriteBack}: it stays unrecorded for a * future retry. (Codex hardening: §retryable write-back avoids re-transfer) + * + * Reconstructs the write-back result from the row so a retry carries the SAME + * payload as the original settle: a Stripe payout re-sends its `stripeTransferId`; + * a `manual_bank` payout has no transfer id but re-sends its `manual_paid_note` + * as `message` (so the reference note is not lost on retry). (add-manual-bank-payout D4) */ async function reattemptWriteBack(deps: PayoutDeps, row: PayoutRow): Promise { + if (row.payoutMethod === 'manual_bank') { + await tryWriteBack(deps, row.jomonRef, { + status: 'paid', + message: row.manualPaidNote ?? undefined, + }) + return + } await tryWriteBack(deps, row.jomonRef, { status: 'paid', stripeTransferId: row.stripeTransferId ?? undefined, diff --git a/packages/api/src/payouts/store.ts b/packages/api/src/payouts/store.ts index cc2a730..6ba7798 100644 --- a/packages/api/src/payouts/store.ts +++ b/packages/api/src/payouts/store.ts @@ -3,6 +3,9 @@ import { and, eq, inArray } from 'drizzle-orm' import { schema, type Database } from '@checkin/db' import type { PayoutStatus } from './status' +/** How a payout was settled: a Stripe Connect transfer or a manual bank transfer. */ +export type PayoutMethod = 'stripe_connect' | 'manual_bank' + /** A payout row as the domain reads it (Stripe/Jomon-type-free). */ export interface PayoutRow { id: string @@ -14,6 +17,14 @@ export interface PayoutRow { stripeTransferId: string | null /** When the settled result was written back to Jomon, or null if not yet. */ jomonWrittenBackAt: Date | null + /** How this payout was settled (`stripe_connect` default / `manual_bank`). */ + payoutMethod: PayoutMethod + /** Manual bank transfer reference note (NULL for Stripe payouts). */ + manualPaidNote: string | null + /** When a `manual_bank` payout was confirmed paid (NULL for Stripe payouts). */ + manualPaidAt: Date | null + /** The accountant (`users.id`) who recorded the manual transfer, or null. */ + manualPaidBy: string | null } const PAYOUT_COLUMNS = { @@ -25,6 +36,10 @@ const PAYOUT_COLUMNS = { status: schema.payouts.status, stripeTransferId: schema.payouts.stripeTransferId, jomonWrittenBackAt: schema.payouts.jomonWrittenBackAt, + payoutMethod: schema.payouts.payoutMethod, + manualPaidNote: schema.payouts.manualPaidNote, + manualPaidAt: schema.payouts.manualPaidAt, + manualPaidBy: schema.payouts.manualPaidBy, } as const /** Statuses from which a payout may be claimed for execution (not `paid`/`processing`). */ @@ -156,6 +171,61 @@ export async function claimPayoutForExecution( return claimed === 1 } +/** Inputs to the atomic store-level manual-paid settle. */ +export interface SettlePayoutManuallyInput { + jomonRef: string + /** Free-text reference note for the manual transfer (e.g. bank ref number). */ + note?: string + /** The accountant (`users.id`) recording it — resolved server-side, may be null. */ + byUserId?: string | null +} + +/** + * Atomically settle a payout as `paid` via a MANUAL bank transfer (no Stripe + * transfer): a single conditional UPDATE flips it to `paid` and records the audit + * fields ONLY if it is currently in a claimable state — i.e. NOT already `paid` + * or `processing`. + * + * UPDATE payouts + * SET status='paid', payout_method='manual_bank', manual_paid_at=NOW(), + * manual_paid_note=?, manual_paid_by=? + * WHERE jomon_ref=? AND status IN ('pending','onboarding_waiting','failed') + * + * It deliberately shares the SAME `CLAIMABLE_STATUSES` predicate as + * {@link claimPayoutForExecution}, so the manual settle and the Stripe execution + * claim stay in lockstep: if both race for the same `jomon_ref`, only the one + * whose WHERE matches first wins (`affectedRows === 1`) and the loser short- + * circuits with `affectedRows === 0` — never a double payout. `stripe_transfer_id` + * is NOT touched (a manual payout has none). The orchestration wrapper + * `markPayoutManuallyPaid` (execute.ts) adds the Jomon write-back. (add-manual-bank-payout D2) + */ +export async function settlePayoutManually( + db: Database, + input: SettlePayoutManuallyInput, +): Promise { + const result = await db + .update(schema.payouts) + .set({ + status: 'paid', + payoutMethod: 'manual_bank', + manualPaidAt: new Date(), + manualPaidNote: input.note ?? null, + manualPaidBy: input.byUserId ?? null, + }) + .where(and( + eq(schema.payouts.jomonRef, input.jomonRef), + inArray(schema.payouts.status, CLAIMABLE_STATUSES), + )) + + // Same affectedRows extraction as `claimPayoutForExecution`: 1 ⇒ we settled it, + // 0 ⇒ it was already paid/processing (claimable WHERE matched nothing). + const affected = (result as unknown as { affectedRows?: number })?.affectedRows ?? 0 + const claimed = Array.isArray(result) + ? ((result[0] as { affectedRows?: number })?.affectedRows ?? 0) + : affected + return claimed === 1 +} + /** Mark a payout's Jomon write-back as completed (records the timestamp). */ export async function setPayoutJomonWrittenBackAt( db: Database, diff --git a/packages/api/src/router.ts b/packages/api/src/router.ts index 02dbf1d..98e66d0 100644 --- a/packages/api/src/router.ts +++ b/packages/api/src/router.ts @@ -7,6 +7,7 @@ import { sanitizeRedirect, createEmailVerification, getUserById, + getUserByTraqId, getOrCreateUserByMailHash, linkTraqId, setPayoutOnboardingStatus, @@ -31,7 +32,9 @@ import { } from './ledger' import { executePayout, + getPayoutByJomonRef, listPayouts, + markPayoutManuallyPaid, nextOnboardingStatus, processApprovedPayouts, type PayoutExecuteConfig, @@ -447,6 +450,44 @@ export const appRouter = { input.jomonRef, ) }), + + /** + * Record a MANUAL bank transfer as a settled `paid` payout for a payee who + * cannot complete Connect onboarding — WITHOUT issuing a Stripe transfer. + * Admin only + `assertCsrf` (it settles the payout and writes back to Jomon). + * Only `pending` / `onboarding_waiting` / `failed` rows are settled; `paid` / + * `processing` short-circuit. The actor (`manual_paid_by`) is resolved from + * the SERVER SESSION (never client input) to prevent spoofing: prefer the + * session's `userId`, else resolve the session's `traqId` to a `users.id` + * (traQ-only admin whose userId isn't exposed); null when unresolvable. + * (add-manual-bank-payout D5; spec §手動振込での paid 確定) + */ + markManuallyPaid: adminProc + .input(z.object({ + jomonRef: z.string().min(1), + note: z.string().max(255).optional(), + })) + .handler(async ({ input, context }) => { + context.assertCsrf() + + // Translate a missing row to a precise NOT_FOUND at the route boundary + // (mirrors `createOnboardingLink`'s getUserById → ORPCError pattern), so a + // bad jomonRef is a 404, not a 500 from the domain's plain throw. The + // orchestration still re-reads atomically and is the source of truth. + const row = await getPayoutByJomonRef(context.db, input.jomonRef) + if (!row) { + throw new ORPCError('NOT_FOUND', { message: 'payout not found' }) + } + + const byUserId = context.session?.userId + ?? (context.session?.traqId + ? (await getUserByTraqId(context.db, context.session.traqId))?.id ?? null + : null) + return markPayoutManuallyPaid( + { db: context.db, stripe: context.stripe, jomon: context.jomon }, + { jomonRef: input.jomonRef, note: input.note, byUserId }, + ) + }), }, } diff --git a/packages/db/drizzle/0009_unique_shinobi_shaw.sql b/packages/db/drizzle/0009_unique_shinobi_shaw.sql new file mode 100644 index 0000000..9222871 --- /dev/null +++ b/packages/db/drizzle/0009_unique_shinobi_shaw.sql @@ -0,0 +1,5 @@ +ALTER TABLE `payouts` ADD `payout_method` enum('stripe_connect','manual_bank') DEFAULT 'stripe_connect' NOT NULL;--> statement-breakpoint +ALTER TABLE `payouts` ADD `manual_paid_at` timestamp;--> statement-breakpoint +ALTER TABLE `payouts` ADD `manual_paid_note` varchar(255);--> statement-breakpoint +ALTER TABLE `payouts` ADD `manual_paid_by` varchar(36);--> statement-breakpoint +ALTER TABLE `payouts` ADD CONSTRAINT `payouts_manual_paid_by_users_id_fk` FOREIGN KEY (`manual_paid_by`) REFERENCES `users`(`id`) ON DELETE no action ON UPDATE no action; \ No newline at end of file diff --git a/packages/db/drizzle/meta/0009_snapshot.json b/packages/db/drizzle/meta/0009_snapshot.json new file mode 100644 index 0000000..e550a88 --- /dev/null +++ b/packages/db/drizzle/meta/0009_snapshot.json @@ -0,0 +1,598 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "79824b96-5b54-4a9d-b269-ea89bb00a0ab", + "prevId": "74654375-27ef-412f-b9bd-c802c9100a3f", + "tables": { + "email_verifications": { + "name": "email_verifications", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mail_hash": { + "name": "mail_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect": { + "name": "redirect", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "email_verifications_id": { + "name": "email_verifications_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "email_verifications_token_hash_unique": { + "name": "email_verifications_token_hash_unique", + "columns": [ + "token_hash" + ] + } + }, + "checkConstraint": {} + }, + "membership_slots": { + "name": "membership_slots", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "activity_year": { + "name": "activity_year", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "half": { + "name": "half", + "type": "enum('zenki','kouki')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "charge_group": { + "name": "charge_group", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('open','paid')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "membership_slots_user_id_users_id_fk": { + "name": "membership_slots_user_id_users_id_fk", + "tableFrom": "membership_slots", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "membership_slots_id": { + "name": "membership_slots_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "membership_slots_user_year_half_uq": { + "name": "membership_slots_user_year_half_uq", + "columns": [ + "user_id", + "activity_year", + "half" + ] + } + }, + "checkConstraint": {} + }, + "payouts": { + "name": "payouts", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "jomon_ref": { + "name": "jomon_ref", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "amount": { + "name": "amount", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "currency": { + "name": "currency", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('pending','onboarding_waiting','processing','paid','failed')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "stripe_transfer_id": { + "name": "stripe_transfer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payout_method": { + "name": "payout_method", + "type": "enum('stripe_connect','manual_bank')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'stripe_connect'" + }, + "manual_paid_at": { + "name": "manual_paid_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "manual_paid_note": { + "name": "manual_paid_note", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "manual_paid_by": { + "name": "manual_paid_by", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "jomon_written_back_at": { + "name": "jomon_written_back_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "payouts_user_id_users_id_fk": { + "name": "payouts_user_id_users_id_fk", + "tableFrom": "payouts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "payouts_manual_paid_by_users_id_fk": { + "name": "payouts_manual_paid_by_users_id_fk", + "tableFrom": "payouts", + "tableTo": "users", + "columnsFrom": [ + "manual_paid_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "payouts_id": { + "name": "payouts_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "payouts_jomon_ref_unique": { + "name": "payouts_jomon_ref_unique", + "columns": [ + "jomon_ref" + ] + } + }, + "checkConstraint": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id_hash": { + "name": "id_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "traq_id": { + "name": "traq_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sessions_id": { + "name": "sessions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "sessions_id_hash_unique": { + "name": "sessions_id_hash_unique", + "columns": [ + "id_hash" + ] + } + }, + "checkConstraint": {} + }, + "stripe_events": { + "name": "stripe_events", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "received_at": { + "name": "received_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "stripe_events_id": { + "name": "stripe_events_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "stripe_events_event_id_unique": { + "name": "stripe_events_event_id_unique", + "columns": [ + "event_id" + ] + } + }, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mail_hash": { + "name": "mail_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "traq_id": { + "name": "traq_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripe_connected_account_id": { + "name": "stripe_connected_account_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payout_onboarding_status": { + "name": "payout_onboarding_status", + "type": "enum('none','requested','done')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'none'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "users_mail_hash_unique": { + "name": "users_mail_hash_unique", + "columns": [ + "mail_hash" + ] + }, + "users_traq_id_unique": { + "name": "users_traq_id_unique", + "columns": [ + "traq_id" + ] + }, + "users_stripe_connected_account_id_unique": { + "name": "users_stripe_connected_account_id_unique", + "columns": [ + "stripe_connected_account_id" + ] + } + }, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 4b036e8..70d6d4d 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1782312508235, "tag": "0008_sticky_shiva", "breakpoints": true + }, + { + "idx": 9, + "version": "5", + "when": 1782344833815, + "tag": "0009_unique_shinobi_shaw", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 4f33079..9313404 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -133,6 +133,24 @@ export const payouts = mysqlTable('payouts', { .default('pending'), // Stripe Transfer id, set once the payout succeeds (status `paid`). stripeTransferId: varchar('stripe_transfer_id', { length: 255 }), + // How this payout was settled. `stripe_connect` is the default Stripe Connect + // transfer path (existing rows / existing flow are unchanged). `manual_bank` + // marks a payout the accountant settled by a manual bank transfer when the + // payee cannot complete Connect onboarding — no Stripe transfer is issued, so + // `stripe_transfer_id` stays NULL. (add-manual-bank-payout D1) + payoutMethod: mysqlEnum('payout_method', ['stripe_connect', 'manual_bank']) + .notNull() + .default('stripe_connect'), + // When a `manual_bank` payout was confirmed `paid` (NULL for Stripe payouts). + manualPaidAt: timestamp('manual_paid_at'), + // Free-text reference note for a manual bank transfer (e.g. the bank transfer + // reference number). NULL for Stripe payouts. (add-manual-bank-payout D1) + manualPaidNote: varchar('manual_paid_note', { length: 255 }), + // The accountant (`users.id`) who recorded the manual transfer. Resolved from + // the server session, never client input. NULL when it could not be resolved + // (traQ-only admin without a linked user) — the audit then rests on + // `manual_paid_at` + `manual_paid_note`. (add-manual-bank-payout D1) + manualPaidBy: varchar('manual_paid_by', { length: 36 }).references(() => users.id), // When the settled result was successfully written back to Jomon (NULL until // then). Decouples the write-back from the transfer: a transfer can succeed // (`paid`) while the write-back fails, so a `paid` re-run with a NULL value From 2ca60c954c9271bac423c2b24d59c6369b8aa4ec Mon Sep 17 00:00:00 2001 From: rei <107461411+reiroop@users.noreply.github.com> Date: Fri, 18 Sep 2026 10:13:20 +0900 Subject: [PATCH 26/30] =?UTF-8?q?test(api):=20=E5=A5=91=E7=B4=84=E3=81=A8?= =?UTF-8?q?=E8=AA=8D=E5=8F=AF=E3=81=AE=E3=83=86=E3=82=B9=E3=83=88=E3=81=8C?= =?UTF-8?q?=E4=B8=BB=E5=BC=B5=E3=82=92=E5=AE=9F=E9=9A=9B=E3=81=AB=E5=8C=BA?= =?UTF-8?q?=E5=88=A5=E3=81=99=E3=82=8B=E3=82=88=E3=81=86=E3=81=AB=E3=81=99?= =?UTF-8?q?=E3=82=8B=20(U8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3 つのテストが、名前で述べている主張の一部を測っていなかった。 - capability-routers.test.ts: 移した 13 プロシージャの検査が、同じファイル内の リテラル配列を数えるだけだったので、4 つの capability のルーターに 14 個目が 現れても落ちなかった。ルーターのキーから経路の一覧を作り、移した 13 と集合として 突き合わせる。減った場合と増えた場合の両方で落ちる。 - contract-input-parity.test.ts: 「移設前を zod 3、契約側を zod 4 で組んで比べる」 ことに依拠しながら、packages/api が解決する zod が major 3 であることを 見ていなかった。上げると zod 4 どうしの自己比較に静かに変わり、受理範囲が 変わっても差が出ない。前提そのものを検査に載せる。 - orpc.test.ts: 認可が落ちる側の requireUser と requireAdmin が同じコードで 投げていたので、ビルダーが 2 つを取り違えても同じ結果になっていた。落とす側の コードを分け、どちらが呼ばれたかを観測できるようにする。 Co-Authored-By: Claude Opus 5 (1M context) --- packages/api/src/capability-routers.test.ts | 17 +++++++++-- .../api/src/contract-input-parity.test.ts | 28 +++++++++++++++++++ packages/api/src/orpc.test.ts | 25 ++++++++++++----- 3 files changed, 61 insertions(+), 9 deletions(-) diff --git a/packages/api/src/capability-routers.test.ts b/packages/api/src/capability-routers.test.ts index c179f44..e838c2f 100644 --- a/packages/api/src/capability-routers.test.ts +++ b/packages/api/src/capability-routers.test.ts @@ -28,6 +28,16 @@ const capabilityRouters = { payouts: payoutsRouter, } +/** + * 4 つのルーターが実際に持つ経路。ルーターのキーから作るので、どれかに 14 個目の + * プロシージャが現れれば、この配列が増えて下の比較が落ちる。移した 13 と突き合わせる + * 対象をリテラルの列挙ではなく実物にするのは、列挙どうしを比べても増えた側を検出 + * できないためである。 + */ +const actualPaths = Object.entries(capabilityRouters).flatMap( + ([capability, router]) => Object.keys(router).map(procedure => `/${capability}/${procedure}`), +) + /** 移す前の形。auth 側 `router.ts` が持っていた `health.check` を足したもの。 */ const withHealth = { ...capabilityRouters, @@ -66,7 +76,7 @@ describe('health.check を移さなかったこと', () => { expect(await matches(withHealth, '/health/check')).toBe(true) }) - it('移した 13 プロシージャの経路は一致する', async () => { + it('4 つのルーターが持つ経路は、移した 13 プロシージャと過不足なく一致する', async () => { const moved = [ '/auth/requestEmailVerification', '/auth/me', @@ -82,7 +92,10 @@ describe('health.check を移さなかったこと', () => { '/payouts/execute', '/payouts/markManuallyPaid', ] - expect(moved).toHaveLength(13) + // 実物のキーと突き合わせる。これで「移していないものが増えていない」ことも見る。 + expect([...actualPaths].sort()).toEqual([...moved].sort()) + expect(actualPaths).toHaveLength(13) + // 名前が一致するだけでなく、RPC の経路としても一致することを見る。 for (const path of moved) { expect(await matches(capabilityRouters, path), path).toBe(true) } diff --git a/packages/api/src/contract-input-parity.test.ts b/packages/api/src/contract-input-parity.test.ts index 637ce02..0f14a05 100644 --- a/packages/api/src/contract-input-parity.test.ts +++ b/packages/api/src/contract-input-parity.test.ts @@ -1,3 +1,4 @@ +import { createRequire } from 'node:module' import { describe, expect, it } from 'vitest' import { call, implement } from '@orpc/server' import { z } from 'zod' @@ -20,6 +21,33 @@ import { contract } from '@checkin/api-contract' * 意図して変えた差は {@link EXPECTED_DIFFERENCES} に列挙する。ここに無い差が出たら失敗する。 */ +// --------------------------------------------------------------------------- +// このテストが依拠している前提 +// --------------------------------------------------------------------------- + +/** + * このパッケージが解決する zod のメジャーバージョン。 + * + * この比較は、`packages/api` が zod 3 を、`@checkin/api-contract` が zod 4 を解決することに + * 依拠している。`packages/api` を zod 4 へ上げると、上の `legacySchemas` も契約側と同じ版で + * 組まれることになり、比較が zod 4 どうしの自己比較に静かに変わる。そのとき受理範囲が + * 変わっていても差は出ないので、テストは緑のまま主張を測らなくなる。前提そのものを + * 検査に載せて、上げた日に落ちるようにする。 + */ +function zodMajor(): number { + const pkg: unknown = createRequire(import.meta.url)('zod/package.json') + if (typeof pkg !== 'object' || pkg === null || !('version' in pkg) || typeof pkg.version !== 'string') { + return Number.NaN + } + return Number(pkg.version.split('.')[0]) +} + +describe('比較が依拠している前提', () => { + it('packages/api が解決する zod は major 3 である', () => { + expect(zodMajor()).toBe(3) + }) +}) + // --------------------------------------------------------------------------- // 移設前のスキーマ(auth ブランチの packages/api/src/router.ts の `.input(...)`) // --------------------------------------------------------------------------- diff --git a/packages/api/src/orpc.test.ts b/packages/api/src/orpc.test.ts index 877319f..84cc9c9 100644 --- a/packages/api/src/orpc.test.ts +++ b/packages/api/src/orpc.test.ts @@ -15,15 +15,24 @@ import { adminProc, userProc, type Context } from './orpc' * 「この入力はそもそも検証を通る」という別の理由でないことを示せない。 */ -/** 認可だけを差し替えた最小の Context。ハンドラは呼ばれないので他の依存は供給しない。 */ +/** + * 認可だけを差し替えた最小の Context。ハンドラは呼ばれないので他の依存は供給しない。 + * + * 落とす側の 2 つのヘルパは**違うコード**で投げる。同じコードにすると、ビルダーが + * `requireUser` と `requireAdmin` を取り違えていても同じ結果になり、取り違えを観測できない。 + * 実物の `requireAdmin` が投げるコードとは独立に、ここではどちらが呼ばれたかの目印として使う。 + */ function contextWith(auth: { pass: boolean }): Context { - const deny = (): never => { + const denyUser = (): never => { throw new ORPCError('UNAUTHORIZED', { message: 'login required' }) } + const denyAdmin = (): never => { + throw new ORPCError('FORBIDDEN', { message: 'accountant only' }) + } // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- 認可の検査だけを見るので、ハンドラが動くための依存は供給しない return { - requireUser: auth.pass ? () => ({ userId: 'u1', mailHash: 'h1' }) : deny, - requireAdmin: auth.pass ? () => ({ traqId: 'alice' }) : deny, + requireUser: auth.pass ? () => ({ userId: 'u1', mailHash: 'h1' }) : denyUser, + requireAdmin: auth.pass ? () => ({ traqId: 'alice' }) : denyAdmin, assertCsrf: () => {}, } as unknown as Context } @@ -51,7 +60,7 @@ describe('認可の検査は入力検証より前に走る', () => { throw new Error('handler must not be reached') }) - it('userProc: 認可が落ちる呼び出しは、入力が不正でも UNAUTHORIZED になる', async () => { + it('userProc: 認可が落ちる呼び出しは、入力が不正でも requireUser が投げたコードになる', async () => { const code = await codeOf(() => // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- 契約の入力型で弾かれる入力を渡すため、呼び出し時だけ型を外す call(userHandler, INVALID_INPUT as never, { context: contextWith({ pass: false }) }), @@ -67,12 +76,14 @@ describe('認可の検査は入力検証より前に走る', () => { expect(code).toBe('BAD_REQUEST') }) - it('adminProc: 認可が落ちる呼び出しは、入力が不正でも UNAUTHORIZED になる', async () => { + it('adminProc: 認可が落ちる呼び出しは、入力が不正でも requireAdmin が投げたコードになる', async () => { const code = await codeOf(() => // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- 同上 call(adminHandler, INVALID_INPUT as never, { context: contextWith({ pass: false }) }), ) - expect(code).toBe('UNAUTHORIZED') + // FORBIDDEN は contextWith が requireAdmin に与えた目印。UNAUTHORIZED なら requireUser が + // 呼ばれたことになり、ビルダーが取り違えている。 + expect(code).toBe('FORBIDDEN') }) it('adminProc: 認可が通る呼び出しでは、同じ入力が BAD_REQUEST になる', async () => { From 8846183aacc7040d8e2ccaa03d6f444bf69dbc1a Mon Sep 17 00:00:00 2001 From: rei <107461411+reiroop@users.noreply.github.com> Date: Fri, 18 Sep 2026 10:13:51 +0900 Subject: [PATCH 27/30] =?UTF-8?q?docs(api):=20drizzle=20=E3=81=8C=E9=87=8D?= =?UTF-8?q?=E8=A4=87=E3=82=AD=E3=83=BC=E3=82=A8=E3=83=A9=E3=83=BC=E3=82=92?= =?UTF-8?q?=E5=8C=85=E3=82=80=E5=BD=A2=E3=81=AE=E5=AE=9F=E6=B8=AC=E3=82=92?= =?UTF-8?q?=E8=A8=98=E9=8C=B2=E3=81=99=E3=82=8B=20(U8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isDuplicateKeyError が cause の連鎖を辿る根拠は、これまで「drizzle は mysql2 の エラーを包み、元のものを cause に持つ」という記述だけで、測ったものではなかった。 MariaDB 11 に移行を適用し、membership_slots_user_year_half_uq に違反する行を drizzle-orm 0.45.2 + mysql2 3.23.2 で 2 回 insert して測った。投げられるのは DrizzleQueryError で、それ自身は code も errno も持たず、1 段下の cause が mysql2 の Error (code: ER_DUP_ENTRY, errno: 1062) である。したがって最上位だけを 読む実装は重複キーを検出できない。 共有版のコメントを測った形に書き直し、テストのフィクスチャも測った形に合わせる。 stripe/connect.ts と webhook/events.ts の浅い 2 実装は、統一すると auth 由来の コードの振る舞いが変わるので触らない。影響と統一の判断は後続 issue に残す。 Co-Authored-By: Claude Opus 5 (1M context) --- packages/api/src/mysql-result.test.ts | 9 ++++++++- packages/api/src/mysql-result.ts | 14 +++++++++++--- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/api/src/mysql-result.test.ts b/packages/api/src/mysql-result.test.ts index a85de1d..d9c9148 100644 --- a/packages/api/src/mysql-result.test.ts +++ b/packages/api/src/mysql-result.test.ts @@ -42,8 +42,15 @@ describe('isDuplicateKeyError', () => { }) it('drizzle が包んだ形を cause から見つける', () => { + // 2026-09-18 に実測した形を写したもの。包んだ側は code も errno も持たず、 + // 1 段下の cause が mysql2 のエラーである(詳細は `mysql-result.ts` のコメント)。 expect(isDuplicateKeyError( - new Error('Failed query: insert ...', { cause: { code: 'ER_DUP_ENTRY' } }), + new Error('Failed query: insert into `membership_slots` ...', { + cause: Object.assign(new Error('Duplicate entry ... for key \'membership_slots_user_year_half_uq\''), { + code: 'ER_DUP_ENTRY', + errno: 1062, + }), + }), )).toBe(true) }) diff --git a/packages/api/src/mysql-result.ts b/packages/api/src/mysql-result.ts index c7b3fd1..22fa433 100644 --- a/packages/api/src/mysql-result.ts +++ b/packages/api/src/mysql-result.ts @@ -29,9 +29,17 @@ export function affectedRowCount(result: unknown): number { /** * Whether an error is a MySQL/MariaDB duplicate-key error (ER_DUP_ENTRY = 1062). * - * Walks the `cause` chain because drizzle wraps the mysql2 error ("Failed query: - * ...") and carries the original (with `code`/`errno`) as `.cause`. The walk is - * bounded at 5 links so a self-referential chain cannot loop forever. + * Walks the `cause` chain because drizzle wraps the mysql2 error and carries the + * original (with `code`/`errno`) as `.cause`. Measured on 2026-09-18 with + * drizzle-orm 0.45.2 + mysql2 3.23.2 against MariaDB 11, by inserting a row that + * violates `membership_slots_user_year_half_uq` twice: the thrown value is a + * `DrizzleQueryError` ("Failed query: insert into `membership_slots` ...") that + * carries NEITHER `code` NOR `errno`, and its `.cause` (one link down, with no + * further `cause`) is the mysql2 `Error` with `code: 'ER_DUP_ENTRY'` and + * `errno: 1062`. So reading only the top level never detects a duplicate here. + * The walk is bounded at 5 links — more than the one link measured, so an added + * wrapper still resolves — and the bound stops a self-referential chain from + * looping forever. */ export function isDuplicateKeyError(err: unknown): boolean { let cur: unknown = err From 99bad686e4f3ad336fa8039dcd30b2f431f299a3 Mon Sep 17 00:00:00 2001 From: rei <107461411+reiroop@users.noreply.github.com> Date: Fri, 18 Sep 2026 10:27:12 +0900 Subject: [PATCH 28/30] =?UTF-8?q?chore(=E4=BE=9D=E5=AD=98=E9=96=A2?= =?UTF-8?q?=E4=BF=82):=20packages/api=20=E3=81=AE=20zod=20=E3=82=92=204=20?= =?UTF-8?q?=E7=B3=BB=E3=81=B8=E4=B8=8A=E3=81=92=E3=81=A6=E3=83=A2=E3=83=8E?= =?UTF-8?q?=E3=83=AC=E3=83=9D=E3=81=AE=E7=89=88=E3=82=92=204.4.3=20?= =?UTF-8?q?=E3=81=AB=E6=8F=83=E3=81=88=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 統合後のツリーは `packages/api` が zod 3(`^3.24.0`、解決版 3.25.76)、`packages/api-contract` が zod 4(`^4.4.3`)を宣言していて、1 つのモノレポに zod のメジャーが 2 つ入っていた。 `origin/main` の `packages/api/package.json` に zod の宣言が無く、auth ブランチの `^3.24.0` が コンフリクトせずそのまま通ったためである。承認済みの計画の D9 は「依存は main 側の新しい版を 採る」と定め、メジャーが動く 7 つに zod 3→4 を挙げている。同居を許す判断はこの D9 を覆すもの だったので撤回し、D9 のとおり `packages/api` の宣言を `^4.4.3` に上げた。`pnpm-lock.yaml` から zod 3.25.76 の項目が消え、リポジトリが解決する zod は 4.4.3 だけになった。 `packages/api` で zod を import する実装は `src/jomon/http.ts` だけで、同ファイルが使う `z.object`・`z.string().trim().min()`・`z.number().int().positive()`・`z.union`・`z.array`・ `z.unknown()`・`.transform()`・`.nullish()`・`.safeParse` は zod 4.4.3 でそのまま通るので、実装の 修正は要らなかった。上げた結果として変わるものは 2 つある。`z.number().int().positive()` が `2**53` を拒否するようになること(zod 4 の `int()` は `safeint` で `Number.MAX_SAFE_INTEGER` を 超える整数を受理しない。Jomon の金額に対してはこの拒否が正しい挙動である)と、`error.message` の文字列の形が変わること(zod 3.25.76 は `"code":"too_small","type":"string"`、zod 4.4.3 は `"origin":"string","code":"too_small"`)で、後者は `jomon/http.ts` が例外の文面へ埋めているので 例外文の文字列が変わる。スキーマが受理・拒否する値の集合は、この `2**53` の 1 件を除いて 変わらない。 `src/contract-input-parity.test.ts` は削除した。このテストは移設前のスキーマを `packages/api` の zod 3 で組み直し、契約側の zod 4 と受理範囲を比べるもので、`packages/api` も 4 になると zod 4 どうしの自己比較になり、測っていた主張が消える。このテストが測った結果(移設で受理範囲が 変わったのは `payments` の 2 つの一覧の 7 入力だけで、それは `params.ts` の `pagination` に 揃えた意図した変更である)は PR-2 の本文に載せる。 Co-Authored-By: Claude Opus 5 (1M context) --- packages/api/package.json | 2 +- .../api/src/contract-input-parity.test.ts | 365 ------------------ pnpm-lock.yaml | 9 +- 3 files changed, 3 insertions(+), 373 deletions(-) delete mode 100644 packages/api/src/contract-input-parity.test.ts diff --git a/packages/api/package.json b/packages/api/package.json index 92b6fc4..81608b6 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -15,7 +15,7 @@ "@orpc/server": "^1.14.13", "drizzle-orm": "^0.45.0", "stripe": "^22.4.0", - "zod": "^3.24.0" + "zod": "^4.4.3" }, "devDependencies": { "@types/node": "^24.13.3", diff --git a/packages/api/src/contract-input-parity.test.ts b/packages/api/src/contract-input-parity.test.ts deleted file mode 100644 index 0f14a05..0000000 --- a/packages/api/src/contract-input-parity.test.ts +++ /dev/null @@ -1,365 +0,0 @@ -import { createRequire } from 'node:module' -import { describe, expect, it } from 'vitest' -import { call, implement } from '@orpc/server' -import { z } from 'zod' -import { contract } from '@checkin/api-contract' - -/** - * 入力検証の受理範囲が、契約への移設で変わっていないことを固定するテスト。 - * - * 移設は zod 3(`packages/api` のローカルの依存)から zod 4(`@checkin/api-contract` の依存) - * への移動を伴うので、移設後のスキーマだけを測っても「新しい zod での受理範囲」が分かるだけで、 - * 移設で受理範囲が変わったかどうかは分からない。ここでは移設前のスキーマをこのパッケージの - * zod 3 で組み直し、契約側の zod 4 のスキーマと同じ入力の集合を通して、受理と拒否を比べる。 - * 1 つのファイルから 2 つの版の zod を import する経路は無いので、比較は「zod 3 の - * safeParse」と「契約を実装した手続きを呼んでハンドラに到達したか」の 2 つの手段で行う。 - * - * 契約側の受理・拒否は公開 API だけで測る。`call` が入力検証に失敗すれば例外になり、 - * ハンドラには到達しない。到達したかどうかを sentinel で記録するので、契約の内部表現 - * (`~orpc` 等)を一切参照せずに受理範囲が分かる。 - * - * 意図して変えた差は {@link EXPECTED_DIFFERENCES} に列挙する。ここに無い差が出たら失敗する。 - */ - -// --------------------------------------------------------------------------- -// このテストが依拠している前提 -// --------------------------------------------------------------------------- - -/** - * このパッケージが解決する zod のメジャーバージョン。 - * - * この比較は、`packages/api` が zod 3 を、`@checkin/api-contract` が zod 4 を解決することに - * 依拠している。`packages/api` を zod 4 へ上げると、上の `legacySchemas` も契約側と同じ版で - * 組まれることになり、比較が zod 4 どうしの自己比較に静かに変わる。そのとき受理範囲が - * 変わっていても差は出ないので、テストは緑のまま主張を測らなくなる。前提そのものを - * 検査に載せて、上げた日に落ちるようにする。 - */ -function zodMajor(): number { - const pkg: unknown = createRequire(import.meta.url)('zod/package.json') - if (typeof pkg !== 'object' || pkg === null || !('version' in pkg) || typeof pkg.version !== 'string') { - return Number.NaN - } - return Number(pkg.version.split('.')[0]) -} - -describe('比較が依拠している前提', () => { - it('packages/api が解決する zod は major 3 である', () => { - expect(zodMajor()).toBe(3) - }) -}) - -// --------------------------------------------------------------------------- -// 移設前のスキーマ(auth ブランチの packages/api/src/router.ts の `.input(...)`) -// --------------------------------------------------------------------------- - -const legacySchemas = { - 'auth.requestEmailVerification': z.object({ - email: z.string().email(), - redirect: z.string().optional(), - }), - 'membership.issueInvoice': z.object({ - email: z.string().email(), - name: z.string().min(1), - feeType: z.enum(['new', 'continuation']), - }), - 'membership.issueSpecialInvoice': z.object({ - userId: z.string().min(1), - name: z.string().min(1).optional(), - email: z.string().email().optional(), - coverage: z.enum(['zenki', 'kouki']), - activityYear: z.number().int().optional(), - }), - 'membership.issueSpecialInvoiceByEmail': z.object({ - email: z.string().email(), - name: z.string().min(1).optional(), - coverage: z.enum(['zenki', 'kouki']), - activityYear: z.number().int().optional(), - }), - 'payments.listInvoices': z.object({ - status: z.enum(['draft', 'open', 'paid', 'uncollectible', 'void']).optional(), - limit: z.number().int().optional(), - startingAfter: z.string().optional(), - }), - 'payments.listCheckoutSessions': z.object({ - status: z.enum(['open', 'complete', 'expired']).optional(), - limit: z.number().int().optional(), - startingAfter: z.string().optional(), - }), - 'payouts.createOnboardingLink': z.object({ userId: z.string().min(1) }), - 'payouts.onboardingStatus': z.object({ userId: z.string().min(1) }), - 'payouts.list': z.object({ - status: z.enum(['pending', 'onboarding_waiting', 'processing', 'paid', 'failed']).optional(), - }), - 'payouts.execute': z.object({ jomonRef: z.string().min(1) }), - 'payouts.markManuallyPaid': z.object({ - jomonRef: z.string().min(1), - note: z.string().max(255).optional(), - }), -} as const - -type ProcedureName = keyof typeof legacySchemas - -// --------------------------------------------------------------------------- -// 契約側(zod 4)の受理範囲を公開 API だけで測る -// --------------------------------------------------------------------------- - -/** ハンドラに到達したことを記録する sentinel。入力検証を通ったときだけ立つ。 */ -const reached = { value: false } - -/** 到達を記録して必ず投げるハンドラ。戻り値の型は never なのでどの出力契約にも当てはまる。 */ -function probeHandler(): never { - reached.value = true - throw new Error('probe: handler reached') -} - -const authImpl = implement(contract.auth) -const membershipImpl = implement(contract.membership) -const paymentsImpl = implement(contract.payments) -const payoutsImpl = implement(contract.payouts) - -/* eslint-disable @typescript-eslint/consistent-type-assertions -- 契約の入力型で弾かれる不正な入力も通すため、呼び出し時だけ型を外す */ -const contractProbes: Record Promise> = { - 'auth.requestEmailVerification': input => - call(authImpl.requestEmailVerification.handler(probeHandler), input as never, { context: {} }), - 'membership.issueInvoice': input => - call(membershipImpl.issueInvoice.handler(probeHandler), input as never, { context: {} }), - 'membership.issueSpecialInvoice': input => - call(membershipImpl.issueSpecialInvoice.handler(probeHandler), input as never, { context: {} }), - 'membership.issueSpecialInvoiceByEmail': input => - call(membershipImpl.issueSpecialInvoiceByEmail.handler(probeHandler), input as never, { context: {} }), - 'payments.listInvoices': input => - call(paymentsImpl.listInvoices.handler(probeHandler), input as never, { context: {} }), - 'payments.listCheckoutSessions': input => - call(paymentsImpl.listCheckoutSessions.handler(probeHandler), input as never, { context: {} }), - 'payouts.createOnboardingLink': input => - call(payoutsImpl.createOnboardingLink.handler(probeHandler), input as never, { context: {} }), - 'payouts.onboardingStatus': input => - call(payoutsImpl.onboardingStatus.handler(probeHandler), input as never, { context: {} }), - 'payouts.list': input => - call(payoutsImpl.list.handler(probeHandler), input as never, { context: {} }), - 'payouts.execute': input => - call(payoutsImpl.execute.handler(probeHandler), input as never, { context: {} }), - 'payouts.markManuallyPaid': input => - call(payoutsImpl.markManuallyPaid.handler(probeHandler), input as never, { context: {} }), -} -/* eslint-enable @typescript-eslint/consistent-type-assertions */ - -/** 契約側がこの入力を受理するか。ハンドラに到達したかどうかで測る。 */ -async function contractAccepts(name: ProcedureName, input: unknown): Promise { - reached.value = false - try { - await contractProbes[name](input) - } - catch { - // 入力検証の失敗も、到達後の sentinel も、どちらもここに来る。判定は reached で行う。 - } - return reached.value -} - -/** 移設前のスキーマがこの入力を受理するか。 */ -function legacyAccepts(name: ProcedureName, input: unknown): boolean { - return legacySchemas[name].safeParse(input).success -} - -// --------------------------------------------------------------------------- -// 通す入力の集合 -// --------------------------------------------------------------------------- - -/** - * メールアドレスの境界。`z.string().email()`(zod 3)は 4 箇所移したので、受理範囲が - * 変わるとすればここに出る。 - */ -const EMAILS = [ - 'a@b.co', - 'a@b', - '"quoted"@b.co', - 'a@b.', - 'a@@b.co', - 'a b@c.co', - '', - 'A@B.CO', - 'a+tag@b.co', - 'a@b.co ', -] - -/** 手続きごとに通す入力。ラベルは失敗時に差を読めるようにするためのもの。 */ -const CASES: { name: ProcedureName, inputs: { label: string, value: unknown }[] }[] = [ - { - name: 'auth.requestEmailVerification', - inputs: [ - ...EMAILS.map(email => ({ label: `email=${JSON.stringify(email)}`, value: { email } })), - { label: 'redirect 付き', value: { email: 'a@b.co', redirect: '/home' } }, - { label: 'redirect が空文字', value: { email: 'a@b.co', redirect: '' } }, - { label: 'redirect が数値', value: { email: 'a@b.co', redirect: 1 } }, - { label: 'email が無い', value: {} }, - { label: 'email が数値', value: { email: 1 } }, - { label: '余分なキー', value: { email: 'a@b.co', extra: 'x' } }, - { label: 'undefined', value: undefined }, - { label: 'null', value: null }, - { label: '配列', value: [] }, - ], - }, - { - name: 'membership.issueInvoice', - inputs: [ - ...EMAILS.map(email => ({ - label: `email=${JSON.stringify(email)}`, - value: { email, name: 'n', feeType: 'new' }, - })), - { label: '正常(continuation)', value: { email: 'a@b.co', name: 'n', feeType: 'continuation' } }, - { label: 'name が空文字', value: { email: 'a@b.co', name: '', feeType: 'new' } }, - { label: 'name が無い', value: { email: 'a@b.co', feeType: 'new' } }, - { label: 'feeType が範囲外', value: { email: 'a@b.co', name: 'n', feeType: 'other' } }, - { label: 'feeType が無い', value: { email: 'a@b.co', name: 'n' } }, - ], - }, - { - name: 'membership.issueSpecialInvoice', - inputs: [ - { label: '正常(最小)', value: { userId: 'u1', coverage: 'zenki' } }, - { label: '正常(全部)', value: { userId: 'u1', name: 'n', email: 'a@b.co', coverage: 'kouki', activityYear: 2026 } }, - ...EMAILS.map(email => ({ - label: `email=${JSON.stringify(email)}`, - value: { userId: 'u1', coverage: 'zenki', email }, - })), - { label: 'userId が空文字', value: { userId: '', coverage: 'zenki' } }, - { label: 'userId が無い', value: { coverage: 'zenki' } }, - { label: 'name が空文字', value: { userId: 'u1', coverage: 'zenki', name: '' } }, - { label: 'coverage が範囲外', value: { userId: 'u1', coverage: 'full' } }, - { label: 'activityYear が小数', value: { userId: 'u1', coverage: 'zenki', activityYear: 2026.5 } }, - { label: 'activityYear が負の整数', value: { userId: 'u1', coverage: 'zenki', activityYear: -1 } }, - { label: 'activityYear が文字列', value: { userId: 'u1', coverage: 'zenki', activityYear: '2026' } }, - { label: 'activityYear が NaN', value: { userId: 'u1', coverage: 'zenki', activityYear: Number.NaN } }, - ], - }, - { - name: 'membership.issueSpecialInvoiceByEmail', - inputs: [ - ...EMAILS.map(email => ({ label: `email=${JSON.stringify(email)}`, value: { email, coverage: 'zenki' } })), - { label: '正常(全部)', value: { email: 'a@b.co', name: 'n', coverage: 'kouki', activityYear: 2026 } }, - { label: 'name が空文字', value: { email: 'a@b.co', coverage: 'zenki', name: '' } }, - { label: 'coverage が無い', value: { email: 'a@b.co' } }, - { label: 'activityYear が小数', value: { email: 'a@b.co', coverage: 'zenki', activityYear: 2026.5 } }, - ], - }, - { - name: 'payments.listInvoices', - inputs: [ - { label: '空', value: {} }, - { label: 'status=draft', value: { status: 'draft' } }, - { label: 'status が範囲外', value: { status: 'open2' } }, - { label: 'limit=1', value: { limit: 1 } }, - { label: 'limit=100', value: { limit: 100 } }, - { label: 'limit=0', value: { limit: 0 } }, - { label: 'limit=101', value: { limit: 101 } }, - { label: 'limit=-5', value: { limit: -5 } }, - { label: 'limit が小数', value: { limit: 10.5 } }, - { label: 'limit が文字列', value: { limit: '10' } }, - { label: 'startingAfter が空文字', value: { startingAfter: '' } }, - { label: 'startingAfter=in_1', value: { startingAfter: 'in_1' } }, - { label: 'startingAfter が数値', value: { startingAfter: 1 } }, - ], - }, - { - name: 'payments.listCheckoutSessions', - inputs: [ - { label: '空', value: {} }, - { label: 'status=complete', value: { status: 'complete' } }, - { label: 'status が範囲外(draft)', value: { status: 'draft' } }, - { label: 'limit=1', value: { limit: 1 } }, - { label: 'limit=100', value: { limit: 100 } }, - { label: 'limit=0', value: { limit: 0 } }, - { label: 'limit=101', value: { limit: 101 } }, - { label: 'limit が小数', value: { limit: 10.5 } }, - { label: 'startingAfter が空文字', value: { startingAfter: '' } }, - { label: 'startingAfter=cs_1', value: { startingAfter: 'cs_1' } }, - ], - }, - { - name: 'payouts.createOnboardingLink', - inputs: [ - { label: '正常', value: { userId: 'u1' } }, - { label: 'userId が空文字', value: { userId: '' } }, - { label: 'userId が無い', value: {} }, - { label: 'userId が数値', value: { userId: 1 } }, - { label: '余分なキー', value: { userId: 'u1', extra: 'x' } }, - ], - }, - { - name: 'payouts.onboardingStatus', - inputs: [ - { label: '正常', value: { userId: 'u1' } }, - { label: 'userId が空文字', value: { userId: '' } }, - { label: 'userId が無い', value: {} }, - ], - }, - { - name: 'payouts.list', - inputs: [ - { label: '空', value: {} }, - { label: 'status=pending', value: { status: 'pending' } }, - { label: 'status=processing', value: { status: 'processing' } }, - { label: 'status が範囲外', value: { status: 'unknown' } }, - { label: 'status が null', value: { status: null } }, - ], - }, - { - name: 'payouts.execute', - inputs: [ - { label: '正常', value: { jomonRef: 'j1' } }, - { label: 'jomonRef が空文字', value: { jomonRef: '' } }, - { label: 'jomonRef が無い', value: {} }, - ], - }, - { - name: 'payouts.markManuallyPaid', - inputs: [ - { label: '正常(note 無し)', value: { jomonRef: 'j1' } }, - { label: '正常(note 有り)', value: { jomonRef: 'j1', note: 'メモ' } }, - { label: 'jomonRef が空文字', value: { jomonRef: '', note: 'メモ' } }, - { label: 'note が 255 文字', value: { jomonRef: 'j1', note: 'x'.repeat(255) } }, - { label: 'note が 256 文字', value: { jomonRef: 'j1', note: 'x'.repeat(256) } }, - { label: 'note が空文字', value: { jomonRef: 'j1', note: '' } }, - ], - }, -] - -/** - * 意図して変えた差。`payments` の 2 つの一覧の入力を main の `params.ts` の `pagination` - * に合わせた結果である(limit を 1..100 に狭め、startingAfter に最小長 1 を課した)。 - * ここに無い差が出たら、移設で意図せず受理範囲が変わっている。 - */ -const EXPECTED_DIFFERENCES: string[] = [ - 'payments.listCheckoutSessions / limit=0: 移設前=受理 契約側=拒否', - 'payments.listCheckoutSessions / limit=101: 移設前=受理 契約側=拒否', - 'payments.listCheckoutSessions / startingAfter が空文字: 移設前=受理 契約側=拒否', - 'payments.listInvoices / limit=-5: 移設前=受理 契約側=拒否', - 'payments.listInvoices / limit=0: 移設前=受理 契約側=拒否', - 'payments.listInvoices / limit=101: 移設前=受理 契約側=拒否', - 'payments.listInvoices / startingAfter が空文字: 移設前=受理 契約側=拒否', -] - -describe('契約への移設で入力検証の受理範囲が変わっていないこと', () => { - it('移設前(zod 3)と契約側(zod 4)の受理・拒否が、意図した差を除いて一致する', async () => { - const differences: string[] = [] - for (const { name, inputs } of CASES) { - for (const { label, value } of inputs) { - const before = legacyAccepts(name, value) - const after = await contractAccepts(name, value) - if (before !== after) { - differences.push(`${name} / ${label}: 移設前=${before ? '受理' : '拒否'} 契約側=${after ? '受理' : '拒否'}`) - } - } - } - expect(differences.sort()).toEqual(EXPECTED_DIFFERENCES) - }) - - it('比較の手段そのものが差を検出できる(自己検査)', async () => { - // 上の検査が「どの入力でも両側が同じ答えを返す」ことでなく、実際に受理と拒否を - // 見分けていることを確かめる。見分けていなければ、受理範囲が変わっても気付けない。 - expect(legacyAccepts('payouts.execute', { jomonRef: 'j1' })).toBe(true) - expect(legacyAccepts('payouts.execute', { jomonRef: '' })).toBe(false) - expect(await contractAccepts('payouts.execute', { jomonRef: 'j1' })).toBe(true) - expect(await contractAccepts('payouts.execute', { jomonRef: '' })).toBe(false) - }) -}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 87a8e9a..6ec0184 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -106,8 +106,8 @@ importers: specifier: ^22.4.0 version: 22.4.0(@types/node@24.13.3) zod: - specifier: ^3.24.0 - version: 3.25.76 + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@types/node': specifier: ^24.13.3 @@ -6162,9 +6162,6 @@ packages: resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} engines: {node: '>= 14'} - zod@3.25.76: - resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} @@ -12265,6 +12262,4 @@ snapshots: compress-commons: 6.0.2 readable-stream: 4.7.0 - zod@3.25.76: {} - zod@4.4.3: {} From eb65597553792a72bb3c284c8b01cd3173e347ae Mon Sep 17 00:00:00 2001 From: rei <107461411+reiroop@users.noreply.github.com> Date: Fri, 18 Sep 2026 10:54:17 +0900 Subject: [PATCH 29/30] =?UTF-8?q?docs(api):=20=E5=A5=91=E7=B4=84=E3=81=8C?= =?UTF-8?q?=20listError=20=E3=82=92=E5=89=A5=E3=81=8C=E3=81=99=E3=81=93?= =?UTF-8?q?=E3=81=A8=E3=81=A8=E8=AA=8D=E5=8F=AF=E3=81=AE=E3=82=B3=E3=83=BC?= =?UTF-8?q?=E3=83=89=E3=81=AE=E9=81=95=E3=81=84=E3=82=92=E3=82=B3=E3=83=A1?= =?UTF-8?q?=E3=83=B3=E3=83=88=E3=81=AB=E5=8F=8D=E6=98=A0=E3=81=99=E3=82=8B?= =?UTF-8?q?=20(U8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2 つのコメントが、この統合が決めたことと両立していなかった。 payouts/execute.ts は、一覧取得に失敗したときの summary について「callers/tests see the fetch failed」と書いていたが、この統合が payouts.processApproved の出力 契約から listError を落としたので、RPC の呼び出し側にはこのキーが届かない。 プロセス内の呼び出し元とテストは読むこと、RPC の呼び出し側には届かないこと、 その理由(失敗の文面を公開面に出さない)と、運用中の原因は直後の console.error の行にあることを書く。契約が剥がすことは contract-surface.test.ts が固定している。 orpc.test.ts は、冒頭の docblock だけが「認可が落ちる Context では UNAUTHORIZED が 返る」と古いままだった。落とす側の requireUser と requireAdmin を別のコードで 投げ分けて取り違えを観測できるようにした後の実態に合わせる。 どちらもコメントだけで、振る舞いは変えていない。 Co-Authored-By: Claude Opus 5 (1M context) --- packages/api/src/orpc.test.ts | 8 +++++--- packages/api/src/payouts/execute.ts | 12 ++++++++++-- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/api/src/orpc.test.ts b/packages/api/src/orpc.test.ts index 84cc9c9..0cd54f4 100644 --- a/packages/api/src/orpc.test.ts +++ b/packages/api/src/orpc.test.ts @@ -10,9 +10,11 @@ import { adminProc, userProc, type Context } from './orpc' * ミドルウェアと入力検証の実行順で、これは同梱の `@orpc/server` の挙動である。順序が * 変わればこの一般則は成り立たなくなるので、版を上げた日に落ちるようここで測る。 * - * 対にして見る。認可が落ちる Context では入力が不正でも UNAUTHORIZED が返り、認可が通る - * Context では同じ不正な入力が BAD_REQUEST になる。後者が無いと、UNAUTHORIZED が返るのが - * 「この入力はそもそも検証を通る」という別の理由でないことを示せない。 + * 対にして見る。認可が落ちる Context では入力が不正でも、そのビルダーが呼んだ認可ヘルパが + * 投げたコードが返る(`userProc` は UNAUTHORIZED、`adminProc` は FORBIDDEN。どちらも下の + * `contextWith` が 2 つのヘルパを見分けるために与えた目印である)。認可が通る Context では + * 同じ不正な入力が BAD_REQUEST になる。後者が無いと、認可のコードが返るのが「この入力は + * そもそも検証を通る」という別の理由でないことを示せない。 */ /** diff --git a/packages/api/src/payouts/execute.ts b/packages/api/src/payouts/execute.ts index d6180c4..2d68c0a 100644 --- a/packages/api/src/payouts/execute.ts +++ b/packages/api/src/payouts/execute.ts @@ -99,6 +99,11 @@ export interface ProcessApprovedSummary { * Set when the approved-requests fetch itself failed: the run is reported with * an empty body and this top-level error indicator instead of throwing, so a * Jomon list/HTTP/zod failure never aborts the whole run with nothing recorded. + * + * Server-side only. `payouts.processApproved`'s output contract does not carry + * this key, so it is stripped from the RPC response and never reaches a client + * (fixed by `contract-surface.test.ts`). Operators read the reason from the log + * line the catch below writes. */ listError?: string } @@ -131,8 +136,11 @@ export async function processApprovedPayouts( // Batch-fetch isolation: the approved-requests pull happens BEFORE any per-item // work, so a list/HTTP/zod failure here would otherwise abort the whole run with // nothing recorded. Catch it, log, and RETURN the (empty) summary with a - // top-level `listError` so callers/tests see the fetch failed rather than a - // throw. No money has moved at this point. (Codex hardening: §batch-abort) + // top-level `listError` rather than throwing. No money has moved at this point. + // In-process callers and tests read `listError`; an RPC client does not, because + // the output contract drops the key (the failure text must not reach the public + // surface), so the reason lives in the log line below. (Codex hardening: + // §batch-abort) let requests: JomonTransferRequest[] try { requests = await deps.jomon.listApprovedTransferRequests() From d8feacab69b0b58916f4589c3edfda7ea3cb67b9 Mon Sep 17 00:00:00 2001 From: rei <107461411+reiroop@users.noreply.github.com> Date: Fri, 18 Sep 2026 11:13:59 +0900 Subject: [PATCH 30/30] =?UTF-8?q?docs(api):=20=E9=87=8D=E8=A4=87=E3=82=AD?= =?UTF-8?q?=E3=83=BC=E3=81=AE=E5=9B=9E=E5=BE=A9=E7=B5=8C=E8=B7=AF=E3=81=8C?= =?UTF-8?q?=E7=8F=BE=E5=9C=A8=E3=81=AF=E5=88=B0=E9=81=94=E3=81=97=E3=81=AA?= =?UTF-8?q?=E3=81=84=E3=81=93=E3=81=A8=E3=82=92=E3=82=B3=E3=83=A1=E3=83=B3?= =?UTF-8?q?=E3=83=88=E3=81=AB=E6=9B=B8=E3=81=8F=20(U8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit webhook/events.ts と stripe/connect.ts のコメントが、同じパッケージの mysql-result.ts に記録した実測と両立していなかった。この 2 ファイルが持つ isDuplicateKeyError は最上位しか読まないが、drizzle は mysql2 のエラーを包み、 code と errno を持つ側を cause に置く。したがって実際の重複キーでは false を 返し、コメントが説明している回復経路には入らず例外を投げ直す。 recordStripeEventOnce は「false を返す」と書いているが、その分岐に入らない。 どちらの呼び出し側も hasProcessedStripeEvent で照会してから呼ぶので、通常の 再配信は insert に届かず、同時に届いた 2 つが両方とも照会を通り抜けたときだけ 競って、負けた側がエラーになって Stripe の再送で処理される。 connect.ts は、通常の負け筋が claimConnectedAccountId の戻り値 false であって この catch ではないので、到達しないのは catch の中の won = false だけである。 外側のコメントも、その扱いが現に働いているように読めたので直した。 どちらもコメントだけで、実行される行は 1 行も変えていない。3 実装の統一は 振る舞いの変更になるので、統一を扱う後続 issue に残す。 Co-Authored-By: Claude Opus 5 (1M context) --- packages/api/src/stripe/connect.ts | 14 +++++++++++++- packages/api/src/webhook/events.ts | 13 +++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/api/src/stripe/connect.ts b/packages/api/src/stripe/connect.ts index 16ee211..4f910cd 100644 --- a/packages/api/src/stripe/connect.ts +++ b/packages/api/src/stripe/connect.ts @@ -63,12 +63,24 @@ export async function getOrCreateConnectedAccount( // Conditional compare-and-set: only link if the column is still NULL. The // `unique` constraint on the column backs this up — should the IS NULL guard - // ever be lost to a race we treat the duplicate-key error as "lost the claim". + // ever be lost to a race, the duplicate-key error would mean "lost the claim" + // (but see the gap noted in the catch below). let won: boolean try { won = await claimConnectedAccountId(db, input.userId, created.id) } catch (err) { + // KNOWN GAP — the `won = false` below is currently unreachable. + // `isDuplicateKeyError` (bottom of this file) reads only the top level of the + // thrown value, but drizzle wraps the mysql2 error and carries the one holding + // `code`/`errno` as its `.cause` (measured; the chain is written out on + // `isDuplicateKeyError` in `../mysql-result.ts`), so a duplicate key is + // rethrown here. The ordinary lost-race path does not depend on this branch: + // `claimConnectedAccountId` returns false when the conditional UPDATE matched + // no row, and only the rarer case of the IS NULL guard itself being lost to a + // race reaches the constraint. Switching to the shared reader would change + // behaviour, so it is left to the follow-up issue that covers unifying the + // three copies. if (!isDuplicateKeyError(err)) { throw err } diff --git a/packages/api/src/webhook/events.ts b/packages/api/src/webhook/events.ts index cec9d5f..7b52ddc 100644 --- a/packages/api/src/webhook/events.ts +++ b/packages/api/src/webhook/events.ts @@ -24,6 +24,19 @@ export async function hasProcessedStripeEvent(db: Database, eventId: string): Pr * Idempotency rides on the `stripe_events.event_id` UNIQUE constraint: a * concurrent/duplicate insert fails with a duplicate-key error, which we treat * as "already processed". (payment-webhook spec: §event id による冪等処理) + * + * KNOWN GAP — the `false` return is currently unreachable. The local + * `isDuplicateKeyError` below reads only the top level of the thrown value, but + * drizzle wraps the mysql2 error and carries the one holding `code`/`errno` as + * its `.cause` (measured; the chain is written out on `isDuplicateKeyError` in + * `../mysql-result.ts`). So a real duplicate key is not recognised and the error + * is rethrown instead of reporting "already processed". Both callers + * (`processInvoicePaid` and the `account-updated` route) query + * `hasProcessedStripeEvent` before calling this, so an ordinary redelivery never + * reaches the insert; only two deliveries that pass that check concurrently do, + * and the loser surfaces an error that Stripe retries (the retry then stops at + * the query). Switching to the shared reader would change that behaviour, so it + * is left to the follow-up issue that covers unifying the three copies. */ export async function recordStripeEventOnce( db: Database,
status + 方法 + transfer ID + + + Stripe + + {{ row.stripeTransferId ?? '—' }}