Skill: ianchenx/substracker (version 1.1.0)

--- name: substracker description: Manage SubsTracker subscriptions and configuration via CLI scripts. Handles login, subscription CRUD, config updates, notifications, and dashboard queries. Use this skill whenever the user mentions subscriptions, recurring payments, subscription tracking, renewal reminders, payment history, notification configuration (Telegram, Bark, email, webhook, Gotify), or anything related to their SubsTracker instance — even if they just say "add a subscription", "what am I paying for", "check my renewals", "how much do I spend monthly", "update my Telegram config", "加个订阅", "我订阅了什么", "有哪些快到期了", "每月花多少钱", "续费", "订阅提醒", "通知设置", "看下订阅", "账单" without explicitly mentioning SubsTracker. --- # SubsTracker API Skill Manage subscriptions via CLI scripts that wrap the SubsTracker REST API. The scripts handle authentication, cookie management, and retries — you just call a command and get JSON back. ## Skill Structure ``` substracker-skills/ ├── SKILL.md ← You are here └── scripts/ ├── main.ts ← CLI entry point (routing only) ├── client.ts ← HTTP client, env loading, auth ├── types.ts ← TypeScript interfaces (canonical schema + docs) ├── subscriptions.ts ← Subscription commands ├── payments.ts ← Payment commands ├── config.ts ← Config commands ├── dashboard.ts ← Dashboard command └── notifications.ts ← Notification test command ``` Read `scripts/types.ts` for all field names, types, defaults, and descriptions (TSDoc comments). ## Configuration Scripts auto-load credentials from `.env` files. No manual setup in the session. **Load priority** (first found wins): 1. System environment variables 2. `<cwd>/.substracker-skills/.env` 3. `~/.substracker-skills/.env` Required variables in `.env`: ``` SUBSTRACKER_URL=https://sub.example.com SUBSTRACKER_USER=admin SUBSTRACKER_PASS=your_password ``` If variables are missing, the script exits with a clear error. Ask the user to create their `.env` file. ## Running Commands Resolve the runtime: use `bun` if installed, otherwise `npx -y bun`. All commands follow the same pattern: ```bash bun <SKILL_DIR>/scripts/main.ts <command> [subcommand] [--flags] ``` Scripts output JSON to stdout (for you to parse) and log to stderr (for debugging). Authentication and session cookies are handled automatically — if a session expires, the script re-logs in and retries. ## Command Reference ### Login ```bash bun scripts/main.ts login ``` Usually not needed — all commands auto-login when no session exists. Use this to verify credentials. ### Subscriptions ```bash bun scripts/main.ts s list bun scripts/main.ts s create --name "Netflix" --expiry-date 2026-04-07 --amount 15.99 bun scripts/main.ts s get <id> bun scripts/main.ts s update <id> --amount 19.99 bun scripts/main.ts s delete <id> bun scripts/main.ts s toggle <id> --active false bun scripts/main.ts s renew <id> --amount 15.99 --note "March renewal" bun scripts/main.ts s test-notify <id> ``` #### Subscription Flags (create / update) | Flag | Type | Required | Description | |------|------|----------|-------------| | `--name` | string | create only | Subscription name | | `--expiry-date` | string | create only | Expiration date (ISO format) | | `--amount` | number | - | Cost per billing cycle | | `--currency` | string | - | Currency code, default `CNY` | | `--period-unit` | `day\|month\|year` | - | Billing cycle unit, default `month` | | `--period-value` | number | - | Billing cycle length, default `1` | | `--category` | string | - | Category label (e.g. `娱乐`) | | `--custom-type` | string | - | Custom type (e.g. `视频流媒体`) | | `--mode` | `cycle\|reset` | - | Subscription mode | | `--start-date` | string | - | Start date (ISO format) | | `--auto-renew` | `true\|false` | - | Auto-renew flag, default `true` | | `--active` | `true\|false` | - | Active status | | `--reminder-unit` | `day\|hour` | - | Reminder unit | | `--reminder-value` | number | - | Reminder value | | `--reminder-days` | number | - | Reminder days before expiry | | `--lunar` | `true\|false` | - | Use lunar calendar | | `--notes` | string | - | Notes / memo | #### Toggle Flags | Flag | Type | Description | |------|------|-------------| | `--active` | `true\|false` | Set active or inactive | #### Renew Flags | Flag | Type | Description | |------|------|-------------| | `--amount` | number | Payment amount | | `--period-multiplier` | number | Multiply billing cycle | | `--payment-date` | string | Payment date | | `--note` | string | Payment note | ### Payments ```bash bun scripts/main.ts p list <sub-id> bun scripts/main.ts p edit <sub-id> <payment-id> --amount 19.99 --note "adjusted" bun scripts/main.ts p delete <sub-id> <payment-id> ``` #### Payment Edit Flags | Flag | Type | Description | |------|------|-------------| | `--date` | string | Payment date | | `--amount` | number | Payment amount | | `--note` | string | Payment note | ### Dashboard ```bash bun scripts/main.ts d ``` Returns monthly/yearly spend, active count, upcoming renewals, expense breakdown. ### Config ```bash bun scripts/main.ts c get bun scripts/main.ts c update --timezone Asia/Shanghai --notifiers telegram,bark ``` #### Config Flags | Flag | Type | Description | |------|------|-------------| | `--username` | string | Admin username | | `--password` | string | Admin password | | `--timezone` | string | Timezone (e.g. `Asia/Shanghai`) | | `--show-lunar` | `true\|false` | Show lunar calendar dates | | `--theme` | string | Theme mode | | `--notifiers` | string | Comma-separated: `telegram,bark,email,webhook,wechatbot,gotify` | | `--tg-bot-token` | string | Telegram bot token | | `--tg-chat-id` | string | Telegram chat ID | | `--bark-key` | string | Bark device key | | `--bark-server` | string | Bark server URL | | `--webhook-url` | string | Webhook URL | | `--webhook-method` | string | Webhook HTTP method | | `--webhook-template` | string | Webhook body template | | `--wechat-webhook` | string | WeChat bot webhook URL | | `--gotify-url` | string | Gotify server URL | | `--gotify-token` | string | Gotify app token | | `--email-from` | string | Sender email | | `--email-to` | string | Recipient email | | `--resend-key` | string | Resend API key | | `--clear-secrets` | string | Comma-separated fields to clear | | `--debug` | `true\|false` | Enable debug logs | | `--payment-history-limit` | number | Max payment history entries | ### Test Notification ```bash bun scripts/main.ts t --type telegram ``` | Flag | Type | Required | Description | |------|------|----------|-------------| | `--type` | string | ✓ | `telegram\|notifyx\|webhook\|wechatbot\|email\|bark\|gotify` | | Other flags | - | - | Passed as config overrides (e.g. `--tg-bot-token` → `TG_BOT_TOKEN`) | ## Presenting Results When showing subscription data to the user, format JSON responses as readable tables or summaries — don't dump raw JSON. For example, a subscription list should look like a table with name, amount, cycle, next renewal date, and status. ## Error Handling - **Missing env vars**: script exits with instructions to create `.env` - **401 Unauthorized**: auto re-login and retry (once) - **API errors**: response includes `success: false` and `message` explaining what went wrong

