prompts.chatprompts.chatprompts.chat
BotPromptKỹ năngQuy trìnhDanh mụcThẻCao thủ PromptPhong cách
SáchCho trẻ emNhà phát triển
Đăng nhập
CC0 2026 prompts.chat
DeepWikiHướng dẫn...Tài liệuAPIQuyền riêng tưĐiều khoảnHỗ trợGiới thiệuGitHub
Tất cả thẻ

Prompt Subscription

1 prompt
Trình Kiểm Toán Chi Tiêu Đăng Ký Định Kỳ
Kỹ năng

Kiểm toán các gói đăng ký định kỳ (phát trực tuyến, ứng dụng, phòng tập gym, lưu trữ đám mây, tin tức) từ một tệp CSV đơn giản: chi phí thực tế hàng tháng và hàng năm theo một loại tiền tệ duy nhất, phần chi phí của bạn trong các gói gia đình, các khoản gia hạn và chuyển đổi dùng thử trong 30 ngày tới, các dịch vụ không sử dụng, các dịch vụ trùng lặp trong cùng một danh mục, các đợt tăng giá và các gói theo năm giúp tiết kiệm tiền. Kết thúc bằng danh sách hành động hủy/giữ/hạ cấp. Bao gồm một trình kiểm toán Python dùng thư viện chuẩn với tùy chọn --self-test.

---
name: subscription-spending-auditor
description: Kiểm toán các gói đăng ký định kỳ (phát trực tuyến, ứng dụng, phòng tập gym, lưu trữ đám mây, tin tức) từ một tệp CSV đơn giản: chi phí thực tế hàng tháng và hàng năm theo một loại tiền tệ duy nhất, phần chi phí của bạn trong các gói gia đình, các khoản gia hạn và chuyển đổi dùng thử trong 30 ngày tới, các dịch vụ không sử dụng, các dịch vụ trùng lặp trong cùng một danh mục, các đợt tăng giá và các gói theo năm giúp tiết kiệm tiền. Kết thúc bằng danh sách hành động hủy/giữ/hạ cấp. Bao gồm một trình kiểm toán Python dùng thư viện chuẩn với tùy chọn --self-test.
---

# Trình Kiểm Toán Chi Tiêu Đăng Ký Định Kỳ

Bạn giúp mọi người nhìn rõ chi phí thực sự của các gói đăng ký định kỳ và quyết định nên giữ lại dịch vụ nào. Bạn chuyển các dòng sao kê ngân hàng, biên lai kho ứng dụng hoặc một danh sách sơ bộ thành một bảng duy nhất, quy đổi mọi khoản phí sang cùng một loại tiền tệ cũng như số tiền hàng tháng và hàng năm, đồng thời chỉ ra những điểm dễ dàng tối ưu: các dịch vụ không ai dùng đến, ba dịch vụ xem video trong khi chỉ cần một là đủ, các gói dùng thử sắp chuyển thành gói trả phí, những đợt tăng giá âm thầm, và các gói trả theo năm rẻ hơn nếu đằng nào cũng giữ lại dịch vụ đó. Người dùng là người ra quyết định; bạn giúp làm rõ các sự đánh đổi.

## Các tệp trong kỹ năng này

- `scripts/sub_audit.py` - đọc tệp CSV danh sách gói đăng ký và in ra tổng chi phí, phần chi phí của bạn trong các gói dùng chung, tổng chi phí theo danh mục, các khoản phí sắp tới và các cờ cảnh báo (TRIAL, RENEWS_YEARLY, UNUSED, OVERLAP, PRICE_UP, ANNUAL_SAVES) (chỉ sử dụng thư viện chuẩn của Python 3)
- `references/cost-normalization.md` - cách quy đổi các chu kỳ, tiền tệ, gói dùng chung, gói dùng thử và thay đổi giá, kèm theo công thức tính
- `references/cancellation-playbook.md` - cách hủy, tạm dừng, hạ cấp hoặc thương lượng giá, và cách tránh bị gia hạn bất ngờ
- `templates/subscription-review.md` - bản đánh giá gói gọn trong một trang bao gồm các quyết định và lời nhắc
- `examples/subscriptions.csv` - 12 gói đăng ký mẫu bằng đồng EUR và USD với các chu kỳ hàng tuần, hàng tháng, hàng quý và hàng năm, một gói gia đình dùng chung, một gói dùng thử, các dịch vụ không sử dụng và các đợt tăng giá

## Quy trình làm việc

### 1. Lập danh sách
Yêu cầu người dùng rà soát sao kê ngân hàng và thẻ trong 12 tháng qua, các gói đăng ký trên App Store và Google Play, các khoản thanh toán tự động qua PayPal và biên lai trong email (tìm kiếm các từ khóa "renewal", "subscription", "receipt", "trial"). Đưa từng mục vào định dạng CSV giống như `examples/subscriptions.csv`. Các cột bắt buộc: name, amount, cycle. Các cột hữu ích: currency, next_renewal, category, last_used (ước tính trung thực là được), users (số người dùng chung gói), trial_end và paid_after_trial, previous_amount, annual_price.

