posimai-root/routes/stripe.js

258 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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: `
<p>この度は Ponshu Room Pro をご購入いただきありがとうございます。</p>
<p>以下のライセンスキーをアプリ内「設定 → ライセンスを有効化」から入力してください。</p>
<p style="font-size:24px;font-weight:bold;letter-spacing:4px;font-family:monospace;
background:#f5f5f5;padding:16px;border-radius:8px;display:inline-block;">
${licenseKey}
</p>
<p>ご不明な点は <a href="mailto:${APP_SUPPORT_EMAIL}">${APP_SUPPORT_EMAIL}</a> までお問い合わせください。</p>
`,
}),
});
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: `
<div style="font-family:sans-serif;max-width:480px;margin:0 auto;padding:24px">
<h2 style="font-size:20px;font-weight:600;margin-bottom:8px">ご購入ありがとうございます</h2>
<p style="color:#555;line-height:1.6">Posimai パスの購入が完了しました。<br>
下のボタンをクリックするとすぐにアプリをご利用いただけます。</p>
<div style="margin:24px 0">
<a href="${magicLinkUrl}"
style="display:inline-block;background:#6EE7B7;color:#0D0D0D;
font-weight:600;padding:12px 28px;border-radius:8px;
text-decoration:none;font-size:15px">
Posimai を開く
</a>
</div>
<p style="font-size:12px;color:#888">このリンクは15分間有効です。<br>
期限切れの場合は <a href="${loginBase}/login">${loginBase}/login</a> からログインしてください。</p>
<hr style="border:none;border-top:1px solid #eee;margin:24px 0">
<p style="font-size:12px;color:#aaa">
ご不明な点は <a href="mailto:${APP_SUPPORT_EMAIL}">${APP_SUPPORT_EMAIL}</a> までお問い合わせください。
</p>
</div>
`,
}),
});
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 };
};