path: scripts/client.ts
content: // SubsTracker HTTP Client — env loading, auth, cookie management
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import type { SubsTrackerEnv } from "./types";

const COOKIE_PATH = path.join(os.homedir(), ".substracker-skills", "cookie");

// ─── Env Loading (baoyu-style) ───

function loadEnvFile(envPath: string): Record<string, string> {
  const env: Record<string, string> = {};
  if (!fs.existsSync(envPath)) return env;

  const content = fs.readFileSync(envPath, "utf-8");
  for (const line of content.split("\n")) {
    const trimmed = line.trim();
    if (!trimmed || trimmed.startsWith("#")) continue;
    const eqIdx = trimmed.indexOf("=");
    if (eqIdx > 0) {
      const key = trimmed.slice(0, eqIdx).trim();
      let value = trimmed.slice(eqIdx + 1).trim();
      if (
        (value.startsWith('"') && value.endsWith('"')) ||
        (value.startsWith("'") && value.endsWith("'"))
      ) {
        value = value.slice(1, -1);
      }
      env[key] = value;
    }
  }
  return env;
}

export function loadConfig(): SubsTrackerEnv {
  const cwdEnvPath = path.join(process.cwd(), ".substracker-skills", ".env");
  const homeEnvPath = path.join(os.homedir(), ".substracker-skills", ".env");

  const cwdEnv = loadEnvFile(cwdEnvPath);
  const homeEnv = loadEnvFile(homeEnvPath);

  const url = process.env.SUBSTRACKER_URL || cwdEnv.SUBSTRACKER_URL || homeEnv.SUBSTRACKER_URL;
  const user = process.env.SUBSTRACKER_USER || cwdEnv.SUBSTRACKER_USER || homeEnv.SUBSTRACKER_USER;
  const pass = process.env.SUBSTRACKER_PASS || cwdEnv.SUBSTRACKER_PASS || homeEnv.SUBSTRACKER_PASS;

  const missing = [
    !url && "SUBSTRACKER_URL",
    !user && "SUBSTRACKER_USER",
    !pass && "SUBSTRACKER_PASS",
  ].filter(Boolean);

  if (missing.length) {
    throw new Error(
      `Missing: ${missing.join(", ")}.\n` +
      "Set via environment variables or in .substracker-skills/.env file."
    );
  }

  return { SUBSTRACKER_URL: url.replace(/\/+$/, ""), SUBSTRACKER_USER: user, SUBSTRACKER_PASS: pass };
}

