副業の20万円は売上ではなく所得。売上26万円でも超えない見本を、境界3本で確かめた

売上26万円、経費7万円。2026年の副業をこの2行で表すと、「20万円を超えたか」の答えは、売上を見るか所得を見るかでひっくり返ります。売上なら超えています。所得は19万円なので、超えていません。
デジタル商品やAI関連の副業を始めると、まず気になるのがこの20万円です。ネットで調べると「副業で20万円を超えたら確定申告」と一行で書かれていて、その一行をそのままコードにすると、私は最初の版で間違えました。今回は合成した売上と経費のCSVで、その間違いと直し方を残します。実在の売上も申告内容も使っていません。
11月3日の回では、AIに渡す売上CSVの列名を決めました。ただ、その合計を何と比べるのかは決めていませんでした。10月9日の消費税の端数の回も、見ていたのは売上側の数字だけです。経費を引いた先にある判定を、今回は同じ小さなコードで固定します。
数える対象は「売上」ではなく「所得金額」でした
国税庁のタックスアンサー No.1900 は、給与所得者が確定申告をしなければならない場合を挙げています。副業に関わるのは2番です。給与を1か所から受け、その全部が源泉徴収されている人は、給与所得と退職所得を除く各種の所得金額の合計が20万円を「超える」と申告が必要になります。
ここで数えるのは所得金額です。No.1350 によると、事業所得は「総収入金額 − 必要経費 = 事業所得の金額」で計算します。売上が総収入金額、経費が必要経費に当たります。
もう1つ、言葉の細かいところがあります。条件は「20万円を超える」です。ちょうど20万円は超えていません。コードに直すと > で、>= ではありません。
この回の前提は2つだけです。給与を1か所から受けていること、副業以外に他の所得が無いこと。給与が2か所以上ある場合は条件が変わります。事業所得か雑所得かの区分、住民税の申告、確定申告をすれば還付される場合も別の話で、ここでは判定しません。
売上を足しただけの最初の版
データは、売上3行と経費3行です。
mkdir side-income-check
cd side-income-check
cat > sales_a.csv <<'E'
date,item,amount
2026-03-14,テンプレートA,98000
2026-06-02,テンプレートA,92000
2026-09-20,プロンプト集,70000
E
cat > expenses_a.csv <<'E'
date,item,amount
2026-03-01,独自ドメイン,2800
2026-04-10,AI APIの利用料,41200
2026-07-05,決済手数料,26000
E
最初に書いたのは、売上CSVの合計が20万円を超えたら終了コード10で止める17行の版です。
cat > check_v1.py <<'E'
import csv
import sys
LIMIT = 200_000
def total(path):
with open(path, newline="", encoding="utf-8") as f:
return sum(int(row["amount"]) for row in csv.DictReader(f))
sales = total(sys.argv[1])
print(f"売上 {sales}円")
if sales > LIMIT:
print("20万円超 -> 確定申告が必要")
sys.exit(10)
print("20万円以下")
E
python3 check_v1.py sales_a.csv; echo "exit=$?"
売上 260000円
20万円超 -> 確定申告が必要
exit=10
動きます。出力も自然です。だから厄介でした。
この版は、経費ファイルを開いてすらいません。経費の合計は7万円なので、所得は19万円です。
AIにコードを書かせる場合も、プロンプトに「副業が20万円を超えたら」と書けば、同じ形の判定が返ってくる気がします。試したわけではありません。ただ、一行の言い回しからは売上と所得の区別が落ちやすいと思うので、検査は人が持っておきたいです。
所得で数え直し、入力も疑う版
直した版は check.py です。45行あるので畳みました。売上CSVと経費CSVを両方読み、差を所得として20万円と比べます。入力が壊れていれば、判定せずに終了コード2で止めます。
check.py の全文(45行)
cat > check.py <<'E'
import csv
import sys
LIMIT = 200_000 # 国税庁 No.1900: 20万円を「超える」と申告が必要
def load(path):
with open(path, newline="", encoding="utf-8") as f:
reader = csv.DictReader(f)
if reader.fieldnames != ["date", "item", "amount"]:
raise ValueError(f"{path}: 列は date,item,amount の順で必要です: {reader.fieldnames}")
rows = list(reader)
if not rows:
raise ValueError(f"{path}: データ行が0行です")
years = {r["date"][:4] for r in rows}
amounts = []
for r in rows:
if None in r or None in r.values():
raise ValueError(f"{path}: 列の数が合わない行があります: {r}")
if not (r["amount"].isascii() and r["amount"].isdigit()):
raise ValueError(f"{path}: amount は0以上の整数です: {r['amount']!r}")
amounts.append(int(r["amount"]))
return years, sum(amounts)
def main(sales_path, expenses_path):
try:
y1, sales = load(sales_path)
y2, expenses = load(expenses_path)
if len(y1 | y2) != 1:
raise ValueError(f"年が混ざっています: {sorted(y1 | y2)}")
except (OSError, ValueError) as e:
print(f"ERROR {e}")
return 2
income = sales - expenses
print(f"売上 {sales}円 - 経費 {expenses}円 = 所得 {income}円")
if income > LIMIT:
print("20万円超 -> 所得税の確定申告が必要")
return 10
print("20万円以下 -> この条件では申告不要")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1], sys.argv[2]))
E
さっきのデータで実行します。
python3 check.py sales_a.csv expenses_a.csv; echo "exit=$?"
売上 260000円 - 経費 70000円 = 所得 190000円
20万円以下 -> この条件では申告不要
exit=0
出力の最後を「申告不要」と言い切るか、少し迷いました。上に書いた前提が外れれば、不要とは限りません。だから「この条件では」を付けています。
20万円ちょうどと、1円違いを固定する
一番ずれやすいのは境界です。売上30万円を固定して、経費を10万円と99,999円にした2本を足します。所得は20万円ちょうど、と20万0001円です。
printf 'date,item,amount\n2026-05-01,テンプレートB,300000\n' > sales_b.csv
printf 'date,item,amount\n2026-05-02,AI APIの利用料,100000\n' > expenses_b.csv
printf 'date,item,amount\n2026-05-02,AI APIの利用料,99999\n' > expenses_c.csv
python3 check.py sales_b.csv expenses_b.csv; echo "exit=$?"
python3 check.py sales_b.csv expenses_c.csv; echo "exit=$?"
売上 300000円 - 経費 100000円 = 所得 200000円
20万円以下 -> この条件では申告不要
exit=0
売上 300000円 - 経費 99999円 = 所得 200001円
20万円超 -> 所得税の確定申告が必要
exit=10
並べると、所得19万円は終了コード0、20万円ちょうども0、20万0001円は10です。
この3本が本当に境界を見張っているのか、判定を >= に書き換えたコピーで確かめました。
sed 's/income > LIMIT/income >= LIMIT/' check.py > check_mut.py
python3 check_mut.py sales_b.csv expenses_b.csv; echo "exit=$?"
売上 300000円 - 経費 100000円 = 所得 200000円
20万円超 -> 所得税の確定申告が必要
exit=10
20万円ちょうどの結果が、0から10に変わりました。> と >= の取り違えは、データが19万円と25万円だけでは見つかりません。境界のデータは、この1本のために置いています。
悪い入力で、一度すり抜けました
CSVを検査するコードは、正常な見本だけでは足りません。列名の違い、データ0行、年の混在を試しました。
printf 'date,item,price\n2026-05-01,テンプレートB,300000\n' > sales_badcol.csv
printf 'date,item,amount\n' > sales_empty.csv
printf 'date,item,amount\n2025-12-31,テンプレートB,300000\n' > sales_2025.csv
for f in sales_badcol.csv sales_empty.csv sales_2025.csv; do
python3 check.py $f expenses_b.csv; echo "exit=$?"
done
ERROR sales_badcol.csv: 列は date,item,amount の順で必要です: ['date', 'item', 'price']
exit=2
ERROR sales_empty.csv: データ行が0行です
exit=2
ERROR 年が混ざっています: ['2025', '2026']
exit=2
ここまでは想定どおり。通らなかったのは、金額を 300,000 とカンマ付きで書いた行です。CSVではカンマが列の区切りになるため、300 と 000 に分かれます。
printf 'date,item,amount\n2026-05-01,テンプレートB,300,000\n' > sales_comma.csv
sed '/None in r or None in r.values()/,+1d' check.py > check_prefix.py
python3 check_prefix.py sales_comma.csv expenses_b.csv; echo "exit=$?"
売上 300円 - 経費 100000円 = 所得 -99700円
20万円以下 -> この条件では申告不要
exit=0
売上30万円が300円として読まれ、所得はマイナスになり、終了コード0で「申告不要」と返しました。申告が要る人に「要らない」と言う、一番危険な向きの間違いです。
check_prefix.py は、check.py から「列の数が合わない行」を調べる2行を抜いた、直す前の版です。
check.py の if None in r or None in r.values() は、この穴をふさぐために足した行です。直した版では、同じ入力が止まります。
python3 check.py sales_comma.csv expenses_b.csv; echo "exit=$?"
ERROR sales_comma.csv: 列の数が合わない行があります: {'date': '2026-05-01', 'item': 'テンプレートB', 'amount': '300', None: ['000']}
exit=2
全角数字の 3000000 も試しましたが、amount は0以上の整数です で止まりました。
まだ納得していないこと
この検査は、経費の線引きを判定していません。AI APIの利用料を全額経費にしてよいのか、自宅の通信費はどう按分するのか。コードは入力された経費をそのまま引くだけ。入れすぎれば所得は小さく出ます。判定が変わる20万円の手前では、ここが一番気になります。
もう1つは、事業所得と雑所得の違いです。この回は事業所得の計算式だけを根拠に書きました。どちらになるのか。国税庁の資料を読み込んでから、別に書くつもりです。
参照
- 国税庁 No.1900 給与所得者で確定申告が必要な人(令和8年4月1日現在法令等)
- 国税庁 No.1350 事業所得の課税のしくみ(事業所得)(令和8年4月1日現在法令等)


