'use strict'; const crypto = require('crypto'); /** * Stripe Webhook ハンドラー * * 処理するイベント: * checkout.session.completed * - metadata.product === 'ponshu_room_pro' * -> ponshu_licenses にライセンスキーを発行し Resend でメール送信 * - それ以外 * -> users テーブルに購入記録(既存の Posimai 購読フロー) * customer.subscription.deleted * -> users.plan を free に戻す * * 使用方法: * const { handleWebhook } = require('./routes/stripe')(pool); * app.post('/api/stripe/webhook', express.raw({ type: 'application/json' }), * (req, res) => handleWebhook(req, res)); */ module.exports = function createStripeHandler(pool) { const STRIPE_WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET || ''; const RESEND_API_KEY = process.env.RESEND_API_KEY || ''; const APP_SUPPORT_EMAIL = process.env.APP_SUPPORT_EMAIL || 'support@posimai.soar-enrich.com'; const FROM_EMAIL = process.env.FROM_EMAIL || 'noreply@posimai.soar-enrich.com'; // ── ライセンスキー生成 (PONSHU-XXXX-XXXX-XXXX) ── function generateLicenseKey() { const hex = crypto.randomBytes(6).toString('hex').toUpperCase(); return `PONSHU-${hex.slice(0, 4)}-${hex.slice(4, 8)}-${hex.slice(8, 12)}`; } // ── Resend でライセンスキーをメール送信 ── async function sendLicenseEmail(toEmail, licenseKey) { if (!RESEND_API_KEY) { console.warn('[Stripe] RESEND_API_KEY not set — skipping license email'); return; } const emailRes = await fetch('https://api.resend.com/emails', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${RESEND_API_KEY}`, }, body: JSON.stringify({ from: FROM_EMAIL, to: toEmail, subject: 'Ponshu Room Pro ライセンスキーのお知らせ', html: `

この度は Ponshu Room Pro をご購入いただきありがとうございます。

以下のライセンスキーをアプリ内「設定 → ライセンスを有効化」から入力してください。

${licenseKey}

ご不明な点は ${APP_SUPPORT_EMAIL} までお問い合わせください。

`, }), }); if (!emailRes.ok) { const errBody = await emailRes.text(); console.error('[Stripe] Resend API error:', emailRes.status, errBody); } else { console.log(`[Stripe] License email sent to ${toEmail}`); } } // ── Ponshu Room Pro 購入処理 ── async function handlePonshuPurchase(session) { const email = session.customer_details?.email?.toLowerCase(); if (!email) { console.error('[Stripe] Ponshu purchase: no email in session', session.id); return; } // 冪等性チェック — Stripe がリトライしても重複発行しない const existing = await pool.query( `SELECT license_key FROM ponshu_licenses WHERE stripe_session_id = $1`, [session.id] ); if (existing.rows.length > 0) { console.log(`[Stripe] Ponshu: duplicate webhook for session ${session.id}, skipping`); return; } const licenseKey = generateLicenseKey(); await pool.query( `INSERT INTO ponshu_licenses (license_key, email, plan, status, stripe_session_id) VALUES ($1, $2, 'pro', 'active', $3)`, [licenseKey, email, session.id] ); console.log(`[Stripe] Ponshu license issued: ${licenseKey.substring(0, 12)}... -> ${email}`); await sendLicenseEmail(email, licenseKey); } // ── 購入完了メール(マジックリンク付き)送信 ── async function sendWelcomeMagicLink(email) { if (!RESEND_API_KEY) { console.warn('[Stripe] RESEND_API_KEY not set — skipping welcome email'); return; } try { // マジックリンク用トークンを生成(15分有効) const token = crypto.randomBytes(32).toString('hex'); const expiresAt = new Date(Date.now() + 15 * 60 * 1000); await pool.query( `INSERT INTO magic_link_tokens (email, token, expires_at) VALUES ($1, $2, $3)`, [email, token, expiresAt] ); const loginBase = process.env.MAGIC_LINK_BASE_URL || 'https://posimai.soar-enrich.com'; const magicLinkUrl = `${loginBase}/auth/verify?token=${token}&redirect=${encodeURIComponent('https://brief.posimai.soar-enrich.com')}`; const emailRes = await fetch('https://api.resend.com/emails', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${RESEND_API_KEY}`, }, body: JSON.stringify({ from: FROM_EMAIL, to: email, subject: 'Posimai へようこそ — ログインリンク', html: `

ご購入ありがとうございます

Posimai パスの購入が完了しました。
下のボタンをクリックするとすぐにアプリをご利用いただけます。

Posimai を開く

このリンクは15分間有効です。
期限切れの場合は ${loginBase}/login からログインしてください。


ご不明な点は ${APP_SUPPORT_EMAIL} までお問い合わせください。

`, }), }); if (!emailRes.ok) { const errBody = await emailRes.text(); console.error('[Stripe] Welcome email error:', emailRes.status, errBody); } else { console.log(`[Stripe] Welcome magic link sent to ${email}`); } } catch (e) { console.error('[Stripe] sendWelcomeMagicLink error:', e.message); } } // ── Posimai 購読購入処理(既存フロー) ── async function handlePosiPurchase(session) { const email = session.customer_details?.email?.toLowerCase(); if (!email) return; const existing = await pool.query( `SELECT user_id FROM users WHERE email = $1`, [email] ); let userId; if (existing.rows.length > 0) { userId = existing.rows[0].user_id; } else { const baseId = email.split('@')[0].replace(/[^a-zA-Z0-9_-]/g, '').substring(0, 30); userId = `${baseId}_${Date.now().toString(36)}`; await pool.query( `INSERT INTO users (user_id, name, email, email_verified) VALUES ($1, $2, $3, true) ON CONFLICT (user_id) DO NOTHING`, [userId, baseId, email] ); } await pool.query( `UPDATE users SET purchased_at = NOW(), stripe_session_id = $1, plan = 'premium', stripe_customer_id = $2, stripe_subscription_id = $3 WHERE user_id = $4`, [session.id, session.customer, session.subscription, userId] ); console.log(`[Stripe] Plan upgraded to premium for ${email}`); // 購入完了メールにマジックリンクを同梱(非同期・失敗しても購入処理には影響しない) sendWelcomeMagicLink(email).catch(e => console.error('[Stripe] Welcome email failed (non-critical):', e.message) ); } // ── Webhook エントリーポイント ── async function handleWebhook(req, res) { const sig = req.headers['stripe-signature']; if (!STRIPE_WEBHOOK_SECRET) { console.warn('[Stripe] STRIPE_WEBHOOK_SECRET not set — webhook disabled'); return res.status(503).json({ error: 'Webhook not configured' }); } if (!sig) return res.status(400).json({ error: 'Missing stripe-signature' }); // 署名検証(stripe ライブラリ不使用・軽量実装) let event; try { const rawBody = req.body; // Buffer (express.raw が渡す) const match = sig.match(/t=(\d+).*?,.*?v1=([a-fA-F0-9]+)/); if (!match || match.length < 3) throw new Error('Invalid signature format'); const [, tsStr, v1Sig] = match; const tolerance = 300; // 5 分 if (Math.abs(Date.now() / 1000 - parseInt(tsStr)) > tolerance) { throw new Error('Timestamp too old'); } const payload = `${tsStr}.${rawBody.toString('utf8')}`; const expected = crypto.createHmac('sha256', STRIPE_WEBHOOK_SECRET).update(payload).digest('hex'); if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1Sig))) { throw new Error('Signature mismatch'); } event = JSON.parse(rawBody.toString('utf8')); } catch (e) { console.error('[Stripe] Webhook verification failed:', e.message); return res.status(400).json({ error: 'Webhook verification failed' }); } if (event.type === 'checkout.session.completed') { const session = event.data.object; try { if (session.metadata?.product === 'ponshu_room_pro') { await handlePonshuPurchase(session); } else { await handlePosiPurchase(session); } } catch (e) { console.error('[Stripe] Error processing checkout session:', e.message); return res.status(500).json({ error: 'Processing error' }); } } if (event.type === 'customer.subscription.deleted') { const subscription = event.data.object; try { await pool.query( `UPDATE users SET plan = 'free', stripe_subscription_id = NULL WHERE stripe_customer_id = $1`, [subscription.customer] ); console.log(`[Stripe] Plan downgraded to free for customer: ${subscription.customer}`); } catch (e) { console.error('[Stripe] DB error downgrading plan:', e.message); } } res.json({ received: true }); } return { handleWebhook }; };