// ─── Cookie ───

function saveCookie(setCookieHeader: string): void {
  fs.mkdirSync(path.dirname(COOKIE_PATH), { recursive: true });
  fs.writeFileSync(COOKIE_PATH, setCookieHeader, "utf-8");
}

function loadCookie(): string {
  if (!fs.existsSync(COOKIE_PATH)) return "";
  return fs.readFileSync(COOKIE_PATH, "utf-8").trim();
}

export function hasCookie(): boolean {
  return !!loadCookie();
}

// ─── Auth ───

let _config: SubsTrackerEnv | null = null;

export function init(cfg: SubsTrackerEnv): void {
  _config = cfg;
}

function getConfig(): SubsTrackerEnv {
  if (!_config) throw new Error("Client not initialized. Call init() first.");
  return _config;
}

export async function login(): Promise<boolean> {
  const cfg = getConfig();
  const res = await fetch(`${cfg.SUBSTRACKER_URL}/api/login`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ username: cfg.SUBSTRACKER_USER, [REDACTED_SECRET] }),
    redirect: "manual",
  });

  const setCookie = res.headers.get("set-cookie");
  if (setCookie) saveCookie(setCookie);

  const data = (await res.json()) as { success: boolean };
  return data.success === true;
}

// ─── Generic API Call ───

export async function api<T = unknown>(
  method: string,
  endpoint: string,
  body?: unknown,
  retried = false
): Promise<T> {
  const cfg = getConfig();
  const cookie = loadCookie();
  const headers: Record<string, string> = { "Content-Type": "application/json" };

  if (cookie) {
    const match = cookie.match(/([^=]+=[^;]+)/);
    if (match) headers["Cookie"] = match[1]!;
  }

  const res = await fetch(`${cfg.SUBSTRACKER_URL}${endpoint}`, {
    method,
    headers,
    body: body ? JSON.stringify(body) : undefined,
    redirect: "manual",
  });

  if (res.status === 401 && !retried) {
    console.error("[substracker] Session expired, re-logging in...");
    const ok = await login();
    if (ok) return api<T>(method, endpoint, body, true);
    throw new Error("Login failed");
  }

  return (await res.json()) as T;
}


path: scripts/config.ts
content: // Config commands
import { api } from "./client";
import type { AppConfig } from "./types";

export async function get() {
  return api("GET", "/api/config");
}