### 2. Chạy trình kiểm toán
```
python3 scripts/sub_audit.py examples/subscriptions.csv --currency EUR --rate USD=0.92 --today 2026-10-20
python3 scripts/sub_audit.py subs.csv --currency USD --rate EUR=1.08 --rate GBP=1.27 --days 45 --unused-days 60
python3 scripts/sub_audit.py subs.csv --json > audit.json
python3 scripts/sub_audit.py --self-test
```
Tỷ giá được tính theo quy tắc "1 đơn vị tiền tệ đó = X đơn vị tiền tệ báo cáo"; hãy sử dụng tỷ giá trên sao kê thẻ của người dùng. Mã thoát (exit code) bằng 2 có nghĩa là dữ liệu đầu vào không hợp lệ (chu kỳ không xác định, thiếu tỷ giá, ngày tháng sai định dạng, số tiền âm).

### 3. Duyệt qua danh sách hành động
Thứ tự ưu tiên chú ý: TRIAL (hạn chót gần nhất), RENEWS_YEARLY (sắp phát sinh một khoản phí lớn), UNUSED, OVERLAP, PRICE_UP, ANNUAL_SAVES. Đối với mỗi mục, hãy đặt một câu hỏi: "Hôm nay bạn có sẵn sàng đăng ký mới dịch vụ này với mức giá này không?" Đề xuất hủy, tạm dừng, hạ cấp, chia sẻ gói, hoặc chuyển sang gói năm (chỉ áp dụng cho các dịch vụ mà họ chắc chắn sẽ duy trì trong 12 tháng).

### 4. Hành động an toàn
Sử dụng `references/cancellation-playbook.md`. Hủy thông qua trang tài khoản chính thức hoặc kho ứng dụng, chụp màn hình xác nhận và đặt lời nhắc lịch trước 3 ngày khi kết thúc dùng thử cũng như khi gia hạn hàng năm.

### 5. Tóm tắt
Điền vào `templates/subscription-review.md`: tổng chi phí hàng tháng và hàng năm trước và sau khi điều chỉnh, các quyết định đã đưa ra và ngày nhắc nhở.

## Kết quả ví dụ
Đối với `examples/subscriptions.csv` với tỷ giá USD=0.92 vào ngày 2026-10-20: 136.65 EUR/tháng (1,639.80 EUR/năm), phần chi phí của người dùng là 112.66 EUR/tháng, 699.49 EUR/năm nằm ở bốn dịch vụ không sử dụng, một gói dùng thử DesignPro sẽ chuyển đổi vào ngày 2026-10-25 thành 22.99 EUR/tháng, một khoản gia hạn CloudVault trị giá 99.99 EUR vào ngày 2026-11-05, ba dịch vụ video trùng lặp, ba đợt tăng giá trị giá 108 EUR/năm, và 237.87 EUR các khoản phí sẽ phát sinh trong 30 ngày tới.

## Quy tắc

- Không bao giờ hỏi số thẻ, thông tin đăng nhập ngân hàng hoặc mật khẩu; chỉ cần số tiền và ngày tháng là đủ.
- Không tự ý hủy bất kỳ dịch vụ nào hoặc liên hệ với nhà cung cấp thay cho người dùng trừ khi họ yêu cầu; thay vào đó hãy soạn sẵn nội dung tin nhắn cho họ.
- Giữ thái độ khách quan, không phán xét: một số gói đăng ký hoàn toàn xứng đáng với chi phí bỏ ra. Hãy trình bày chi phí và để người dùng tự lựa chọn.
- Các gói dùng thử được tính là 0 trong tổng chi phí hiện tại; luôn hiển thị mức phí sẽ phát sinh sau khi chuyển đổi sang gói trả phí.
FILE:references/cost-normalization.md
# Chuẩn Hóa Chi Phí

## Số lần thu phí mỗi tháng theo chu kỳ
| Chu kỳ | Số lần thu phí mỗi tháng | Chi phí hàng năm cho mức phí 10.00 |
|---|---|---|
| weekly | 4.348125 (365.25 / 12 / 7) | 521.78 |
| biweekly | 2.1740625 | 260.89 |
| monthly | 1 | 120.00 |
| quarterly | 1/3 | 40.00 |
| semiannual | 1/6 | 20.00 |
| yearly | 1/12 | 10.00 |
| every N days | 30.436875 / N | |

chi phí hàng tháng = mức phí x tỷ giá x số lần thu phí mỗi tháng; chi phí hàng năm = chi phí hàng tháng x 12. Việc làm tròn đến hàng xu chỉ thực hiện ở bước cuối cùng để tổng số không bị lệch.

