SLOPSHOPPER

stripe-dashboard-th

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

newpanecommandnetwork
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · stripe-dashboard-th
│ ┃ stripe-dashboard-th ✕ › fix the failing auth test and add an audit log call │ ┃ พิมพ์ /stripe เพื่อโหลดข้อมูล │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /stripe │ ⎿ stripe-dashboard-th: 🔑 ยังไม่ได้ตั้ง STRIPE_SECRET_KEY │ ⎿ stripe-dashboard-th: 1. ไปที่ https://dashboard.stripe.com/apike │ ⎿ stripe-dashboard-th: (ให้สิทธิ์ Read ที่ Charges, Balance tra │ ⎿ stripe-dashboard-th: 2. ตั้งตัวแปรก่อนเปิด claude แล้วเปิดใหม่: │ ⎿ stripe-dashboard-th: PowerShell: $env:STRIPE_SECRET_KEY = "r │ ⎿ stripe-dashboard-th: macOS/Linux: export STRIPE_SECRET_KEY=rk │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · stripe-dashboard-th
พิมพ์ /stripe เพื่อโหลดข้อมูล
README

Mod 16: Stripe Dashboard TH

ดูยอดขายจาก 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เปิด/ปิดโหมดข้อมูลตัวอย่าง

ตั้งค่า key

  1. ไปที่ https://dashboard.stripe.com/apikeys แล้วสร้าง Restricted key ที่ให้สิทธิ์ Read สำหรับ Charges, Balance transactions และ Subscriptions
  2. ตั้งตัวแปร environment ก่อนเปิด 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 แล้วอ่านตามบทที่เขียนไว้ในคอมเมนต์:

  1. เรียก REST API ด้วย $.http.fetch และดึงข้อมูลหลายหน้าด้วย starting_after
  2. แปลงข้อมูลดิบ: สตางค์เป็นบาท, สกุลเงินที่ไม่มีทศนิยม และคำนวณ MRR จากรอบบิลต่างๆ
  3. ข้อมูลตัวอย่างแบบสุ่มด้วย seed (ได้ผลเหมือนเดิมทุกครั้ง)
  4. อ่าน secret จาก env ด้วย $.env.get และ cache ข้อมูล
  5. คำนวณสถิติแยกตามสกุลเงิน
  6. dashboard ในแผง: การ์ดยอดและกราฟแท่ง
  7. คำสั่งที่มี subcommand และการส่งออก CSV

หมายเหตุ

  • Mod นี้อ่านข้อมูลอย่างเดียว ไม่สร้าง แก้ไข หรือคืนเงินใน Stripe
  • ดึงข้อมูลย้อนหลัง 31 วัน สูงสุด 1,000 รายการ
  • ทดสอบกับ Stripe API จำลองแล้ว (pagination, ค่าธรรมเนียม, MRR) แต่ยังไม่ได้ลองกับบัญชี Stripe จริง แนะนำให้ลองด้วย test key (rk_test_...) ก่อน
  • ไฟล์ CSV มีอีเมลลูกค้า อย่า commit เข้า git
Source 1 files
hooks/register.tsx 438 lines
1/**
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