export async function update(flags: Record<string, string | boolean>) {
  const cfg: AppConfig = {};
  if (flags["username"]) cfg.ADMIN_USERNAME = flags["username"] as string;
  if (flags["password"]) cfg.ADMIN_PASSWORD = flags["password"] as string;
  if (flags["tg-bot-token"]) cfg.TG_BOT_TOKEN = flags["tg-bot-token"] as string;
  if (flags["tg-chat-id"]) cfg.TG_CHAT_ID = flags["tg-chat-id"] as string;
  if (flags["timezone"]) cfg.TIMEZONE = flags["timezone"] as string;
  if (flags["show-lunar"] !== undefined) cfg.SHOW_LUNAR = flags["show-lunar"] !== "false";
  if (flags["theme"]) cfg.THEME_MODE = flags["theme"] as string;
  if (flags["notifiers"]) cfg.ENABLED_NOTIFIERS = (flags["notifiers"] as string).split(",");
  if (flags["webhook-url"]) cfg.WEBHOOK_URL = flags["webhook-url"] as string;
  if (flags["webhook-method"]) cfg.WEBHOOK_METHOD = flags["webhook-method"] as string;
  if (flags["webhook-template"]) cfg.WEBHOOK_TEMPLATE = flags["webhook-template"] as string;
  if (flags["wechat-webhook"]) cfg.WECHATBOT_WEBHOOK = flags["wechat-webhook"] as string;
  if (flags["bark-key"]) cfg.BARK_DEVICE_KEY = flags["bark-key"] as string;
  if (flags["bark-server"]) cfg.BARK_SERVER = flags["bark-server"] as string;
  if (flags["gotify-url"]) cfg.GOTIFY_SERVER_URL = flags["gotify-url"] as string;
  if (flags["gotify-token"]) cfg.GOTIFY_APP_TOKEN = flags["gotify-token"] as string;
  if (flags["email-from"]) cfg.EMAIL_FROM = flags["email-from"] as string;
  if (flags["email-to"]) cfg.EMAIL_TO = flags["email-to"] as string;
  if (flags["resend-key"]) cfg.RESEND_API_KEY = flags["resend-key"] as string;
  if (flags["clear-secrets"]) cfg.CLEAR_SECRET_FIELDS = (flags["clear-secrets"] as string).split(",");
  if (flags["debug"] !== undefined) cfg.DEBUG_LOGS = flags["debug"] !== "false";
  if (flags["payment-history-limit"]) cfg.PAYMENT_HISTORY_LIMIT = Number(flags["payment-history-limit"]);
  return api("POST", "/api/config", cfg);
}


path: scripts/dashboard.ts
content: // Dashboard command
import { api } from "./client";

export async function stats() {
  return api("GET", "/api/dashboard/stats");
}


path: scripts/main.ts
content: #!/usr/bin/env bun
// SubsTracker CLI — thin routing layer
// Usage: bun scripts/main.ts <command> [subcommand] [--flags]

import { loadConfig, init, login, hasCookie } from "./client";
import * as subscriptions from "./subscriptions";
import * as payments from "./payments";
import * as config from "./config";
import * as dashboard from "./dashboard";
import * as notifications from "./notifications";

// ─── CLI Helpers ───

function parseFlags(args: string[]): Record<string, string | boolean> {
  const flags: Record<string, string | boolean> = {};
  for (let i = 0; i < args.length; i++) {
    const arg = args[i]!;
    if (arg.startsWith("--")) {
      const key = arg.slice(2);
      const next = args[i + 1];
      if (next && !next.startsWith("--")) {
        flags[key] = next;
        i++;
      } else {
        flags[key] = true;
      }
    }
  }
  return flags;
}

function positional(args: string[]): string[] {
  return args.filter((a) => !a.startsWith("--"));
}

function out(data: unknown): void {
  console.log(JSON.stringify(data, null, 2));
}

// ─── Help ───