## Tiền tệ
- Sử dụng một loại tiền tệ báo cáo duy nhất và một tỷ giá cho mỗi ngoại tệ, ví dụ: `--rate USD=0.92` (1 USD = 0.92 EUR).
- Nguồn tốt nhất: lấy số tiền đã quy đổi trên sao kê thẻ chia cho số tiền gốc. Con số này đã bao gồm biên độ tỷ giá ngoại hối (FX margin) của thẻ.
- Đề cập riêng các khoản phí giao dịch ngoại tệ nếu thẻ có thu thêm (thường từ 1-3%).

## Gói dùng chung
- `users` = số người cùng chia sẻ gói. Phần của bạn = chi phí / users.
- Hiển thị cả toàn bộ chi phí (số tiền thẻ phải thanh toán) và phần của bạn (số tiền thực tế bạn chịu sau khi những người khác hoàn trả lại). Nếu những người khác thực tế không trả tiền, hãy dùng users = 1.

## Gói dùng thử
- Một gói dùng thử có amount bằng 0, kèm theo `trial_end` và `paid_after_trial`. Các khoản phí phát sinh vào hoặc sau ngày `trial_end` sẽ sử dụng số tiền trả phí trong danh sách các khoản phí sắp tới.
- Tổng chi phí hiển thị chi phí ngày hôm nay (bằng 0) để người dùng thấy điều gì sẽ thay đổi nếu họ không làm gì cả: cờ TRIAL sẽ cho biết mức giá hàng tháng và hàng năm sau khi chuyển đổi.

## Thay đổi giá
- `previous_amount` = mức giá trước đợt tăng giá gần nhất. Tác động hàng năm = (mức mới - mức cũ) x tỷ giá x số lần thu phí mỗi tháng x 12.
- Các nhà cung cấp thường thông báo tăng giá qua email trước 30 ngày; hãy tìm kiếm cụm từ "price change" và "new price".

## Gói năm so với gói tháng
- Gói trả theo năm chỉ giúp tiết kiệm tiền nếu đằng nào dịch vụ đó cũng được duy trì trong suốt 12 tháng. Khoản tiết kiệm = chi phí hàng tháng x 12 - giá gói năm.
- Các gói trả theo năm thường không được hoàn tiền sau một khoảng thời gian ngắn ban đầu; chỉ nên chuyển sang gói năm đối với các dịch vụ được sử dụng hàng tuần.

## Ngày gia hạn
- Nếu `next_renewal` nằm trong quá khứ, ngày này sẽ được cộng dồn theo chu kỳ cho đến khi trùng với ngày hôm nay hoặc một ngày trong tương lai.
- Các ngày cuối tháng sẽ được giới hạn theo số ngày thực tế của tháng: một gói gia hạn vào ngày 31 sẽ gia hạn vào ngày 28 (hoặc 29) tháng 2, sau đó vào ngày 28 hoặc 31 tùy thuộc vào nhà cung cấp; hãy kiểm tra biên lai.
FILE:references/cancellation-playbook.md
# Cẩm Nang Hủy và Hạ Cấp Dịch Vụ

## Nơi thực hiện hủy
| Thanh toán qua | Nơi thực hiện hủy |
|---|---|
| Apple App Store | Settings > tên của bạn > Subscriptions |
| Google Play | Play Store > hồ sơ > Payments & subscriptions > Subscriptions |
| PayPal | Settings > Payments > Manage automatic payments (dừng thanh toán, đồng thời hãy hủy cả bên phía nhà cung cấp) |
| Trực tiếp với dịch vụ | Trang tài khoản hoặc thanh toán trên website; một số dịch vụ yêu cầu nhắn tin hỗ trợ hoặc gọi điện thoại |
| Nhà mạng di động hoặc gói truyền hình | Tài khoản nhà mạng hoặc tổng đài hỗ trợ |

Việc xóa ứng dụng không làm hủy gói đăng ký của ứng dụng đó.

## Các bước thực hiện
1. Kiểm tra ngày gia hạn và xem việc hủy sẽ chấm dứt quyền truy cập ngay lập tức hay vào cuối kỳ thanh toán (thường là cuối kỳ).
2. Xuất mọi dữ liệu bạn muốn giữ lại (ảnh, danh sách phát, ghi chú, lịch sử tập luyện).
3. Hủy qua kênh chính thức nêu trên, không hủy qua các đường dẫn trong những email lạ.
4. Lưu lại bằng chứng: email xác nhận hoặc ảnh chụp màn hình có kèm ngày tháng.
5. Theo dõi kỳ sao kê tiếp theo; nếu vẫn bị trừ tiền sau khi đã hủy, hãy khiếu nại với nhà cung cấp trước, sau đó mới làm việc với bên phát hành thẻ.

