diff --git a/README.md b/README.md
index 5195b16..99c9d82 100644
--- a/README.md
+++ b/README.md
@@ -5,6 +5,7 @@
## 功能特性
- ➕ 添加订阅(名称、费用、货币、分类、到期日、周期、续费方式、备注)
+- 🔁 手动续订日期起算方式可选:从续费日起算,或从到期日顺延(提前续费不损失天数)
- 📋 列出订阅并查看详情
- 🗂️ 按分类浏览订阅
- ✏️ 编辑订阅信息
@@ -103,6 +104,7 @@ python SubMind.py
- `frequency_value`(正整数)
- `renewal_type`(`auto` / `manual`)
- `notes`(可选)
+- `renewal_base`(可选,仅手动续费有效:`today`=从续费日起算,`due_date`=从到期日顺延;默认 `today`)
示例:
diff --git a/SubMind.py b/SubMind.py
index 664bacd..dfb2989 100644
--- a/SubMind.py
+++ b/SubMind.py
@@ -51,7 +51,7 @@ AUTO_UPDATE_BRANCH = os.getenv('AUTO_UPDATE_BRANCH', 'main').strip() or 'main'
# --- 对话处理器状态 ---
(ADD_NAME, ADD_COST, ADD_CURRENCY, ADD_CATEGORY, ADD_NEXT_DUE,
- ADD_FREQ_UNIT, ADD_FREQ_VALUE, ADD_RENEWAL_TYPE, ADD_NOTES) = range(9)
+ ADD_FREQ_UNIT, ADD_FREQ_VALUE, ADD_RENEWAL_TYPE, ADD_RENEWAL_BASE, ADD_NOTES) = range(10)
(EDIT_SELECT_FIELD, EDIT_GET_NEW_VALUE, EDIT_FREQ_UNIT, EDIT_FREQ_VALUE) = range(4)
(REMIND_SELECT_ACTION, REMIND_GET_DAYS) = range(2)
(IMPORT_UPLOAD,) = range(1)
@@ -115,6 +115,7 @@ def init_db():
id INTEGER PRIMARY KEY, user_id INTEGER, name TEXT, cost REAL, currency TEXT,
category TEXT, next_due DATE, frequency TEXT,
renewal_type TEXT DEFAULT 'auto',
+ renewal_base TEXT DEFAULT 'today',
reminders_enabled BOOLEAN DEFAULT TRUE,
reminder_days INTEGER DEFAULT 3,
reminder_on_due_date BOOLEAN DEFAULT TRUE,
@@ -140,6 +141,8 @@ def init_db():
cursor.execute("ALTER TABLE subscriptions ADD COLUMN notes TEXT")
if 'last_reminded_date' not in columns:
cursor.execute("ALTER TABLE subscriptions ADD COLUMN last_reminded_date DATE")
+ if 'renewal_base' not in columns:
+ cursor.execute("ALTER TABLE subscriptions ADD COLUMN renewal_base TEXT DEFAULT 'today'")
cursor.execute('''
CREATE TABLE IF NOT EXISTS categories (
@@ -257,6 +260,30 @@ def calculate_new_due_date(base_date, unit, value):
return base_date + delta if delta else None
+# 手动续费的日期起算方式:
+# - today: 以点击续费当天为起点重新计算
+# - due_date: 以上一周期到期日为起点顺延(提前续费不损失天数;已过期则从今天起算)
+VALID_RENEWAL_BASES = {'today', 'due_date'}
+RENEWAL_BASE_LABELS = {
+ 'today': '从续费日起算',
+ 'due_date': '从到期日顺延',
+}
+
+
+def calculate_renewal_due_date(renewal_base, next_due, unit, value):
+ if str(renewal_base or 'today') == 'due_date':
+ try:
+ base = datetime.datetime.strptime(next_due, '%Y-%m-%d').date() if next_due else None
+ except (ValueError, TypeError):
+ base = None
+ today = datetime.date.today()
+ if base is None or base < today:
+ base = today
+ else:
+ base = datetime.date.today()
+ return calculate_new_due_date(base, unit, value)
+
+
def format_frequency(unit, value) -> str:
if not unit or value is None:
return "未知"
@@ -275,6 +302,7 @@ EDITABLE_SUB_FIELDS = {
'category': 'category',
'next_due': 'next_due',
'renewal_type': 'renewal_type',
+ 'renewal_base': 'renewal_base',
'notes': 'notes'
}
MAX_NAME_LEN = 128
@@ -636,7 +664,7 @@ async def export_command(update: Update, context: CallbackContext):
def process_export():
with get_db_connection() as conn:
df = pd.read_sql_query(
- "SELECT name, cost, currency, category, next_due, frequency_unit, frequency_value, renewal_type, notes FROM subscriptions WHERE user_id = ?",
+ "SELECT name, cost, currency, category, next_due, frequency_unit, frequency_value, renewal_type, renewal_base, notes FROM subscriptions WHERE user_id = ?",
conn, params=(user_id,))
if df.empty:
return False, None
@@ -665,7 +693,7 @@ async def export_command(update: Update, context: CallbackContext):
async def import_start(update: Update, context: CallbackContext):
await update.message.reply_text(
- "请上传一个 CSV 文件以导入订阅数据。\n文件应包含以下列:name, cost, currency, category, next_due, frequency_unit, frequency_value, renewal_type, notes(notes 可为空)。")
+ "请上传一个 CSV 文件以导入订阅数据。\n文件应包含以下列:name, cost, currency, category, next_due, frequency_unit, frequency_value, renewal_type, notes(notes 可为空)。\n可选列:renewal_base(手动续费的日期起算方式,today=从续费日起算 / due_date=从到期日顺延,默认 today)。")
return IMPORT_UPLOAD
@@ -690,6 +718,8 @@ async def import_upload_received(update: Update, context: CallbackContext):
valid_units = ['day', 'week', 'month', 'year']
valid_renewal_types = ['auto', 'manual']
+ valid_renewal_bases = ['today', 'due_date']
+ has_renewal_base_col = 'renewal_base' in df.columns
records = []
for _, row in df.iterrows():
try:
@@ -711,6 +741,11 @@ async def import_upload_received(update: Update, context: CallbackContext):
renewal_type = str(row['renewal_type']).lower()
if renewal_type not in valid_renewal_types:
raise ValueError(f"无效续费类型: {renewal_type}")
+ renewal_base = 'today'
+ if renewal_type == 'manual' and has_renewal_base_col and pd.notna(row['renewal_base']):
+ renewal_base = str(row['renewal_base']).lower().strip()
+ if renewal_base not in valid_renewal_bases:
+ raise ValueError(f"无效续订起算方式: {renewal_base}")
notes = str(row['notes']).strip() if pd.notna(row['notes']) else None
if notes and len(notes) > MAX_NOTES_LEN:
raise ValueError(f"备注过长(>{MAX_NOTES_LEN})")
@@ -726,7 +761,7 @@ async def import_upload_received(update: Update, context: CallbackContext):
raise ValueError(f"类别过长(>{MAX_CATEGORY_LEN})")
records.append((
user_id, name, cost, currency, category,
- next_due, frequency_unit, frequency_value, renewal_type, notes
+ next_due, frequency_unit, frequency_value, renewal_type, renewal_base, notes
))
except Exception as e:
logger.error(f"Invalid row in CSV import, error: {e}")
@@ -736,8 +771,8 @@ async def import_upload_received(update: Update, context: CallbackContext):
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.executemany('''
- INSERT INTO subscriptions (user_id, name, cost, currency, category, next_due, frequency_unit, frequency_value, renewal_type, notes)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ INSERT INTO subscriptions (user_id, name, cost, currency, category, next_due, frequency_unit, frequency_value, renewal_type, renewal_base, notes)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', records)
for record in records:
cursor.execute("INSERT OR IGNORE INTO categories (user_id, name) VALUES (?, ?)", (user_id, record[4]))
@@ -916,6 +951,35 @@ async def add_renewal_type_received(update: Update, context: CallbackContext):
await query.edit_message_text("错误:无效的续费类型,请重试。")
return ConversationHandler.END
sub_data['renewal_type'] = renewal_type
+ if renewal_type == 'manual':
+ keyboard = [
+ [InlineKeyboardButton("从续费日起算", callback_data='renewbase_today'),
+ InlineKeyboardButton("从到期日顺延", callback_data='renewbase_due_date')]
+ ]
+ await query.edit_message_text(
+ "第九步:请选择手动续费的日期起算方式\n\n"
+ "- 从续费日起算:以您点击续费当天为起点重新计算\n"
+ "- 从到期日顺延:以上一周期到期日为起点顺延(提前续费不损失天数)",
+ reply_markup=InlineKeyboardMarkup(keyboard), parse_mode='HTML')
+ return ADD_RENEWAL_BASE
+ sub_data['renewal_base'] = 'today'
+ await query.edit_message_text("最后一步(可选):需要添加备注吗?\n(如:共享账号、用途等。不需要请 /skip)")
+ return ADD_NOTES
+
+
+async def add_renewal_base_received(update: Update, context: CallbackContext):
+ sub_data, _ = _get_new_sub_data_or_end(update, context)
+ query = update.callback_query
+ await query.answer()
+ if sub_data is None:
+ await query.edit_message_text("会话已过期,请重新使用 /add_sub 开始。")
+ return ConversationHandler.END
+
+ renewal_base = query.data.partition('renewbase_')[2]
+ if renewal_base not in VALID_RENEWAL_BASES:
+ await query.edit_message_text("错误:无效的日期起算方式,请重试。")
+ return ConversationHandler.END
+ sub_data['renewal_base'] = renewal_base
await query.edit_message_text("最后一步(可选):需要添加备注吗?\n(如:共享账号、用途等。不需要请 /skip)")
return ADD_NOTES
@@ -960,12 +1024,13 @@ def save_subscription(user_id, data):
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
- INSERT INTO subscriptions (user_id, name, cost, currency, category, next_due, frequency_unit, frequency_value, renewal_type, notes)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ INSERT INTO subscriptions (user_id, name, cost, currency, category, next_due, frequency_unit, frequency_value, renewal_type, renewal_base, notes)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (
user_id, data.get('name'), data.get('cost'), data.get('currency'), data.get('category'),
data.get('next_due'),
- data.get('unit'), data.get('value'), data.get('renewal_type', 'auto'), data.get('notes')
+ data.get('unit'), data.get('value'), data.get('renewal_type', 'auto'),
+ data.get('renewal_base', 'today'), data.get('notes')
))
conn.commit()
@@ -1026,12 +1091,17 @@ async def show_subscription_view(update: Update, context: CallbackContext, sub_i
safe_name, safe_category, safe_freq = escape_html(name), escape_html(category), escape_html(freq_text)
cost_str, converted_cost_str = escape_html(f"{cost:.2f}"), escape_html(f"{converted_cost:.2f}")
renewal_text = "手动续费" if renewal_type == 'manual' else "自动续费"
+ renewal_base_text = ""
+ if renewal_type == 'manual':
+ base_label = RENEWAL_BASE_LABELS.get(sub['renewal_base'] or 'today', sub['renewal_base'])
+ renewal_base_text = f"\n- 续订起算: {escape_html(base_label)}"
reminder_status = "开启" if reminders_enabled else "关闭"
text = (f"订阅详情: {safe_name}\n\n"
f"- 费用: {cost_str} {currency.upper()} (~{converted_cost_str} {main_currency.upper()})\n"
f"- 类别: {safe_category}\n"
f"- 下次付款: {next_due} (周期: {safe_freq})\n"
- f"- 续费方式: {renewal_text}\n"
+ f"- 续费方式: {renewal_text}"
+ f"{renewal_base_text}\n"
f"- 提醒状态: {reminder_status}")
if notes:
text += f"\n- 备注: {escape_html(notes)}"
@@ -1122,13 +1192,13 @@ async def button_callback_handler(update: Update, context: CallbackContext):
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute(
- "SELECT frequency_unit, frequency_value FROM subscriptions WHERE id = ? AND user_id = ?",
+ "SELECT next_due, frequency_unit, frequency_value, renewal_base FROM subscriptions WHERE id = ? AND user_id = ?",
(sub_id, user_id)
)
sub = cursor.fetchone()
if sub:
- today = datetime.date.today()
- new_due_date = calculate_new_due_date(today, sub['frequency_unit'], sub['frequency_value'])
+ new_due_date = calculate_renewal_due_date(
+ sub['renewal_base'], sub['next_due'], sub['frequency_unit'], sub['frequency_value'])
if new_due_date:
new_date_str = new_due_date.strftime('%Y-%m-%d')
cursor.execute(
@@ -1147,13 +1217,13 @@ async def button_callback_handler(update: Update, context: CallbackContext):
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute(
- "SELECT name, frequency_unit, frequency_value FROM subscriptions WHERE id = ? AND user_id = ?",
+ "SELECT name, next_due, frequency_unit, frequency_value, renewal_base FROM subscriptions WHERE id = ? AND user_id = ?",
(sub_id, user_id)
)
sub = cursor.fetchone()
if sub:
- today = datetime.date.today()
- new_due_date = calculate_new_due_date(today, sub['frequency_unit'], sub['frequency_value'])
+ new_due_date = calculate_renewal_due_date(
+ sub['renewal_base'], sub['next_due'], sub['frequency_unit'], sub['frequency_value'])
if new_due_date:
new_date_str = new_due_date.strftime('%Y-%m-%d')
cursor.execute(
@@ -1254,8 +1324,9 @@ async def edit_start(update: Update, context: CallbackContext):
[InlineKeyboardButton("下次付款日", callback_data="editfield_next_due"),
InlineKeyboardButton("周期", callback_data="editfield_frequency")],
[InlineKeyboardButton("续费方式", callback_data="editfield_renewal_type"),
- InlineKeyboardButton("📝 备注", callback_data="editfield_notes")],
- [InlineKeyboardButton("« 返回详情", callback_data=f'view_{sub_id}')]
+ InlineKeyboardButton("续订起算", callback_data="editfield_renewal_base")],
+ [InlineKeyboardButton("📝 备注", callback_data="editfield_notes"),
+ InlineKeyboardButton("« 返回详情", callback_data=f'view_{sub_id}')]
]
await query.edit_message_text("请选择您想编辑的字段:", reply_markup=InlineKeyboardMarkup(keyboard))
return EDIT_SELECT_FIELD
@@ -1273,6 +1344,17 @@ async def edit_field_selected(update: Update, context: CallbackContext):
]
await query.edit_message_text("请选择新的续费方式:", reply_markup=InlineKeyboardMarkup(keyboard))
return EDIT_GET_NEW_VALUE
+ if field_to_edit == 'renewal_base':
+ keyboard = [
+ [InlineKeyboardButton("从续费日起算", callback_data='editvalue_today'),
+ InlineKeyboardButton("从到期日顺延", callback_data='editvalue_due_date')]
+ ]
+ await query.edit_message_text(
+ "请选择新的续订日期起算方式:\n\n"
+ "- 从续费日起算:以点击续费当天为起点重新计算\n"
+ "- 从到期日顺延:以上一周期到期日为起点顺延(提前续费不损失天数)",
+ reply_markup=InlineKeyboardMarkup(keyboard), parse_mode='HTML')
+ return EDIT_GET_NEW_VALUE
if field_to_edit == 'frequency':
keyboard = [
[InlineKeyboardButton("天", callback_data='freq_unit_day'),
@@ -1362,7 +1444,7 @@ async def edit_new_value_received(update: Update, context: CallbackContext):
if update.message and update.message.text == '/empty' and field == 'notes':
new_value = None
elif query:
- new_value = query.data.split('_')[1]
+ new_value = query.data.partition('_')[2]
elif update.message:
new_value = update.message.text
else:
@@ -1409,6 +1491,11 @@ async def edit_new_value_received(update: Update, context: CallbackContext):
if message_to_reply:
await message_to_reply.reply_text("续费方式只能为 auto 或 manual。")
validation_failed = True
+ elif field == 'renewal_base':
+ if str(new_value) not in VALID_RENEWAL_BASES:
+ if message_to_reply:
+ await message_to_reply.reply_text("续订起算方式无效。")
+ validation_failed = True
elif field == 'notes':
note_val = str(new_value).strip()
if note_val and len(note_val) > MAX_NOTES_LEN:
@@ -1798,6 +1885,7 @@ def main():
ADD_FREQ_UNIT: [CallbackQueryHandler(add_freq_unit_received, pattern='^freq_unit_')],
ADD_FREQ_VALUE: [MessageHandler(filters.TEXT & ~filters.COMMAND, add_freq_value_received)],
ADD_RENEWAL_TYPE: [CallbackQueryHandler(add_renewal_type_received, pattern='^renewal_')],
+ ADD_RENEWAL_BASE: [CallbackQueryHandler(add_renewal_base_received, pattern='^renewbase_')],
ADD_NOTES: [
MessageHandler(filters.TEXT & ~filters.COMMAND, add_notes_received),
CommandHandler('skip', skip_notes)