function printHelp(): void {
  console.log(`SubsTracker CLI

Usage: bun scripts/main.ts <command> [subcommand] [--flags]

Commands:
  login
  subscriptions list | create | get <id> | update <id> | delete <id>
                toggle <id> | renew <id> | test-notify <id>
  payments      list <sub-id> | edit <sub-id> <pid> | delete <sub-id> <pid>
  dashboard
  config        get | update
  test-notification --type telegram|bark|...

Aliases: s=subscriptions  p=payments  d=dashboard  c=config  t=test-notification

Env: SUBSTRACKER_URL, SUBSTRACKER_USER, SUBSTRACKER_PASS
  Or .substracker-skills/.env (project) | ~/.substracker-skills/.env (user)`);
}

// ─── Router ───

async function main(): Promise<void> {
  const args = process.argv.slice(2);

  if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
    printHelp();
    return;
  }

  const cfg = loadConfig();
  init(cfg);

  const command = args[0]!;
  const sub = args[1] || "";
  const rest = args.slice(2);
  const flags = parseFlags(rest);
  const pos = positional(rest);

  // auto-login
  if (command !== "login" && !hasCookie()) {
    console.error("[substracker] No session, logging in...");
    if (!(await login())) { console.error("[substracker] Login failed"); process.exit(1); }
    console.error("[substracker] OK");
  }

  switch (command) {
    case "login":
      out({ success: await login() });
      break;

    // ── subscriptions ──
    case "subscriptions": case "sub": case "s":
      switch (sub) {
        case "list":        out(await subscriptions.list()); break;
        case "get":         out(await subscriptions.get(pos[0]!)); break;
        case "create":      out(await subscriptions.create(flags)); break;
        case "update":      out(await subscriptions.update(pos[0]!, flags)); break;
        case "delete":      out(await subscriptions.del(pos[0]!)); break;
        case "toggle":      out(await subscriptions.toggle(pos[0]!, flags)); break;
        case "renew":       out(await subscriptions.renew(pos[0]!, flags)); break;
        case "test-notify": out(await subscriptions.testNotify(pos[0]!)); break;
        default: throw new Error(`Unknown: subscriptions ${sub}`);
      }
      break;

    // ── payments ──
    case "payments": case "pay": case "p":
      switch (sub) {
        case "list":   out(await payments.list(pos[0]!)); break;
        case "edit":   out(await payments.edit(pos[0]!, pos[1]!, flags)); break;
        case "delete": out(await payments.del(pos[0]!, pos[1]!)); break;
        default: throw new Error(`Unknown: payments ${sub}`);
      }
      break;

    // ── dashboard ──
    case "dashboard": case "dash": case "d":
      out(await dashboard.stats());
      break;

    // ── config ──
    case "config": case "cfg": case "c":
      switch (sub) {
        case "get":    out(await config.get()); break;
        case "update": out(await config.update(parseFlags(args.slice(2)))); break;
        default: throw new Error(`Unknown: config ${sub}`);
      }
      break;

    // ── test notification ──
    case "test-notification": case "test": case "t":
      out(await notifications.test(parseFlags(args.slice(1))));
      break;

    default:
      console.error(`Unknown command: ${command}`);
      printHelp();
      process.exit(1);
  }
}

await main().catch((err) => {
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
  process.exit(1);
});


path: scripts/notifications.ts
content: // Notification test command
import { api } from "./client";
import type { TestNotificationInput } from "./types";

export async function test(flags: Record<string, string | boolean>) {
  if (!flags["type"]) throw new Error("Required: --type (telegram|notifyx|webhook|wechatbot|email|bark|gotify)");

  const input: TestNotificationInput = {
    type: flags["type"] as TestNotificationInput["type"],
  };

  // pass through config overrides (e.g. --tg-bot-token → TG_BOT_TOKEN)
  for (const [k, v] of Object.entries(flags)) {
    if (k !== "type") {
      const configKey = k
        .split("-")
        .map((s) => s.toUpperCase())
        .join("_");
      (input as Record<string, unknown>)[configKey] = v;
    }
  }

  return api("POST", "/api/test-notification", input);
}


path: scripts/payments.ts
content: // Payment commands
import { api } from "./client";
import type { EditPaymentInput } from "./types";