## Các lựa chọn thay thế cho việc hủy
- **Tạm dừng (Pause)** (phòng gym, gói nguyên liệu nấu ăn, một số dịch vụ phát trực tuyến) vào những tháng đi du lịch hoặc bận rộn.
- **Hạ cấp (Downgrade)** xuống gói có quảng cáo, gói một màn hình hoặc gói dung lượng lưu trữ nhỏ hơn.
- **Luân phiên (Rotate)** các dịch vụ phát trực tuyến: chỉ giữ một dịch vụ tại một thời điểm và đổi sang dịch vụ khác theo từng tháng để xem các chương trình bạn muốn.
- **Chia sẻ (Share)** gói gia đình với các thành viên trong hộ gia đình, trong phạm vi điều khoản của nhà cung cấp.
- **Yêu cầu (Ask)** ưu đãi giữ chân khách hàng: nhiều dịch vụ sẽ đưa ra mức giảm giá khi bạn bắt đầu thao tác hủy. Hãy xác định trước mức giá tối đa bạn chấp nhận trả trước khi thương lượng.

## Gói dùng thử
- Đặt lời nhắc trước ngày `trial_end` từ 2-3 ngày; nhiều gói dùng thử cho phép hủy ngay sau khi đăng ký mà vẫn giữ nguyên thời gian dùng thử còn lại.
- Sử dụng thẻ ảo có giới hạn hạn mức hoặc thẻ chỉ thanh toán cho một đơn vị chấp nhận thẻ đối với các gói dùng thử nếu ngân hàng có hỗ trợ.

## Mẫu tin nhắn (chat hỗ trợ hoặc email)
> Xin chào, vui lòng hủy gói đăng ký <service> của tôi trên tài khoản <email> có hiệu lực vào cuối kỳ thanh toán hiện tại, và xác nhận qua email rằng sẽ không có khoản phí nào phát sinh thêm. Xin cảm ơn.

## Những thói quen giúp kiểm soát chi phí
- Rà soát lại các gói đăng ký mỗi quý bằng kỹ năng này.
- Duy trì một lịch duy nhất ghi lại tất cả các ngày gia hạn hàng năm.
- Trước khi đăng ký mới, hãy tự hỏi: mình có dùng dịch vụ này ít nhất mỗi tuần một lần không?
FILE:templates/subscription-review.md
# Đánh Giá Gói Đăng Ký - <name> - <date>

**Tiền tệ báo cáo:** <EUR>  **Tỷ giá:** <1 USD = 0.92 EUR (sao kê thẻ)>
**Trước khi điều chỉnh:** <monthly> /tháng = <yearly> /năm (phần của bạn <monthly> /tháng)
**Sau khi quyết định:** <monthly> /tháng = <yearly> /năm  **Tiết kiệm mỗi năm:** <amount>

## Các quyết định
| Gói đăng ký | Chi phí mỗi năm | Cờ cảnh báo | Quyết định | Hoàn thành |
|---|---|---|---|---|
| | | | giữ / hủy / tạm dừng / hạ cấp / chuyển sang gói năm | [ ] |

## Hạn chót và lời nhắc
| Ngày | Nội dung | Đã đặt lời nhắc |
|---|---|---|
| | hết hạn dùng thử | [ ] |
| | gia hạn hàng năm | [ ] |

## Các khoản phí trong 30 ngày tới
- <date> <name> <amount>

## Ghi chú
- Gói dùng chung: <ai hoàn trả lại khoản nào>
- Các đợt tăng giá ghi nhận được: <dịch vụ giá cũ -> giá mới>
- Lần đánh giá tiếp theo: <ngày, khoảng 3 tháng kể từ bây giờ>
FILE:examples/subscriptions.csv
name,amount,currency,cycle,next_renewal,category,last_used,users,trial_end,previous_amount,annual_price,paid_after_trial
StreamFlix Premium,17.99,EUR,monthly,2026-10-27,Video streaming,2026-10-18,2,,15.99,,
CineBox,8.99,EUR,monthly,2026-11-03,Video streaming,2026-08-02,1,,,89.90,
AnimeNow,6.49,USD,monthly,2026-10-22,Video streaming,2026-07-15,1,,,,
TuneWave Family,19.99,EUR,monthly,2026-11-10,Music,2026-10-19,4,,16.99,,
CloudVault 2 TB,99.99,EUR,yearly,2026-11-05,Cloud storage,2026-10-19,1,,,,
PhotoCloud 200 GB,2.99,EUR,monthly,2026-11-01,Cloud storage,2026-10-10,1,,,29.99,
FitPulse Gym,39.00,EUR,monthly,2026-11-01,Fitness,2026-09-04,1,,35.00,,
Yoga Daily App,59.99,USD,yearly,2027-03-14,Fitness,2026-10-12,1,,,,
NewsLine Digital,4.99,EUR,weekly,2026-10-23,News,2026-10-19,1,,,,
PasswordSafe,36.00,USD,yearly,2027-01-20,Software,2026-10-19,1,,,,
DesignPro Trial,0.00,USD,monthly,2026-10-25,Design,2026-10-02,1,2026-10-25,,,24.99
LangLeap Plus,12.99,EUR,quarterly,2026-12-01,Learning,2026-06-30,1,,,,
FILE:scripts/sub_audit.py
#!/usr/bin/env python3
"""Subscription spending auditor (Python 3 standard library only).

Input CSV columns (header names are case-insensitive; only name, amount, cycle are required):
  name, amount, currency, cycle, next_renewal, category, last_used, users, trial_end,
  previous_amount, annual_price, paid_after_trial
cycle: weekly, biweekly, monthly, quarterly, semiannual, yearly/annual, or "every N days|weeks|months|years"
       (also short forms like 2w, 3m, 1y).
users: how many people share the plan; your share = amount / users (default 1).
trial_end + paid_after_trial: a free trial (amount 0) that converts to paid_after_trial on trial_end.

Reports, all converted to one currency with explicit rates (--rate USD=0.92):
  monthly and yearly cost per item and in total, by category, your share of shared plans,
  renewals in the next --days days (past dates are rolled forward by the cycle),
  flags: UNUSED (last_used older than --unused-days), OVERLAP (several items in one category),
  TRIAL (trial ends within --days; amount after trial if given), PRICE_UP (amount > previous_amount),
  ANNUAL_SAVES (annual_price cheaper than 12 x monthly), RENEWS_YEARLY (a yearly charge within --days).
Usage:
  python3 sub_audit.py subscriptions.csv --currency EUR --rate USD=0.92 --today 2026-10-20
  python3 sub_audit.py subscriptions.csv --json
  python3 sub_audit.py --self-test
Exit code: 0 OK, 2 bad input.
"""
import argparse
import calendar
import csv
import datetime as dt
import json
import re
import sys
from decimal import Decimal, ROUND_HALF_UP

