旅行から帰ってきて、財布や引き出しに残った外国のお金
「また行くかもしれないし」「記念だから」と思い出箱にしまったまま、気づけば何年も経っていませんか?
そのお金、今のレートで日本円にするといくらになるんだろう
気になるけれど、国ごとに通貨を調べて、紙幣や硬貨を数えて、計算するのは少し面倒です
そこで、旅行後に残った外貨を入力すると、日本円の目安をまとめて表示するアプリをTkinterで作りました💰
私は韓国・台湾・ベトナムのお金が残っており、計算したら意外と上手な買い物していた(あまり2000円ぐらい)
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. アプリの画面とコード
画面では、国を検索して複数選択し、通貨ボタンを選んで額面と枚数を入力します
一気に数えなくても手に取ったお金から入力していけばよいです
登録した金額は一覧で確認でき、選択した行を削除することもできます
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()
みなさんの思い出箱には、今なら何円になる外貨が眠っていますか?