export async function list(subId: string) {
  return api("GET", `/api/subscriptions/${subId}/payments`);
}

export async function edit(subId: string, paymentId: string, flags: Record<string, string | boolean>) {
  const input: EditPaymentInput = {};
  if (flags["date"]) input.date = flags["date"] as string;
  if (flags["amount"]) input.amount = Number(flags["amount"]);
  if (flags["note"]) input.note = flags["note"] as string;
  return api("PUT", `/api/subscriptions/${subId}/payments/${paymentId}`, input);
}

export async function del(subId: string, paymentId: string) {
  return api("DELETE", `/api/subscriptions/${subId}/payments/${paymentId}`);
}


path: scripts/subscriptions.ts
content: // Subscription commands
import { api } from "./client";
import type { CreateSubscriptionInput, RenewOptions } from "./types";

export function flagsToSubscription(flags: Record<string, string | boolean>): CreateSubscriptionInput {
  const sub: CreateSubscriptionInput = {
    name: flags["name"] as string,
    expiryDate: (flags["expiry-date"] || flags["expiryDate"]) as string,
  };
  if (flags["period-value"]) sub.periodValue = Number(flags["period-value"]);
  if (flags["period-unit"]) sub.periodUnit = flags["period-unit"] as "day" | "month" | "year";
  if (flags["amount"]) sub.amount = Number(flags["amount"]);
  if (flags["currency"]) sub.currency = flags["currency"] as string;
  if (flags["auto-renew"] !== undefined) sub.autoRenew = flags["auto-renew"] !== "false";
  if (flags["reminder-unit"]) sub.reminderUnit = flags["reminder-unit"] as "day" | "hour";
  if (flags["reminder-value"]) sub.reminderValue = Number(flags["reminder-value"]);
  if (flags["reminder-days"]) sub.reminderDays = Number(flags["reminder-days"]);
  if (flags["active"] !== undefined) sub.isActive = flags["active"] !== "false";
  if (flags["notes"]) sub.notes = flags["notes"] as string;
  if (flags["category"]) sub.category = flags["category"] as string;
  if (flags["custom-type"]) sub.customType = flags["custom-type"] as string;
  if (flags["mode"]) sub.subscriptionMode = flags["mode"] as "cycle" | "reset";
  if (flags["start-date"]) sub.startDate = flags["start-date"] as string;
  if (flags["lunar"]) sub.useLunar = flags["lunar"] !== "false";
  return sub;
}

export async function list() {
  return api("GET", "/api/subscriptions");
}

export async function get(id: string) {
  return api("GET", `/api/subscriptions/${id}`);
}

export async function create(flags: Record<string, string | boolean>) {
  const input = flagsToSubscription(flags);
  if (!input.name || !input.expiryDate) throw new Error("Required: --name and --expiry-date");
  return api("POST", "/api/subscriptions", input);
}

export async function update(id: string, flags: Record<string, string | boolean>) {
  // GET current → merge flags → PUT (partial update support)
  const current = await api<{ subscription: Record<string, unknown> }>("GET", `/api/subscriptions/${id}`);
  const existing = current.subscription;
  const patch = flagsToSubscription(flags);

  const merged: Record<string, unknown> = { ...existing };
  for (const [k, v] of Object.entries(patch)) {
    if (v !== undefined) merged[k] = v;
  }
  return api("PUT", `/api/subscriptions/${id}`, merged);
}

export async function del(id: string) {
  return api("DELETE", `/api/subscriptions/${id}`);
}

export async function toggle(id: string, flags: Record<string, string | boolean>) {
  const isActive = flags["active"] !== "false";
  return api("POST", `/api/subscriptions/${id}/toggle-status`, { isActive });
}

export async function renew(id: string, flags: Record<string, string | boolean>) {
  const opts: RenewOptions = {};
  if (flags["amount"]) opts.amount = Number(flags["amount"]);
  if (flags["period-multiplier"]) opts.periodMultiplier = Number(flags["period-multiplier"]);
  if (flags["payment-date"]) opts.paymentDate = flags["payment-date"] as string;
  if (flags["note"]) opts.note = flags["note"] as string;
  return api("POST", `/api/subscriptions/${id}/renew`, opts);
}