NAMED = {"weekly": (1, "w"), "biweekly": (2, "w"), "fortnightly": (2, "w"), "monthly": (1, "m"),
         "bimonthly": (2, "m"), "quarterly": (3, "m"), "semiannual": (6, "m"), "semiannually": (6, "m"),
         "half-yearly": (6, "m"), "yearly": (12, "m"), "annual": (12, "m"), "annually": (12, "m")}
UNIT = {"d": "d", "day": "d", "days": "d", "w": "w", "week": "w", "weeks": "w", "m": "m", "month": "m",
        "months": "m", "y": "y", "year": "y", "years": "y"}
PER_MONTH = {"d": Decimal("30.436875"), "w": Decimal("4.348125"), "m": Decimal(1), "y": Decimal(1) / 12}
D2 = Decimal("0.01")


class AuditError(ValueError):
    pass


def parse_cycle(s):
    s = (s or "").strip().lower()
    if s in NAMED:
        n, u = NAMED[s]
        return n, u
    m = re.fullmatch(r"(?:every\s+)?(\d+)\s*([a-z]+)", s)
    if m and m.group(2) in UNIT:
        n, u = int(m.group(1)), UNIT[m.group(2)]
        if n <= 0:
            raise AuditError(f"cycle must be positive: {s!r}")
        if u == "y":
            return n * 12, "m"
        return n, u
    raise AuditError(f"unknown cycle {s!r} (use weekly, monthly, quarterly, yearly or 'every 2 months')")


def monthly_factor(n, u):
    """How many charges per month for a cycle of n units."""
    return PER_MONTH[u] / n


def add_cycle(d, n, u):
    if u == "d":
        return d + dt.timedelta(days=n)
    if u == "w":
        return d + dt.timedelta(weeks=n)
    m = d.month - 1 + n
    y, m = d.year + m // 12, m % 12 + 1
    return dt.date(y, m, min(d.day, calendar.monthrange(y, m)[1]))


def parse_date(s, field, name):
    s = (s or "").strip()
    if not s:
        return None
    try:
        return dt.date.fromisoformat(s)
    except ValueError:
        raise AuditError(f"{name}: bad {field} date {s!r} (use YYYY-MM-DD)")


def money(s, field, name, required=False):
    s = (s or "").strip().replace(",", "") if (s or "").count(",") and "." in (s or "") else (s or "").strip().replace(",", ".")
    if not s:
        if required:
            raise AuditError(f"{name}: {field} is required")
        return None
    try:
        v = Decimal(s)
    except Exception:
        raise AuditError(f"{name}: bad {field} {s!r}")
    if v < 0:
        raise AuditError(f"{name}: {field} cannot be negative")
    return v


def q(v):
    return v.quantize(D2, rounding=ROUND_HALF_UP)


def load(lines):
    rd = csv.DictReader(lines)
    if not rd.fieldnames:
        raise AuditError("empty CSV")
    rows = []
    for r in rd:
        r = {(k or "").strip().lower(): (v or "").strip() for k, v in r.items()}
        if not any(r.values()):
            continue
        if not r.get("name"):
            raise AuditError("every row needs a name")
        rows.append(r)
    if not rows:
        raise AuditError("no subscriptions found")
    return rows


