ดูยอดขายจาก Stripe จริง (อ่าน key จาก STRIPE_SECRET_KEY) วันนี้ สัปดาห์ เดือน กราฟ และ MRR พร้อมโหมดข้อมูลตัวอย่าง

ดูยอดขายจาก Stripe ใน Claude Code: วันนี้ เมื่อวาน 7 วัน 30 วัน พร้อมการเติบโตเทียบช่วงก่อน ค่าธรรมเนียมและยอดสุทธิจริง MRR/ARR จาก subscription ที่ active กราฟรายวัน ลูกค้าที่จ่ายมากสุด และยอดแยกตามสินค้า ทั้งหมดดึงจาก Stripe API จริง และมี dashboard ในแผงด้านข้าง ถ้ายังไม่มี key ลองดูหน้าตาได้ด้วยโหมดข้อมูลตัวอย่าง ซึ่งจะมีป้ายบอกทุกจุด
| คำสั่ง | ทำอะไร |
|---|---|
/stripe | สรุป + เปิด dashboard ในแผง |
/stripe today / yesterday / week / month | ยอดตามช่วงเวลา เทียบกับช่วงก่อนหน้า |
/stripe 7days / 30days | กราฟยอดรายวัน |
/stripe transactions [n] | รายการล่าสุด พร้อมค่าธรรมเนียมและยอดสุทธิ |
/stripe top | ลูกค้าที่จ่ายมากสุด 5 อันดับ |
/stripe products | ยอดแยกตามสินค้า (จาก description ของรายการ) |
/stripe refresh | ดึงข้อมูลใหม่ (ปกติจะ cache ไว้ 5 นาที) |
/stripe status | ดูว่าใช้ key แบบไหนอยู่ (แสดงแค่ 4 ตัวท้าย) |
/stripe export | ส่งออก CSV |
/stripe demo / demo off | เปิด/ปิดโหมดข้อมูลตัวอย่าง |
claude:# Windows PowerShell
$env:STRIPE_SECRET_KEY = "rk_live_..."
# macOS / Linux
export STRIPE_SECRET_KEY=rk_live_...
อย่าพิมพ์ key ลงในช่องแชตของ Claude Code เพราะข้อความทั้งหมดจะถูกบันทึกไว้ใน transcript
/plugin install stripe-dashboard-th --marketplace tksoft11/claude-code-mods-th
ต้องตั้ง CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 ก่อนเปิด claude (ดู README หลัก)
เปิด hooks/register.tsx แล้วอ่านตามบทที่เขียนไว้ในคอมเมนต์:
$.http.fetch และดึงข้อมูลหลายหน้าด้วย starting_after$.env.get และ cache ข้อมูลrk_test_...) ก่อนhooks/register.tsx 438 lines1/**
2 * ============================================================================
3 * MOD 16: Stripe Dashboard TH - ดูยอดขายจาก Stripe ใน Claude Code (ฉบับสอนทำ)
4 * ============================================================================
5 * แรงบันดาลใจจาก: Stripe Dashboard (https://dashboard.stripe.com)
6 *
7 * หน้าที่ของ Mod นี้:
8 * 1. ดึงยอดขายจริงจาก Stripe API (30 วันย้อนหลัง) แล้วสรุปวันนี้ / เมื่อวาน / สัปดาห์ / เดือน
9 * 2. ค่าธรรมเนียมและยอดสุทธิจริงจาก balance transaction (ไม่ได้เดาสูตรเอง)
10 * 3. MRR / ARR คำนวณจาก subscription ที่ active จริง
11 * 4. กราฟรายวัน, ลูกค้าที่จ่ายมากสุด, ยอดแยกตามสินค้า, ส่งออก CSV
12 * 5. Dashboard ในแผงด้านข้าง
13 * 6. ยังไม่มี key ก็ลองได้ด้วย /stripe demo (ข้อมูลตัวอย่าง มีป้ายบอกทุกจุด)
14 *
15 * ความปลอดภัยของ key:
16 * - อ่านจากตัวแปร environment STRIPE_SECRET_KEY เท่านั้น ไม่รับ key ผ่านคำสั่ง
17 * (พิมพ์ key ลงช่อง prompt = key ไปอยู่ใน transcript)
18 * - แนะนำใช้ Restricted key (rk_...) ที่ให้สิทธิ์อ่านอย่างเดียว
19 * - Mod นี้อ่านข้อมูลอย่างเดียว ไม่สร้างหรือแก้อะไรใน Stripe
20 */
21
22import type { EngineInterface, Register } from 'claude-code'
23
24interface Txn {
25 id: string
26 amount: number // หน่วยหลัก เช่น บาท (Stripe ส่งมาเป็นสตางค์ ต้องหาร 100)
27 refunded: number
28 fee?: number
29 net?: number
30 currency: string
31 created: number // ms
32 description: string
33 email: string
34}
35
36interface Data {
37 source: 'live' | 'test' | 'demo'
38 txns: Txn[]
39 mrr: Record<string, number> // MRR แยกตามสกุลเงิน
40 fetchedAt: number
41}
42
43const PANE = 'stripe-dashboard-th'
44const STRIPE_PURPLE = '#635bff'
45const DAY = 24 * 60 * 60 * 1000
46
47// สกุลเงินที่ไม่มีทศนิยม (Stripe ส่งมาเป็นหน่วยเต็มอยู่แล้ว)
48const ZERO_DECIMAL = new Set(['bif', 'clp', 'djf', 'gnf', 'jpy', 'kmf', 'krw', 'mga', 'pyg', 'rwf', 'ugx', 'vnd', 'vuv', 'xaf', 'xof', 'xpf'])
49const toMajor = (amount: number, currency: string) => (ZERO_DECIMAL.has(currency) ? amount : amount / 100)
50
51let data: Data | undefined
52let isDemo = false
53
54// ==========================================================================
55// บทที่ 1: เรียก Stripe API ด้วย $.http.fetch (มี pagination)
56// ==========================================================================
57async function stripeGet($: EngineInterface, key: string, path: string): Promise<Record<string, unknown>> {
58 const res = await $.http.fetch(`https://api.stripe.com/v1/${path}`, { headers: { Authorization: `Bearer ${key}` } })
59 const body = JSON.parse(res.text) as Record<string, unknown>
60 if (!res.ok) {
61 const message = (body.error as { message?: string } | undefined)?.message ?? `HTTP ${res.status}`
62 throw new Error(message)
63 }
64 return body
65}
66
67// list API ของ Stripe ส่งทีละ 100 รายการ ต้องขอหน้าถัดไปด้วย starting_after
68async function listAll($: EngineInterface, key: string, path: string, maxPages = 10): Promise<Record<string, unknown>[]> {
69 const items: Record<string, unknown>[] = []
70 let after = ''
71 for (let page = 0; page < maxPages; page++) {
72 const glue = path.includes('?') ? '&' : '?'
73 const body = await stripeGet($, key, `${path}${glue}limit=100${after ? `&starting_after=${after}` : ''}`)
74 const list = (body.data as Record<string, unknown>[] | undefined) ?? []
75 items.push(...list)
76 if (!body.has_more || list.length === 0) break
77 after = String(list[list.length - 1]?.id ?? '')
78 }
79 return items
80}
81
82// ==========================================================================
83// บทที่ 2: แปลงข้อมูลดิบของ Stripe เป็นรายการขาย + คำนวณ MRR
84// ==========================================================================
85function toTxn(c: Record<string, unknown>): Txn {
86 const currency = String(c.currency ?? 'thb')
87 const bt = c.balance_transaction as { fee?: number; net?: number; currency?: string } | string | null
88 const billing = c.billing_details as { email?: string | null } | undefined
89 const sameCurrency = typeof bt === 'object' && bt !== null && bt.currency === currency
90 return {
91 id: String(c.id),
92 amount: toMajor(Number(c.amount ?? 0), currency),
93 refunded: toMajor(Number(c.amount_refunded ?? 0), currency),
94 // fee/net อยู่ในสกุลเงินที่เข้าบัญชี ถ้าไม่ตรงกับสกุลที่ขาย ไม่แสดง (กันบวกผิดสกุล)
95 fee: sameCurrency ? toMajor(Number(bt.fee ?? 0), currency) : undefined,
96 net: sameCurrency ? toMajor(Number(bt.net ?? 0), currency) : undefined,
97 currency,
98 created: Number(c.created ?? 0) * 1000,
99 description: String(c.description ?? 'ไม่ระบุสินค้า'),
100 email: String(billing?.email ?? c.receipt_email ?? 'ไม่ระบุลูกค้า'),
101 }
102}
103
104// MRR = รายได้ต่อเดือนจาก subscription: ปรับทุกรอบบิลให้เป็นต่อเดือน
105function monthlyAmount(item: { quantity?: number; price?: { unit_amount?: number | null; currency?: string; recurring?: { interval?: string; interval_count?: number } | null } }): [string, number] {
106 const price = item.price
107 const currency = price?.currency ?? 'thb'
108 const amount = toMajor((price?.unit_amount ?? 0) * (item.quantity ?? 1), currency)
109 const count = price?.recurring?.interval_count ?? 1
110 const perMonth = { day: 365 / 12, week: 52 / 12, month: 1, year: 1 / 12 }[price?.recurring?.interval ?? 'month'] ?? 1
111 return [currency, (amount * perMonth) / count]
112}
113
114async function fetchLive($: EngineInterface, key: string): Promise<Data> {
115 const since = Math.floor(((await $.clock.now()) - 31 * DAY) / 1000)
116 const charges = await listAll($, key, `charges?created%5Bgte%5D=${since}&expand%5B%5D=data.balance_transaction`)
117 const txns = charges.filter(c => c.status === 'succeeded' && c.paid === true).map(toTxn)
118
119 const mrr: Record<string, number> = {}
120 const subs = await listAll($, key, 'subscriptions?status=active', 5)
121 for (const sub of subs) {
122 const items = ((sub.items as { data?: unknown[] } | undefined)?.data ?? []) as Parameters<typeof monthlyAmount>[0][]
123 for (const item of items) {
124 const [currency, value] = monthlyAmount(item)
125 mrr[currency] = (mrr[currency] ?? 0) + value
126 }
127 }
128 return { source: key.includes('_test_') ? 'test' : 'live', txns, mrr, fetchedAt: await $.clock.now() }
129}
130
131// ==========================================================================
132// บทที่ 3: ข้อมูลตัวอย่าง (สุ่มแบบกำหนด seed ได้ผลเดิมทุกครั้ง) - ใช้ลองเมื่อยังไม่มี key
133// ==========================================================================
134function demoData(now: number): Data {
135 let seed = 42
136 const rand = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648)
137 const products = [
138 { name: 'คอร์สเรียน Claude Code', price: 1290 },
139 { name: 'เสื้อยืดลายไทย', price: 390 },
140 { name: 'Mod พิเศษ', price: 590 },
141 ]
142 const txns: Txn[] = []
143 for (let d = 29; d >= 0; d--) {
144 const day = new Date(now - d * DAY)
145 const isWeekend = day.getDay() === 0 || day.getDay() === 6
146 const count = Math.floor((isWeekend ? 5 : 2) + rand() * 4)
147 for (let i = 0; i < count; i++) {
148 const p = products[Math.floor(rand() * products.length)] ?? products[0]!
149 const fee = Math.round(p.price * 0.0365 + 10) // ตัวเลขตัวอย่าง ไม่ใช่อัตราจริงของ Stripe
150 txns.push({
151 id: `demo_${d}_${i}`,
152 amount: p.price,
153 refunded: 0,
154 fee,
155 net: p.price - fee,
156 currency: 'thb',
157 created: day.getTime() - Math.floor(rand() * 8) * 60 * 60 * 1000,
158 description: p.name,
159 email: `customer${Math.floor(rand() * 15) + 1}@example.com`,
160 })
161 }
162 }
163 return { source: 'demo', txns, mrr: { thb: 18_900 }, fetchedAt: now }
164}
165
166// ==========================================================================
167// บทที่ 4: โหลดข้อมูล (cache 5 นาที) + ป้ายบอกแหล่งข้อมูล
168// ==========================================================================
169const SOURCE_LABEL = { live: '🟢 Stripe (live)', test: '🧪 Stripe (test mode)', demo: '📝 ข้อมูลตัวอย่าง - ไม่ใช่ยอดจริง' }
170
171async function load($: EngineInterface, force = false): Promise<Data> {
172 const now = await $.clock.now()
173 if (isDemo) return (data = data?.source === 'demo' ? data : demoData(now))
174 if (data && data.source !== 'demo' && !force && now - data.fetchedAt < 5 * 60 * 1000) return data
175
176 const key = await $.env.get('STRIPE_SECRET_KEY')
177 if (!key) throw new Error('NO_KEY')
178 return (data = await fetchLive($, key))
179}
180
181const NO_KEY_HELP = `🔑 ยังไม่ได้ตั้ง STRIPE_SECRET_KEY
1821. ไปที่ https://dashboard.stripe.com/apikeys สร้าง Restricted key แบบอ่านอย่างเดียว
183 (ให้สิทธิ์ Read ที่ Charges, Balance transactions และ Subscriptions)
1842. ตั้งตัวแปรก่อนเปิด claude แล้วเปิดใหม่:
185 PowerShell: $env:STRIPE_SECRET_KEY = "rk_live_..."
186 macOS/Linux: export STRIPE_SECRET_KEY=rk_live_...
187⚠️ อย่าพิมพ์ key ลงในช่องแชต (จะถูกบันทึกใน transcript)
188
189ยังไม่มี key? ลอง /stripe demo ดูหน้าตาด้วยข้อมูลตัวอย่างก่อน`
190
191// ==========================================================================
192// บทที่ 5: คำนวณสถิติ (แยกตามสกุลเงิน เลือกสกุลหลักมาแสดง)
193// ==========================================================================
194function mainCurrency(d: Data): string {
195 const count = new Map<string, number>()
196 for (const t of d.txns) count.set(t.currency, (count.get(t.currency) ?? 0) + 1)
197 return [...count.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? 'thb'
198}
199
200function money(n: number, currency: string): string {
201 const symbol = currency === 'thb' ? '฿' : currency === 'usd' ? '$' : `${currency.toUpperCase()} `
202 return `${symbol}${n.toLocaleString('en-US', { maximumFractionDigits: ZERO_DECIMAL.has(currency) ? 0 : 2 })}`
203}
204
205function startOfDay(ms: number): number {
206 const d = new Date(ms)
207 return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
208}
209
210type Range = { total: number; count: number; refunded: number; fee: number; net: number }
211
212function sumRange(d: Data, currency: string, from: number, to: number): Range {
213 const list = d.txns.filter(t => t.currency === currency && t.created >= from && t.created < to)
214 return {
215 total: list.reduce((s, t) => s + t.amount - t.refunded, 0),
216 count: list.length,
217 refunded: list.reduce((s, t) => s + t.refunded, 0),
218 fee: list.reduce((s, t) => s + (t.fee ?? 0), 0),
219 net: list.reduce((s, t) => s + (t.net ?? 0), 0),
220 }
221}
222
223function growth(now: number, before: number): string {
224 if (before === 0) return now > 0 ? '🆕 (ช่วงก่อนไม่มียอด)' : '-'
225 const pct = ((now - before) / before) * 100
226 return `${pct >= 0 ? '📈 +' : '📉 '}${pct.toFixed(1)}%`
227}
228
229function dailyBars(d: Data, currency: string, days: number, now: number): { label: string; total: number; count: number }[] {
230 const today = startOfDay(now)
231 return Array.from({ length: days }, (_, i) => {
232 const from = today - (days - 1 - i) * DAY
233 const r = sumRange(d, currency, from, from + DAY)
234 const date = new Date(from)
235 return { label: `${String(date.getDate()).padStart(2, '0')}/${String(date.getMonth() + 1).padStart(2, '0')}`, total: r.total, count: r.count }
236 })
237}
238
239function chartText(d: Data, currency: string, days: number, now: number): string {
240 const bars = dailyBars(d, currency, days, now)
241 const max = Math.max(1, ...bars.map(b => b.total))
242 return bars.map(b => `${b.label} ${'█'.repeat(Math.round((b.total / max) * 20)).padEnd(20, '░')} ${money(b.total, currency)} (${b.count})`).join('\n')
243}
244
245function summary(d: Data, now: number): string {
246 const c = mainCurrency(d)
247 const today = startOfDay(now)
248 const t = sumRange(d, c, today, today + DAY)
249 const y = sumRange(d, c, today - DAY, today)
250 const week = sumRange(d, c, today - 6 * DAY, today + DAY)
251 const lastWeek = sumRange(d, c, today - 13 * DAY, today - 6 * DAY)
252 const month = sumRange(d, c, today - 29 * DAY, today + DAY)
253 const mrr = d.mrr[c] ?? 0
254 const others = new Set(d.txns.map(x => x.currency)).size - 1
255 return [
256 `💰 Stripe Dashboard ${SOURCE_LABEL[d.source]}`,
257 '----------------------------------------',
258 `วันนี้ ${money(t.total, c)} (${t.count} รายการ) ${growth(t.total, y.total)} จากเมื่อวาน`,
259 `เมื่อวาน ${money(y.total, c)} (${y.count} รายการ)`,
260 `7 วัน ${money(week.total, c)} ${growth(week.total, lastWeek.total)} จาก 7 วันก่อนหน้า`,
261 `30 วัน ${money(month.total, c)} (${month.count} รายการ) เฉลี่ย ${money(month.total / 30, c)}/วัน`,
262 `ค่าธรรมเนียม 30 วัน ${money(month.fee, c)} | สุทธิ ${money(month.net, c)}${month.refunded ? ` | คืนเงิน ${money(month.refunded, c)}` : ''}`,
263 `MRR ${money(mrr, c)} | ARR ${money(mrr * 12, c)}${mrr === 0 ? ' (ไม่มี subscription ที่ active)' : ''}`,
264 others > 0 ? `ℹ️ มีอีก ${others} สกุลเงิน ไม่ได้รวมในยอดนี้` : '',
265 ]
266 .filter(Boolean)
267 .join('\n')
268}
269
270export const register: Register = on => {
271 on('session.start', async ($, e, next) => {
272 isDemo = (await $.store.get('demo')) === true
273 data = undefined
274 await $.command.register({ name: 'stripe', description: 'ดูยอดขายจาก Stripe (/stripe help)', argumentHint: 'today|week|month|7days|transactions|top|demo' })
275 return next(e)
276 })
277
278 // ==========================================================================
279 // บทที่ 6: Dashboard ในแผง - การ์ดยอด + กราฟ 7 วัน + รายการล่าสุด
280 // ==========================================================================
281 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
282 const { Box, Text } = $.ui.resolve(e)
283 if (!data) return <Text dimColor>พิมพ์ /stripe เพื่อโหลดข้อมูล</Text>
284
285 const d = data
286 const now = await $.clock.now()
287 const c = mainCurrency(d)
288 const today = startOfDay(now)
289 const cards = [
290 ['วันนี้', sumRange(d, c, today, today + DAY).total],
291 ['7 วัน', sumRange(d, c, today - 6 * DAY, today + DAY).total],
292 ['30 วัน', sumRange(d, c, today - 29 * DAY, today + DAY).total],
293 ['MRR', d.mrr[c] ?? 0],
294 ] as const
295 const bars = dailyBars(d, c, 7, now)
296 const max = Math.max(1, ...bars.map(b => b.total))
297
298 return (
299 <Box flexDirection="column" gap={1}>
300 <Box backgroundColor={STRIPE_PURPLE} paddingX={1}>
301 <Text color="#ffffff" bold>
302 💰 Stripe {d.source === 'demo' ? '· ข้อมูลตัวอย่าง' : d.source === 'test' ? '· test mode' : ''}
303 </Text>
304 </Box>
305 <Box gap={2} flexWrap="wrap">
306 {cards.map(([label, value]) => (
307 <Box flexDirection="column" borderStyle="round" borderColor={STRIPE_PURPLE} paddingX={1}>
308 <Text dimColor>{label}</Text>
309 <Text bold>{money(value, c)}</Text>
310 </Box>
311 ))}
312 </Box>
313 <Box flexDirection="column">
314 {bars.map(b => (
315 <Text>
316 {b.label} <Text color={STRIPE_PURPLE}>{'█'.repeat(Math.round((b.total / max) * 16)).padEnd(16, '░')}</Text> {money(b.total, c)}
317 </Text>
318 ))}
319 </Box>
320 <Box flexDirection="column">
321 <Text dimColor>รายการล่าสุด</Text>
322 {[...d.txns].sort((a, b) => b.created - a.created).slice(0, 5).map(t => (
323 <Text wrap="truncate-end">
324 {money(t.amount, t.currency)} · {t.description}
325 </Text>
326 ))}
327 </Box>
328 </Box>
329 )
330 })
331
332 // ==========================================================================
333 // บทที่ 7: คำสั่ง /stripe
334 // ==========================================================================
335 on('command.run', { command: 'stripe' }, async ($, e) => {
336 const [sub = 'dashboard', arg] = e.args.trim().split(/\s+/).filter(Boolean)
337
338 if (sub === 'demo') {
339 isDemo = arg !== 'off'
340 data = undefined
341 await $.store.set('demo', isDemo)
342 if (!isDemo) return { text: '🟢 ปิดโหมดข้อมูลตัวอย่างแล้ว ใช้ข้อมูลจริงจาก STRIPE_SECRET_KEY' }
343 }
344 if (sub === 'help') {
345 return {
346 text: `💰 Stripe Dashboard TH (Mod 16)
347========================================
348/stripe สรุป + เปิด dashboard ในแผง
349/stripe today | yesterday | week | month
350/stripe 7days | 30days กราฟรายวัน
351/stripe transactions [n] รายการล่าสุด
352/stripe top ลูกค้าที่จ่ายมากสุด
353/stripe products ยอดแยกตามสินค้า (จาก description)
354/stripe refresh ดึงข้อมูลใหม่
355/stripe status ดูว่าใช้ key แบบไหนอยู่
356/stripe export ส่งออก CSV
357/stripe demo [off] เปิด/ปิดโหมดข้อมูลตัวอย่าง`,
358 }
359 }
360 if (sub === 'status') {
361 const key = await $.env.get('STRIPE_SECRET_KEY')
362 const mode = isDemo ? 'โหมดข้อมูลตัวอย่าง' : key ? `${key.startsWith('rk_') ? 'Restricted key' : 'Secret key'} ${key.includes('_test_') ? '(test)' : '(live)'} ลงท้าย …${key.slice(-4)}` : 'ยังไม่ได้ตั้ง STRIPE_SECRET_KEY'
363 const warn = key?.startsWith('sk_') ? '\n💡 แนะนำเปลี่ยนเป็น Restricted key (rk_) แบบอ่านอย่างเดียว' : ''
364 return { text: `🔑 ${mode}${data ? `\nโหลดล่าสุด ${new Date(data.fetchedAt).toLocaleTimeString('th-TH')} (${data.txns.length} รายการ)` : ''}${warn}` }
365 }
366
367 let d: Data
368 try {
369 d = await load($, sub === 'refresh')
370 } catch (err) {
371 const message = err instanceof Error ? err.message : String(err)
372 return { text: message === 'NO_KEY' ? NO_KEY_HELP : `❌ ดึงข้อมูลจาก Stripe ไม่ได้: ${message}` }
373 }
374 $.ui.invalidate('ui.render')
375
376 const now = await $.clock.now()
377 const c = mainCurrency(d)
378 const today = startOfDay(now)
379 const label = SOURCE_LABEL[d.source]
380 const rangeText = (title: string, r: Range, compare?: Range) =>
381 `${title} ${label}\n ยอดรวม ${money(r.total, c)} | ${r.count} รายการ | เฉลี่ย ${money(r.count ? r.total / r.count : 0, c)}\n ค่าธรรมเนียม ${money(r.fee, c)} | สุทธิ ${money(r.net, c)}${compare ? `\n เทียบช่วงก่อน ${growth(r.total, compare.total)}` : ''}`
382
383 switch (sub) {
384 case 'today':
385 return { text: rangeText('💰 วันนี้', sumRange(d, c, today, today + DAY), sumRange(d, c, today - DAY, today)) }
386 case 'yesterday':
387 return { text: rangeText('💰 เมื่อวาน', sumRange(d, c, today - DAY, today), sumRange(d, c, today - 2 * DAY, today - DAY)) }
388 case 'week':
389 return { text: rangeText('💰 7 วันล่าสุด', sumRange(d, c, today - 6 * DAY, today + DAY), sumRange(d, c, today - 13 * DAY, today - 6 * DAY)) }
390 case 'month':
391 return { text: `${rangeText('💰 30 วันล่าสุด', sumRange(d, c, today - 29 * DAY, today + DAY))}\n MRR ${money(d.mrr[c] ?? 0, c)} | ARR ${money((d.mrr[c] ?? 0) * 12, c)}` }
392 case '7days':
393 case '30days':
394 return { text: `📈 ยอดรายวัน ${label}\n${chartText(d, c, sub === '7days' ? 7 : 30, now)}` }
395 case 'transactions': {
396 const n = Math.min(50, Math.max(1, Number(arg) || 10))
397 const rows = [...d.txns].sort((a, b) => b.created - a.created).slice(0, n).map(t => {
398 const when = new Date(t.created).toLocaleString('th-TH', { dateStyle: 'short', timeStyle: 'short' })
399 return `✅ ${money(t.amount, t.currency)} ${t.description} · ${t.email} · ${when}${t.fee !== undefined ? `\n ค่าธรรมเนียม ${money(t.fee, t.currency)} | สุทธิ ${money(t.net ?? 0, t.currency)}` : ''}${t.refunded ? ` | คืนเงิน ${money(t.refunded, t.currency)}` : ''}`
400 })
401 return { text: [`💳 ${n} รายการล่าสุด ${label}`, ...rows].join('\n') }
402 }
403 case 'top':
404 case 'products': {
405 const group = new Map<string, { total: number; count: number }>()
406 for (const t of d.txns.filter(x => x.currency === c)) {
407 const k = sub === 'top' ? t.email : t.description
408 const g = group.get(k) ?? { total: 0, count: 0 }
409 group.set(k, { total: g.total + t.amount - t.refunded, count: g.count + 1 })
410 }
411 const rows = [...group.entries()].sort((a, b) => b[1].total - a[1].total).slice(0, sub === 'top' ? 5 : 15)
412 return { text: [`${sub === 'top' ? '🏆 ลูกค้าที่จ่ายมากสุด' : '📦 ยอดแยกตามสินค้า'} (30 วัน) ${label}`, ...rows.map(([k, g], i) => `${i + 1}. ${k} - ${money(g.total, c)} (${g.count} ครั้ง)`)].join('\n') }
413 }
414 case 'export': {
415 const root = (await $.session.cwd()).replace(/[\\/]$/, '')
416 const sep = root.includes('\\') ? '\\' : '/'
417 const path = `${root}${sep}stripe-${d.source}-${new Date(now).toISOString().slice(0, 10)}.csv`
418 const csv = ['id,date,amount,refunded,fee,net,currency,description,email', ...d.txns.map(t => [t.id, new Date(t.created).toISOString(), t.amount, t.refunded, t.fee ?? '', t.net ?? '', t.currency, `"${t.description.replace(/"/g, '""')}"`, t.email].join(','))].join('\n')
419 await $.fs.write(path, csv + '\n')
420 return { text: `💾 บันทึก ${d.txns.length} รายการแล้ว: ${path}${d.source === 'live' ? '\n⚠️ ไฟล์มีอีเมลลูกค้า อย่า commit เข้า git' : ''}` }
421 }
422 default: {
423 await $.ui.open({ id: PANE, title: '💰 Stripe' })
424 return { text: summary(d, now) }
425 }
426 }
427 })
428}
429
430/**
431 * ========================================================================
432 * การบ้าน / ต่อยอด
433 * ========================================================================
434 * 1. รีเฟรชอัตโนมัติทุก 15 นาทีด้วย $.clock.every แล้วเด้ง toast เมื่อมียอดขายใหม่
435 * 2. ใช้ Stripe Webhooks + เซิร์ฟเวอร์เล็กๆ ส่งแจ้งเตือนเข้า LINE ทุกครั้งที่ขายได้ (ต่อกับ Mod 12)
436 * 3. ดึงข้อมูลย้อนหลังนานกว่า 30 วันด้วย created[gte] แล้วทำกราฟรายเดือน
437 */
438