export async function testNotify(id: string) {
  return api("POST", `/api/subscriptions/${id}/test-notify`);
}


path: scripts/types.ts
content: // SubsTracker API TypeScript Interfaces
// These serve as the canonical schema for all API operations.

// ─── Environment Config ───

export interface SubsTrackerEnv {
  SUBSTRACKER_URL: string;
  SUBSTRACKER_USER: string;
  SUBSTRACKER_PASS: string;
}

// ─── Subscription ───

export interface CreateSubscriptionInput {
  /** Subscription name (required) */
  name: string;
  /** Next renewal date, YYYY-MM-DD (required) */
  expiryDate: string;
  /** Cycle length, e.g. 1, 3, 6. Default: 1 */
  periodValue?: number;
  /** Cycle unit. Default: "month" */
  periodUnit?: "day" | "month" | "year";
  /** Price per cycle */
  amount?: number;
  /** Currency code. Default: "CNY" */
  currency?: string;
  /** Auto-renew on expiry. Default: true */
  autoRenew?: boolean;
  /** Reminder unit. Default: "day" */
  reminderUnit?: "day" | "hour";
  /** Days/hours before expiry to notify */
  reminderValue?: number;
  /** Legacy alias for reminderValue (day) */
  reminderDays?: number;
  /** Legacy alias for reminderValue (hour) */
  reminderHours?: number;
  /** Enable/disable. Default: true */
  isActive?: boolean;
  /** Free text notes */
  notes?: string;
  /** e.g. "娱乐", "工具", "云服务" */
  category?: string;
  /** e.g. "视频流媒体", "域名" */
  customType?: string;
  /** "cycle" = 累计续期, "reset" = 从当天起算. Default: "cycle" */
  subscriptionMode?: "cycle" | "reset";
  /** Subscription start date, ISO format */
  startDate?: string;
  /** Use lunar calendar for cycle calculation. Default: false */
  useLunar?: boolean;
}

export interface UpdateSubscriptionInput extends CreateSubscriptionInput {
  // same fields, name and expiryDate still required
}

export interface PaymentRecord {
  id: string;
  date: string;
  amount: number;
  /** "initial" = first payment, "manual" = manual renew, "auto" = auto renew */
  type: "initial" | "manual" | "auto";
  note: string;
  periodStart?: string;
  periodEnd?: string;
}

export interface Subscription {
  id: string;
  name: string;
  subscriptionMode: string;
  customType: string;
  category: string;
  startDate: string | null;
  expiryDate: string;
  periodValue: number;
  periodUnit: string;
  reminderUnit: string;
  reminderValue: number;
  reminderDays?: number;
  reminderHours?: number;
  notes: string;
  amount: number | null;
  currency: string;
  lastPaymentDate: string;
  paymentHistory: PaymentRecord[];
  isActive: boolean;
  autoRenew: boolean;
  useLunar: boolean;
  createdAt: string;
  updatedAt?: string;
}

// ─── Renew ───

export interface RenewOptions {
  /** Payment amount. Default: subscription's current amount */
  amount?: number;
  /** How many cycles to renew. Default: 1 */
  periodMultiplier?: number;
  /** ISO datetime, when payment was made. Default: now */
  paymentDate?: string;
  /** Payment note. Default: "手动续订" */
  note?: string;
}

// ─── Payment Edit ───

export interface EditPaymentInput {
  /** ISO datetime */
  date?: string;
  amount?: number;
  note?: string;
}

// ─── Dashboard ───

export interface DashboardStats {
  monthlyExpense: number;
  yearlyExpense: number;
  activeSubscriptions: {
    active: number;
    total: number;
    expiringSoon: number;
  };
  recentPayments: unknown[];
  upcomingRenewals: unknown[];
  expenseByType: unknown[];
  expenseByCategory: unknown[];
  schedulerStatus: unknown;
  schedulerStatusHistory: unknown[];
}