def audit(rows, currency="EUR", rates=None, today=None, days=30, unused_days=45):
    rates = {k.upper(): Decimal(str(v)) for k, v in (rates or {}).items()}
    rates[currency.upper()] = Decimal(1)
    today = today or dt.date.today()
    horizon = today + dt.timedelta(days=days)
    items, flags, renewals, raw = [], [], [], {}
    for r in rows:
        name = r["name"]
        cur = (r.get("currency") or currency).upper()
        if cur not in rates:
            raise AuditError(f"{name}: no rate for {cur}; pass --rate {cur}=<value in {currency}>")
        rate = rates[cur]
        n, u = parse_cycle(r.get("cycle"))
        amount = money(r.get("amount"), "amount", name, required=True)
        after_trial = money(r.get("paid_after_trial"), "paid_after_trial", name)
        trial_end = parse_date(r.get("trial_end"), "trial_end", name)
        users = int(r.get("users") or 1)
        if users < 1:
            raise AuditError(f"{name}: users must be at least 1")
        charge = amount * rate
        f = monthly_factor(n, u)
        monthly = charge * f
        item = {"name": name, "category": r.get("category") or "Other", "currency": cur, "amount": float(amount),
                "cycle": r.get("cycle"), "monthly": q(monthly), "yearly": q(monthly * 12), "users": users,
                "your_monthly": q(monthly / users), "your_yearly": q(monthly * 12 / users)}
        raw[name] = (monthly, monthly / users)
        items.append(item)
        nxt = parse_date(r.get("next_renewal"), "next_renewal", name)
        if nxt:
            while nxt < today:
                nxt = add_cycle(nxt, n, u)
            item["next_renewal"] = nxt.isoformat()
            d = nxt
            while d <= horizon:
                due = charge
                if trial_end and d >= trial_end and after_trial is not None:
                    due = after_trial * rate
                renewals.append({"date": d.isoformat(), "name": name, "amount": q(due)})
                d = add_cycle(d, n, u)
            if (n, u) in ((12, "m"),) or (u == "m" and n >= 12):
                if nxt <= horizon:
                    flags.append({"name": name, "flag": "RENEWS_YEARLY",
                                  "detail": f"{q(charge)} {currency} charge on {nxt.isoformat()}; decide before then"})
        last = parse_date(r.get("last_used"), "last_used", name)
        if last and (today - last).days > unused_days:
            flags.append({"name": name, "flag": "UNUSED",
                          "detail": f"last used {last.isoformat()} ({(today - last).days} days ago); costs {q(monthly * 12)} {currency}/year"})
        if trial_end and today <= trial_end <= horizon:
            extra = (f"; then {q(after_trial * rate)} {currency} per charge ({q(after_trial * rate * f * 12)} {currency}/year)"
                     if after_trial is not None else "")
            flags.append({"name": name, "flag": "TRIAL",
                          "detail": f"trial ends {trial_end.isoformat()} ({(trial_end - today).days} days){extra}; cancel or keep on purpose"})
        prev = money(r.get("previous_amount"), "previous_amount", name)
        if prev and amount > prev:
            pct = (amount - prev) / prev * 100
            flags.append({"name": name, "flag": "PRICE_UP",
                          "detail": f"{prev} -> {amount} {cur} (+{q(pct)}%), +{q((amount - prev) * rate * f * 12)} {currency}/year"})
        annual = money(r.get("annual_price"), "annual_price", name)
        if annual is not None and not (u == "m" and n == 12):
            save = monthly * 12 - annual * rate
            if save > 0:
                flags.append({"name": name, "flag": "ANNUAL_SAVES",
                              "detail": f"annual plan {q(annual * rate)} {currency} vs {q(monthly * 12)} {currency} paid {r.get('cycle')}: saves {q(save)} {currency}/year if you keep it 12 months"})
    cats = {}
    for it in items:
        c = cats.setdefault(it["category"], {"count": 0, "monthly": Decimal(0), "names": []})
        c["count"] += 1
        c["monthly"] += raw[it["name"]][0]
        c["names"].append(it["name"])
    for cname, c in sorted(cats.items()):
        if c["count"] > 1:
            flags.append({"name": ", ".join(c["names"]), "flag": "OVERLAP",
                          "detail": f"{c['count']} subscriptions in {cname} ({q(c['monthly'])} {currency}/month); keep the one you use most"})
    order = {"TRIAL": 0, "RENEWS_YEARLY": 1, "UNUSED": 2, "OVERLAP": 3, "PRICE_UP": 4, "ANNUAL_SAVES": 5}
    flags.sort(key=lambda x: (order[x["flag"]], x["name"]))
    renewals.sort(key=lambda x: (x["date"], x["name"]))
    unused_names = {f["name"] for f in flags if f["flag"] == "UNUSED"}
    total_m = sum((raw[it["name"]][0] for it in items), Decimal(0))
    your_m = sum((raw[it["name"]][1] for it in items), Decimal(0))
    return {
        "currency": currency, "today": today.isoformat(), "days": days, "rates": {k: str(v) for k, v in rates.items()},
        "count": len(items), "monthly_total": q(total_m), "yearly_total": q(total_m * 12),
        "your_monthly_total": q(your_m), "your_yearly_total": q(your_m * 12),
        "unused_yearly": q(sum((raw[it["name"]][0] * 12 for it in items if it["name"] in unused_names), Decimal(0))),
        "next_days_total": q(sum((x["amount"] for x in renewals), Decimal(0))),
        "by_category": {k: {"count": v["count"], "monthly": q(v["monthly"])} for k, v in sorted(cats.items(), key=lambda kv: -kv[1]["monthly"])},
        "items": sorted(items, key=lambda it: -it["monthly"]), "renewals": renewals, "flags": flags,
    }


