1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

思い出箱の外貨、全部数えたらお寿司15貫食べれるぐらい残っていた

1
Last updated at Posted at 2026-09-30

旅行から帰ってきて、財布や引き出しに残った外国のお金
「また行くかもしれないし」「記念だから」と思い出箱にしまったまま、気づけば何年も経っていませんか?

そのお金、今のレートで日本円にするといくらになるんだろう
気になるけれど、国ごとに通貨を調べて、紙幣や硬貨を数えて、計算するのは少し面倒です

そこで、旅行後に残った外貨を入力すると、日本円の目安をまとめて表示するアプリをTkinterで作りました💰
私は韓国・台湾・ベトナムのお金が残っており、計算したら意外と上手な買い物していた(あまり2000円ぐらい)

image.png

2. 作るときに意識したポイント2つ

ポイント1:国の情報と為替レートをAPIから取得

国名や通貨情報には countries.dev を使いました
次のURLでは、国名 name、2文字の国コード alpha2Code、通貨情報 currencies を取得します

COUNTRIES_URL = "https://countries.dev/countries?fields=name,alpha2Code,currencies"

為替レートには ExchangeRate-API を使っています
このURLは米ドル(USD)を基準に、各通貨のレートを返します

RATES_URL = "https://open.er-api.com/v6/latest/USD"

アプリでは、国を選ぶとその国の通貨を表示し、額面と枚数を入力できるようにしました
複数の国の通貨を登録して、まとめて集計できます

ポイント2:通貨ごとに集計してから円に換算

入力した額面に枚数を掛け、通貨ごとに小計をまとめます
そのうえで、USD基準の為替レートを使って日本円の目安を計算します

たとえば、外貨のレートが「1 USD = その通貨のレート」、円のレートが「1 USD = 円のレート」なら、次の計算で円に換算できます

外貨の金額 × USDからJPYへのレート ÷ USDから外貨へのレート

なお、APIの為替レートは1日ごとに更新されるもので、実際の両替レートや手数料を反映した金額ではありません
あくまで「今のレートならこのくらい」という目安として表示しています

3. アプリの画面とコード

画面では、国を検索して複数選択し、通貨ボタンを選んで額面と枚数を入力します
一気に数えなくても手に取ったお金から入力していけばよいです
登録した金額は一覧で確認でき、選択した行を削除することもできます

スクリーンショット 2026-09-30 22.32.51.png

Python 3.10以降、Tkinterがあれば実行でき、追加パッケージは不要です

全体コードはこちら↓
from __future__ import annotations

import json
import threading
import urllib.error
import urllib.request
import tkinter as tk
from tkinter import ttk


COUNTRIES_URL = "https://countries.dev/countries?fields=name,alpha2Code,currencies"
RATES_URL = "https://open.er-api.com/v6/latest/USD"
USER_AGENT = "ForeignCashCounter/1.0 (Tkinter learning app)"


def get_json(url: str):
    request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
    with urllib.request.urlopen(request, timeout=15) as response:
        return json.loads(response.read().decode("utf-8"))