// ─── App Config ───
//
// Secret handling when updating config:
// - Sending "" does NOT clear a secret (prevents accidental wipes)
// - Sending "********" = no change (legacy compat)
// - To clear a [REDACTED_SECRET] its name in CLEAR_SECRET_FIELDS array
// - ADMIN_PASSWORD only changes when non-empty

export interface AppConfig {
  // ── Account ──
  /** Login username */
  ADMIN_USERNAME?: string;
  /** Set only to change password */
  ADMIN_PASSWORD?: string;

  // ── Telegram ──
  /** Bot token from @BotFather (secret) */
  TG_BOT_TOKEN?: string;
  /** Chat or user ID */
  TG_CHAT_ID?: string;

  // ── NotifyX ──
  /** NotifyX API key (secret) */
  NOTIFYX_API_KEY?: string;

  // ── Webhook ──
  /** Webhook endpoint URL (secret) */
  WEBHOOK_URL?: string;
  /** HTTP method. Default: "POST" */
  WEBHOOK_METHOD?: string;
  /** JSON string of custom headers (secret) */
  WEBHOOK_HEADERS?: string;
  /** Body template with {{title}}, {{content}}, {{tags}}, {{timestamp}} */
  WEBHOOK_TEMPLATE?: string;

  // ── WeChat Bot ──
  /** WeChat bot webhook URL (secret) */
  WECHATBOT_WEBHOOK?: string;
  /** Message type: "text" | "markdown". Default: "text" */
  WECHATBOT_MSG_TYPE?: string;
  /** Comma-separated mobile numbers to @mention */
  WECHATBOT_AT_MOBILES?: string;
  /** "true" to @all. Default: "false" */
  WECHATBOT_AT_ALL?: string;

  // ── Email (Resend) ──
  /** Resend API key (secret) */
  RESEND_API_KEY?: string;
  /** Sender email address */
  EMAIL_FROM?: string;
  /** Sender display name */
  EMAIL_FROM_NAME?: string;
  /** Recipient email address */
  EMAIL_TO?: string;

  // ── Bark ──
  /** iOS Bark device key (secret) */
  BARK_DEVICE_KEY?: string;
  /** Bark server URL. Default: "https://api.day.app" */
  BARK_SERVER?: string;
  /** Archive notifications: "true" | "false". Default: "false" */
  BARK_IS_ARCHIVE?: string;

  // ── Gotify ──
  /** Gotify server URL */
  GOTIFY_SERVER_URL?: string;
  /** Gotify app token (secret) */
  GOTIFY_APP_TOKEN?: string;

  // ── General ──
  /** Enabled notification channels. Default: ["notifyx"] */
  ENABLED_NOTIFIERS?: string[];
  /** Timezone, e.g. "Asia/Shanghai". Default: "UTC" */
  TIMEZONE?: string;
  /** Show lunar calendar dates. Default: false */
  SHOW_LUNAR?: boolean;
  /** UI theme: "light" | "dark" | "system". Default: "system" */
  THEME_MODE?: string;
  /** Allowed notification hours (UTC). Empty = always. Default: [] */
  NOTIFICATION_HOURS?: number[];
  /** Token for external /api/notify endpoint (secret) */
  THIRD_PARTY_API_TOKEN?: string;
  /** Enable debug logging. Default: false */
  DEBUG_LOGS?: boolean;
  /** Payment history limit per subscription. Range: 10–1000. Default: 100 */
  PAYMENT_HISTORY_LIMIT?: number;
  /** Field names to explicitly clear their secret values */
  CLEAR_SECRET_FIELDS?: string[];
}

// ─── Test Notification ───

export interface TestNotificationInput {
  /** Channel to test */
  type: "telegram" | "notifyx" | "webhook" | "wechatbot" | "email" | "bark" | "gotify";
  /** Optional config overrides for the channel being tested */
  [key: string]: unknown;
}

// ─── API Response ───

export interface ApiResponse<T = unknown> {
  success: boolean;
  message?: string;
  subscription?: T;
  data?: T;
  payments?: T;
}


Security scan verdicts — ClawScan: suspicious | static analysis: suspicious | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)