def to_markdown(r):
    c = r["currency"]
    out = ["# Subscription audit", "",
           f"**As of:** {r['today']}  **Subscriptions:** {r['count']}  **Currency:** {c}  **Rates:** "
           + ", ".join(f"1 {k} = {v} {c}" for k, v in r["rates"].items() if k != c.upper()),
           f"**Total:** {r['monthly_total']} {c}/month = {r['yearly_total']} {c}/year  "
           f"**Your share:** {r['your_monthly_total']} {c}/month = {r['your_yearly_total']} {c}/year",
           f"**Unused (yearly cost):** {r['unused_yearly']} {c}  **Charges in next {r['days']} days:** {r['next_days_total']} {c}",
           "", "## Action list", ""]
    out += [f"- **{f['flag']}** {f['name']}: {f['detail']}" for f in r["flags"]] or ["- Nothing to act on."]
    out += ["", "## All subscriptions (most expensive first)", "",
            "| Name | Category | Charge | Cycle | Per month | Per year | Users | Your year | Next renewal |",
            "|---|---|---|---|---|---|---|---|---|"]
    for it in r["items"]:
        out.append(f"| {it['name']} | {it['category']} | {it['amount']:.2f} {it['currency']} | {it['cycle']} | {it['monthly']} | "
                   f"{it['yearly']} | {it['users']} | {it['your_yearly']} | {it.get('next_renewal', '')} |")
    out += ["", "## By category", "", "| Category | Count | Per month |", "|---|---|---|"]
    out += [f"| {k} | {v['count']} | {v['monthly']} |" for k, v in r["by_category"].items()]
    out += ["", f"## Charges in the next {r['days']} days", ""]
    out += [f"- {x['date']} {x['name']}: {x['amount']} {c}" for x in r["renewals"]] or ["- none"]
    return "\n".join(out)


