2026-04-10 15:16:57 +00:00
|
|
|
|
'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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-11 06:05:23 +00:00
|
|
|
|
// ── 購入完了メール(マジックリンク付き)送信 ──
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-10 15:16:57 +00:00
|
|
|
|
// ── 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}`);
|
2026-04-11 06:05:23 +00:00
|
|
|
|
|
|
|
|
|
|
// 購入完了メールにマジックリンクを同梱(非同期・失敗しても購入処理には影響しない)
|
|
|
|
|
|
sendWelcomeMagicLink(email).catch(e =>
|
|
|
|
|
|
console.error('[Stripe] Welcome email failed (non-critical):', e.message)
|
|
|
|
|
|
);
|
2026-04-10 15:16:57 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── 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 };
|
|
|
|
|
|
};
|