class CashCounter:
    def __init__(self, root: tk.Tk):
        self.root = root
        root.title("旅の残り外貨カウンター")
        root.geometry("940x820")
        root.minsize(760, 650)

        self.countries: list[str] = []
        self.country_to_currencies: dict[str, list[tuple[str, str, str]]] = {}
        self.country_checks: dict[str, tk.BooleanVar] = {}
        self.rates: dict[str, float] = {}
        self.rates_date = ""
        self.active_country = ""
        self.active_currency: tuple[str, str, str] | None = None
        self.cash_records: list[dict] = []

        self.search_var = tk.StringVar()
        self.active_var = tk.StringVar(value="通貨ボタンを選んでください")
        self.totals_var = tk.StringVar(value="通貨ごとの合計: —")
        self.jpy_var = tk.StringVar(value="日本円の目安: —")
        self.status_var = tk.StringVar(value="無料APIから国と為替レートを読み込み中…")

        self._build_ui()
        self._start_load()

    def _build_ui(self):
        outer = ttk.Frame(self.root, padding=15)
        outer.pack(fill="both", expand=True)

        ttk.Label(outer, text="旅の残り外貨カウンター", font=("TkDefaultFont", 18, "bold")).pack(anchor="w")

        countries_box = ttk.LabelFrame(outer, text="1. 旅行した国を選択(複数選択できます)", padding=10)
        countries_box.pack(fill="both", expand=True, pady=(10, 8))
        tools = ttk.Frame(countries_box)
        tools.pack(fill="x", pady=(0, 6))
        ttk.Label(tools, text="国名で検索:").pack(side="left")
        search = ttk.Entry(tools, textvariable=self.search_var)
        search.pack(side="left", fill="x", expand=True, padx=8)
        ttk.Button(tools, text="検索結果を選択", command=self._select_visible).pack(side="left", padx=(0, 5))
        ttk.Button(tools, text="選択を解除", command=self._clear_country_selection).pack(side="left")
        self.search_var.trace_add("write", lambda *_: self._render_country_checks())

        check_area = ttk.Frame(countries_box)
        check_area.pack(fill="both", expand=True)
        self.country_canvas = tk.Canvas(check_area, height=190, highlightthickness=0)
        country_scroll = ttk.Scrollbar(check_area, orient="vertical", command=self.country_canvas.yview)
        self.country_canvas.configure(yscrollcommand=country_scroll.set)
        country_scroll.pack(side="right", fill="y")
        self.country_canvas.pack(side="left", fill="both", expand=True)
        self.country_check_frame = ttk.Frame(self.country_canvas)
        self.country_check_window = self.country_canvas.create_window((0, 0), window=self.country_check_frame, anchor="nw")
        self.country_check_frame.bind("<Configure>", lambda _e: self.country_canvas.configure(scrollregion=self.country_canvas.bbox("all")))
        self.country_canvas.bind("<Configure>", lambda event: self.country_canvas.itemconfigure(self.country_check_window, width=event.width))

        currency_box = ttk.LabelFrame(outer, text="2. 選んだ国・通貨のボタンを押す", padding=10)
        currency_box.pack(fill="x", pady=8)
        self.currency_buttons = ttk.Frame(currency_box)
        self.currency_buttons.pack(fill="x")
        ttk.Label(currency_box, textvariable=self.active_var, font=("TkDefaultFont", 10, "bold")).pack(anchor="w", pady=(7, 0))

        entry_box = ttk.LabelFrame(outer, text="3. 額面と枚数を入力", padding=10)
        entry_box.pack(fill="x", pady=8)
        ttk.Label(entry_box, text="額面").grid(row=0, column=0, sticky="w")
        ttk.Label(entry_box, text="枚数").grid(row=0, column=1, sticky="w", padx=(10, 0))
        self.face_entry = ttk.Entry(entry_box, width=18)
        self.qty_entry = ttk.Entry(entry_box, width=12)
        self.face_entry.grid(row=1, column=0, sticky="w", pady=(3, 0))
        self.qty_entry.grid(row=1, column=1, sticky="w", padx=(10, 0), pady=(3, 0))
        self.face_entry.insert(0, "0")
        self.qty_entry.insert(0, "1")
        self.face_entry.bind("<KeyRelease>", lambda _e: None)
        self.qty_entry.bind("<Return>", lambda _e: self.add_cash())
        ttk.Button(entry_box, text="金額を追加", command=self.add_cash).grid(row=1, column=2, padx=12)

        records_box = ttk.LabelFrame(outer, text="入力したお金", padding=8)
        records_box.pack(fill="both", expand=True, pady=8)
        columns = ("country", "currency", "face", "count", "subtotal")
        self.records_tree = ttk.Treeview(records_box, columns=columns, show="headings", height=6)
        for col, title, width in (
            ("country", "国", 220), ("currency", "通貨", 170),
            ("face", "額面", 100), ("count", "枚数", 80), ("subtotal", "小計", 150),
        ):
            self.records_tree.heading(col, text=title)
            self.records_tree.column(col, width=width, anchor="w")
        tree_scroll = ttk.Scrollbar(records_box, orient="vertical", command=self.records_tree.yview)
        self.records_tree.configure(yscrollcommand=tree_scroll.set)
        self.records_tree.pack(side="left", fill="both", expand=True)
        tree_scroll.pack(side="left", fill="y")
        ttk.Button(records_box, text="選択した行を削除", command=self.remove_selected).pack(side="right", padx=(8, 0), anchor="n")

        totals = ttk.LabelFrame(outer, text="集計", padding=10)
        totals.pack(fill="x", pady=(0, 6))
        ttk.Label(totals, textvariable=self.totals_var, font=("TkDefaultFont", 11, "bold"), wraplength=880).pack(anchor="w")
        ttk.Label(totals, textvariable=self.jpy_var, font=("TkDefaultFont", 15, "bold"), wraplength=880).pack(anchor="w", pady=(4, 0))
        ttk.Label(outer, textvariable=self.status_var, foreground="#555", wraplength=900).pack(anchor="w", pady=(3, 0))
        footer = ttk.Frame(outer)
        footer.pack(fill="x", pady=(4, 0))
        ttk.Label(footer, text="無料API: countries.dev · ExchangeRate-API", foreground="#666").pack(side="left")
        ttk.Button(footer, text="APIを再接続", command=self._start_load).pack(side="right")

    def _start_load(self):
        self.status_var.set("国と為替レートを読み込み中…")
        threading.Thread(target=self._load_data, daemon=True).start()

    def _load_data(self):
        country_data = rate_data = None
        country_error = rate_error = None
        try:
            country_data = get_json(COUNTRIES_URL)
            if not isinstance(country_data, list):
                raise ValueError("国APIからリスト形式のデータが返りませんでした。")
        except Exception as exc:
            country_error = self._explain_api_error("国データ (countries.dev)", exc)
        try:
            rate_data = get_json(RATES_URL)
            if rate_data.get("result") != "success":
                raise ValueError(rate_data.get("error-type", "為替APIがエラーを返しました"))
        except Exception as exc:
            rate_error = self._explain_api_error("為替レート (ExchangeRate-API)", exc)

        try:
            countries, mapping = [], {}
            for item in country_data or []:
                name = item.get("name")
                if not name:
                    continue
                currencies = item.get("currencies") or []
                if isinstance(currencies, dict):
                    options = [(code, details.get("name", ""), details.get("symbol", ""))
                               for code, details in currencies.items()]
                else:
                    options = [(item.get("code", ""), item.get("name", ""), item.get("symbol", ""))
                               for item in currencies if item.get("code")]
                label = f"{name} ({item.get('alpha2Code', '')})"
                countries.append(label)
                mapping[label] = options
            countries.sort(key=str.casefold)
            self.root.after(0, self._data_loaded, countries, mapping, rate_data or {}, country_error, rate_error)
        except Exception as exc:
            self.root.after(0, self._load_failed, str(exc))

    @staticmethod
    def _explain_api_error(label, exc):
        if isinstance(exc, urllib.error.HTTPError):
            reason = f"HTTP {exc.code} {exc.reason}"
        elif isinstance(exc, urllib.error.URLError):
            reason = str(exc.reason)
        else:
            reason = str(exc)
        return f"{label}: {reason}"

    def _data_loaded(self, countries, mapping, rate_data, country_error, rate_error):
        self.countries = countries
        self.country_to_currencies = mapping
        self.country_checks = {country: tk.BooleanVar(value=False) for country in countries}
        self.rates = rate_data.get("rates", {})
        self.rates_date = rate_data.get("time_last_update_utc", "日付不明")
        self._render_country_checks()
        self._render_currency_buttons()
        errors = " / ".join(error for error in (country_error, rate_error) if error)
        if errors:
            self.status_var.set("一部のAPIに接続できませんでした。『APIを再接続』を押してください。 " + errors)
        else:
            self.status_var.set(f"データ更新: {self.rates_date}。国を検索してチェックしてください。")
        self._calculate_totals()

    def _render_country_checks(self):
        if not hasattr(self, "country_check_frame"):
            return
        for child in self.country_check_frame.winfo_children():
            child.destroy()
        query = self.search_var.get().strip().casefold()
        visible = [country for country in self.countries if query in country.casefold()]
        for index, country in enumerate(visible):
            check = ttk.Checkbutton(
                self.country_check_frame, text=country, variable=self.country_checks[country],
                command=self._country_checks_changed,
            )
            check.grid(row=index // 3, column=index % 3, sticky="w", padx=(2, 8), pady=2)
        self.country_canvas.configure(scrollregion=self.country_canvas.bbox("all"))
        if query and not visible:
            ttk.Label(self.country_check_frame, text="該当する国がありません").grid(row=0, column=0, sticky="w")

    def _select_visible(self):
        query = self.search_var.get().strip().casefold()
        for country, variable in self.country_checks.items():
            if query in country.casefold():
                variable.set(True)
        self._country_checks_changed()

    def _clear_country_selection(self):
        for variable in self.country_checks.values():
            variable.set(False)
        self._country_checks_changed()

    def _country_checks_changed(self):
        self._render_currency_buttons()
        self.active_country = ""
        self.active_currency = None
        self.active_var.set("通貨ボタンを選んでください")

    def _render_currency_buttons(self):
        for child in self.currency_buttons.winfo_children():
            child.destroy()
        selected = [country for country, variable in self.country_checks.items() if variable.get()]
        button_info = []
        for country in selected:
            for code, name, symbol in self.country_to_currencies.get(country, []):
                button_info.append((country, code, name, symbol))
        for index, (country, code, name, symbol) in enumerate(button_info):
            text = f"{country}: {code}"
            if symbol:
                text += f" {symbol}"
            if name:
                text += f" — {name}"
            button = ttk.Button(
                self.currency_buttons, text=text,
                command=lambda c=country, item=(code, name, symbol): self._choose_currency(c, item),
            )
            button.grid(row=index // 3, column=index % 3, sticky="ew", padx=3, pady=3)
        for col in range(3):
            self.currency_buttons.columnconfigure(col, weight=1)
        if selected and not button_info:
            ttk.Label(self.currency_buttons, text="選択した国の通貨データがありません").grid(row=0, column=0, sticky="w")

    def _choose_currency(self, country, currency):
        self.active_country = country
        self.active_currency = currency
        code, name, _symbol = currency
        self.active_var.set(f"選択中: {country} · {code} ({name})。額面と枚数を入力して「金額を追加」を押してください")

    def add_cash(self):
        if not self.active_currency:
            self.status_var.set("先に旅行した国をチェックし、その国・通貨のボタンを押してください。")
            return
        try:
            face = float(self.face_entry.get() or 0)
            count = int(self.qty_entry.get() or 0)
            if face <= 0 or count <= 0:
                raise ValueError
        except ValueError:
            self.status_var.set("額面は0より大きい数、枚数は1以上の整数で入力してください。")
            return

        code, name, symbol = self.active_currency
        record = {"country": self.active_country, "code": code, "name": name,
                  "symbol": symbol, "face": face, "count": count}
        self.cash_records.append(record)
        self.records_tree.insert("", "end", values=(
            record["country"], f"{code} — {name}", f"{face:g}", count,
            f"{face * count:,.2f} {code}",
        ))
        self.face_entry.delete(0, tk.END)
        self.face_entry.insert(0, "0")
        self.qty_entry.delete(0, tk.END)
        self.qty_entry.insert(0, "1")
        self.status_var.set("金額を追加しました。別の通貨ボタンを選んで続けられます。")
        self._calculate_totals()

    def remove_selected(self):
        selected_items = self.records_tree.selection()
        if not selected_items:
            return
        indexes = sorted((self.records_tree.index(item) for item in selected_items), reverse=True)
        for item in selected_items:
            self.records_tree.delete(item)
        for index in indexes:
            del self.cash_records[index]
        self._calculate_totals()

    def _calculate_totals(self):
        totals: dict[str, float] = {}
        for record in self.cash_records:
            code = record["code"]
            totals[code] = totals.get(code, 0.0) + record["face"] * record["count"]
        if not totals:
            self.totals_var.set("通貨ごとの合計: —")
            self.jpy_var.set("日本円の目安: —")
            return

        self.totals_var.set("通貨ごとの合計: " + " / ".join(
            f"{amount:,.2f} {code}" for code, amount in sorted(totals.items())
        ))
        usd_to_jpy = self.rates.get("JPY")
        yen_total = 0.0
        unsupported = []
        for code, amount in totals.items():
            usd_to_foreign = self.rates.get(code)
            if usd_to_jpy and usd_to_foreign:
                yen_total += amount * usd_to_jpy / usd_to_foreign
            else:
                unsupported.append(code)
        if unsupported:
            self.jpy_var.set(f"日本円の目安: ¥{yen_total:,.0f}(換算できない通貨: {', '.join(sorted(unsupported))})")
        else:
            self.jpy_var.set(f"日本円の目安: ¥{yen_total:,.0f}")

    def _load_failed(self, detail):
        self.status_var.set("国データの加工に失敗しました。API接続を確認してください。 " + detail)


def main():
    root = tk.Tk()
    CashCounter(root)
    root.mainloop()


if __name__ == "__main__":
    main()

みなさんの思い出箱には、今なら何円になる外貨が眠っていますか?

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?