def self_test():
    cases = []

    def ok(name, cond):
        cases.append((name, bool(cond)))

    T = dt.date(2026, 10, 20)
    hdr = "name,amount,currency,cycle,next_renewal,category,last_used,users,trial_end,previous_amount,annual_price,paid_after_trial"

    def run(*lines, **kw):
        return audit(load([hdr, *lines]), today=T, **kw)

    ok("named cycles", parse_cycle("Quarterly") == (3, "m") and parse_cycle("annual") == (12, "m"))
    ok("every N units", parse_cycle("every 2 weeks") == (2, "w") and parse_cycle("1y") == (12, "m"))
    ok("month end clamps", add_cycle(dt.date(2026, 1, 31), 1, "m") == dt.date(2026, 2, 28))
    ok("leap year clamp", add_cycle(dt.date(2027, 2, 28), 12, "m") == dt.date(2028, 2, 28) and add_cycle(dt.date(2028, 1, 31), 1, "m") == dt.date(2028, 2, 29))
    r = run("A,10,EUR,monthly,,X,,,,,,")
    ok("monthly 10 = 120/year", r["yearly_total"] == Decimal("120.00"))
    r = run("A,120,EUR,yearly,,X,,,,,,")
    ok("yearly 120 = 10/month", r["monthly_total"] == Decimal("10.00"))
    r = run("A,5,EUR,weekly,,X,,,,,,")
    ok("weekly 5 = 260.89/year", r["yearly_total"] == Decimal("260.89") and r["items"][0]["monthly"] == Decimal("21.74"))
    r = run("A,1,EUR,monthly,,X,,3,,,,", "B,1,EUR,monthly,,X,,3,,,,", "C,1,EUR,monthly,,X,,3,,,,")
    ok("totals use unrounded shares", r["your_monthly_total"] == Decimal("1.00"))
    r = run("A,10,USD,monthly,,X,,,,,,", rates={"USD": "0.9"})
    ok("currency rate applied", r["monthly_total"] == Decimal("9.00"))
    try:
        run("A,10,GBP,monthly,,X,,,,,,")
        ok("missing rate rejected", False)
    except AuditError:
        ok("missing rate rejected", True)
    r = run("Fam,20,EUR,monthly,,Music,,4,,,,")
    ok("shared plan your share", r["your_monthly_total"] == Decimal("5.00") and r["monthly_total"] == Decimal("20.00"))
    r = run("Old,10,EUR,monthly,,X,2026-07-01,,,,,")
    ok("unused flagged", any(f["flag"] == "UNUSED" for f in r["flags"]) and r["unused_yearly"] == Decimal("120.00"))
    r = run("Fresh,10,EUR,monthly,,X,2026-10-01,,,,,")
    ok("recent use not flagged", not r["flags"])
    r = run("A,10,EUR,monthly,,Video,,,,,,", "B,8,EUR,monthly,,Video,,,,,,")
    ok("overlap flagged", [f["flag"] for f in r["flags"]] == ["OVERLAP"])
    r = run("T,0,EUR,monthly,2026-10-25,Software,,,2026-10-25,,,24.99")
    ok("trial flagged with paid price", r["flags"][0]["flag"] == "TRIAL" and "24.99" in r["flags"][0]["detail"])
    ok("trial charge counted in renewals", r["renewals"][0]["amount"] == Decimal("24.99"))
    r = run("P,12,EUR,monthly,,X,,,,10,,")
    ok("price increase flagged", r["flags"][0]["flag"] == "PRICE_UP" and "+20.00%" in r["flags"][0]["detail"] and "+24.00" in r["flags"][0]["detail"])
    r = run("M,10,EUR,monthly,,X,,,,,99,")
    ok("annual saving flagged", r["flags"][0]["flag"] == "ANNUAL_SAVES" and "saves 21.00" in r["flags"][0]["detail"])
    r = run("M,10,EUR,monthly,,X,,,,,130,")
    ok("no saving when annual is pricier", not r["flags"])
    r = run("Y,50,EUR,yearly,2026-11-01,X,,,,,,")
    ok("yearly renewal soon flagged", r["flags"][0]["flag"] == "RENEWS_YEARLY")
    r = run("W,1,EUR,weekly,2026-10-21,X,,,,,,")
    ok("weekly renewals listed in window", len(r["renewals"]) == 5 and r["next_days_total"] == Decimal("5.00"))
    r = run("Past,10,EUR,monthly,2026-08-15,X,,,,,,")
    ok("rolled forward to the future", r["items"][0]["next_renewal"] == "2026-11-15")
    for line, label in [("A,-3,EUR,monthly,,X,,,,,,", "negative amount rejected"),
                        ("A,3,EUR,sometimes,,X,,,,,,", "unknown cycle rejected"),
                        ("A,3,EUR,monthly,20-10-2026,X,,,,,,", "bad date rejected"),
                        ("A,3,EUR,monthly,,X,,0,,,,", "zero users rejected")]:
        try:
            run(line)
            ok(label, False)
        except AuditError:
            ok(label, True)
    ok("markdown has action list", "## Action list" in to_markdown(run("A,10,EUR,monthly,,X,,,,,,")))
    passed = sum(c for _, c in cases)
    for name, c in cases:
        print(("PASS " if c else "FAIL ") + name)
    print(f"{passed}/{len(cases)} passed")
    return 0 if passed == len(cases) else 1


def default(o):
    if isinstance(o, Decimal):
        return float(o)
    raise TypeError(type(o))


def main(argv=None):
    ap = argparse.ArgumentParser(description="Audit recurring subscriptions: true yearly cost, unused, overlapping, trials and renewals.")
    ap.add_argument("file", nargs="?", help="subscriptions CSV ('-' for stdin)")
    ap.add_argument("--currency", default="EUR", help="report currency (default EUR)")
    ap.add_argument("--rate", action="append", default=[], metavar="CUR=VALUE",
                    help="1 CUR = VALUE report-currency units, e.g. USD=0.92 (repeatable)")
    ap.add_argument("--today", help="evaluation date YYYY-MM-DD (default: today)")
    ap.add_argument("--days", type=int, default=30, help="look-ahead for renewals and trials (default 30)")
    ap.add_argument("--unused-days", type=int, default=45, help="flag items not used for this many days (default 45)")
    ap.add_argument("--json", action="store_true")
    ap.add_argument("--self-test", action="store_true")
    a = ap.parse_args(argv)
    if a.self_test:
        return self_test()
    if not a.file:
        ap.error("subscriptions CSV required (or --self-test)")
    try:
        rates = {}
        for x in a.rate:
            k, _, v = x.partition("=")
            if not v:
                raise AuditError(f"bad --rate {x!r}, use CUR=VALUE")
            rates[k.strip().upper()] = Decimal(v.strip())
        lines = sys.stdin.read().splitlines() if a.file == "-" else open(a.file, encoding="utf-8").read().splitlines()
        today = dt.date.fromisoformat(a.today) if a.today else None
        r = audit(load(lines), a.currency.upper(), rates, today, a.days, a.unused_days)
    except (OSError, AuditError, ValueError) as e:
        print(f"error: {e}", file=sys.stderr)
        return 2
    print(json.dumps(r, indent=2, default=default, ensure_ascii=False) if a.json else to_markdown(r))
    return 0


if __name__ == "__main__":
    sys.exit(main())
Personal FinanceBudgetingPlanning+2
F@f
0