7
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?

GASとAWSで家族カード管理サイトを作ってみた

Last updated at Posted at 2025-12-23

どうも、駆け出しエンジニアです

gakuです
初めてのアドベントカレンダー参加で何を書けばいいか分からず…
せっかくなら、日頃ちょっと面倒だなと思っていたことを
技術で楽にしてみようと思い、家族カード管理サイトを作ってみました

設計も実装も試行錯誤しながらなので、
「こういう作り方もあるんだな」くらいで読んでもらえれば嬉しいです。

実家にお金を入れる

皆さんは一人暮らしですか?
私は社会人1年目なのですが、まだ実家に住んでるんですよね
アドベントカレンダーでいうことでもないと思いますが…

社会人って実家にお金を入れるみたいで、調べたところ、20代男性の平均では月6万円ほど家に入れるケースも多いらしいです

私も多少は入れているのですが
現金をそのまま渡すのはつまらないので、親に三井住友カードNLの家族カードを渡してます(親から承諾はもらってます)
その家族カードで〇万円を目安で使っていいよーって感じです
しかし、以下の理由で管理がとてもめんどくさいです

  1. 親がカードで使った合計金額がわかるのは、締め日の後
  2. 利用通知から足し算してくのもめんどう
  3. 支払いの立替などで、現金で払うこともある
  4. 〇ヶ月分で!と大きい買い物をすることもある

なので、親が今いくら使用可能なのか可視化したいと思いました

作ったもの

利用履歴が自動で追加され、
「今月あといくら使えるか」が一目で分かる
家族カード管理用の可視化サイトを作りました

手動で追加することもできるようにしています

作ったものは以上になるので、これ以降は開発メモになります

技術選定

今回はDynamoDBを使ってみたい、とにかく安くしたいという思想で作成したので、めちゃくちゃ単純です

フロントエンド

React(Vite) + TypeScript

バックエンド インフラ

  • Google Apps Script(GAS)
  • AWS API Gateway(HTTP API)
  • AWS Lambda(python3.14)
  • DynamoDB

image.png

本来であれば認証やIP制限などを入れるべきですが、
今回は利用者が親のみという前提で、簡略化しています

また、今回はどれだけコードを自分で書かなくても実装できるかも試したいので、できるだけAIに書いてもらいました

SAM使ってみたかったんですけど、時間なくてマネコンポチポチ実装になります…

なぜ GAS?

GAS(Google Apps Script)って便利でGoogleサービスのAPIを使えるんですよね
三井住友カードの家族カードは、利用されると以下のような即時メールが届きます

なので今回は、GASにあるGmailAppのAPIを使用して、メールに来た利用履歴をDBに入れちゃおう作戦です

GASって定期実行の設定がとても簡単なんですよね

注意点

Gmailの無料アカウントはAPI制限があるみたい(具体的な数字は開示されてない?)
定期実行頻度は10分程度にしておきます

実装

実装したものを適当に乗っけていきます

まずはGASを実装

利用通知メールの定期監視

家族カード利用通知メールを定期監視するプログラム
function extractAndPostUsageData() {
  // 1. POST先のURLを定義します。
  // !!! ここにご自身のPOST先のURLを記入してください !!!
  const scriptProperties = PropertiesService.getScriptProperties();
  const POST_URL = scriptProperties.getProperty('API_GATEWAY_URL');

  // 2. Gmail検索クエリを定義します。
  const searchQuery = 'is:unread from:"三井住友カード" subject:"家族カードご利用のお知らせ【三井住友カード】"';
  const threads = GmailApp.search(searchQuery);

  if (threads.length === 0) {
    Logger.log("該当する未読メールは見つかりませんでした。");
    return;
  }

  let threadsMarkedAsReadCount = 0;

  // 3. 取得したスレッドを一つずつ処理します。
  threads.forEach(thread => {
    const subject = thread.getFirstMessageSubject();
    Logger.log(`==========================================`);
    Logger.log(`スレッド件名: ${subject}`);
    
    // このスレッド内で1回でもPOSTに成功したかどうかのフラグ
    let postSuccessfulInThread = false; 
    
    // スレッド内のすべてのメッセージを取得
    const messages = thread.getMessages();
    
    // 4. スレッド内のメッセージを一つずつ処理します。
    messages.forEach((message, messageIndex) => {
      const msgIdentifier = `[Message ${messageIndex + 1}]`;
      // プレーンテキストの本文を取得
      const plainBody = message.getPlainBody();

      // --- 抽出用の正規表現 ---
      
      // 1. 利用日(例: ◇利用日:2025/12/03 21:57)
      // マッチグループ1に日付と時刻を格納
      const dateRegex = /(?:◇利用日|ご利用日時|利用日時|利用日)\s*[::]\s*(\d{4}\/\d{2}\/\d{2}\s*\d{2}:\d{2})/;
      
      // 2. 利用先(例: ◇利用先:Visa加盟店)
      // マッチグループ1に利用先の文字列を格納
      const merchantRegex = /(?:◇利用先|ご利用先|利用先)\s*[::]\s*([^\r\n]+)/;
      
      // 3. 利用金額(例: ◇利用金額:301円)
      // マッチグループ1に金額文字列を格納
      const amountRegex = /(?:◇利用金額|ご利用金額|利用金額|ご利用料金|利用料金|お支払金額|合計金額)\s*[::]\s*([¥\uffe5]?\s*[0-9,]+)\s*円/;
      
      // 本文から最初の一致を抽出
      let dateMatch = plainBody.match(dateRegex);
      let merchantMatch = plainBody.match(merchantRegex);
      let amountMatch = plainBody.match(amountRegex);
      
      let extractedDate = null;
      let extractedMerchant = null;
      let extractedAmount = null;

      // 抽出結果の格納
      if (dateMatch && dateMatch[1]) {
        extractedDate = dateMatch[1].trim();
        Logger.log(`${msgIdentifier} 日時抽出: ${extractedDate}`);
      }

      var now = new Date();

      // 指定したフォーマット (YYYY/MM/DD HH:mm) に変換
      // var insert_date = Utilities.formatDate(now, Session.getScriptTimeZone(), "yyyy/MM/dd HH:mm");
      
      if (merchantMatch && merchantMatch[1]) {
        extractedMerchant = merchantMatch[1].trim();
        // 抽出した利用先に含まれる "◇" や "¥" などの記号を念のため取り除く
        extractedMerchant = extractedMerchant.replace(/[\uffe5◇]/g, '').trim(); 
        Logger.log(`${msgIdentifier} 利用先抽出: ${extractedMerchant}`);
      }
      
      if (amountMatch && amountMatch[1]) {
        extractedAmount = amountMatch[1].trim();
        cleanAmount = amountMatch[1].replace(/,/g, '').trim();
        Logger.log(`${msgIdentifier} 金額抽出: ${extractedAmount}`);
      }
      
      // 5. 3つの情報すべてが揃っているか確認し、揃っていればPOST処理を実行
      if (extractedMerchant && cleanAmount) {
        
        // POSTするリクエストボディ(JSON形式)を構築
        const payload = {
          USER: "1",
          // 利用日時
          DATE: extractedDate, 
          // 利用先
          categoryName: extractedMerchant,
          // 利用金額 (カンマや¥記号を含む生文字列)
          amount: cleanAmount 
        };

        const options = {
          'method': 'post',
          'contentType': 'application/json',
          'payload': JSON.stringify(payload),
          'muteHttpExceptions': true // エラーが発生してもスクリプトを停止させない
        };
        
        try {
          const response = UrlFetchApp.fetch(POST_URL, options);
          const responseCode = response.getResponseCode();
          
          if (responseCode >= 200 && responseCode < 300) {
            Logger.log(`${msgIdentifier} ✅ POST成功 (Code: ${responseCode}) - ${JSON.stringify(payload)}`);
            postSuccessfulInThread = true; // 成功フラグを立てる
          } else {
            Logger.log(`${msgIdentifier} ❌ POST失敗 (Code: ${responseCode}) - ${response.getContentText()}`);
          }
        } catch (e) {
          Logger.log(`${msgIdentifier} 致命的なPOSTエラー: ${e.toString()}`);
        }
      } else {
        Logger.log(`${msgIdentifier} ⚠️ 必要な情報が不足しているため、POSTをスキップします。(日時: ${!!extractedDate}, 利用先: ${!!extractedMerchant}, 金額: ${!!extractedAmount})`);
      }
      
    }); // messages.forEach 終了
    
    // 6. スレッド内のメッセージで1回でもPOSTが成功していれば既読にする
    if (postSuccessfulInThread) {
      thread.markRead();
      threadsMarkedAsReadCount++;
      Logger.log(`  -> スレッド全体を既読にしました。`);
    } else {
      Logger.log(`  -> スレッド内で有効なデータ抽出とPOSTが確認できなかったため、既読にせずスキップします。`);
    }
  }); // threads.forEach 終了

  Logger.log(`==========================================`);
  Logger.log(`処理が完了しました。合計 ${threadsMarkedAsReadCount}件のスレッドを既読にしました。`);
}

ほとんどgeminiに書いてもらったんですけどコメントアウトやら、出力がうるさい

以下で未読のメールを見つけます

const searchQuery = 'is:unread from:"三井住友カード" subject:"家族カードご利用のお知らせ【三井住友カード】"';
const threads = GmailApp.search(searchQuery);

注意点:GASだとしても環境変数をしっかり使います

const scriptProperties = PropertiesService.getScriptProperties();
const POST_URL = scriptProperties.getProperty('API_GATEWAY_URL');

image.png

あとは、いい感じにまとめてpostするだけ(絶対postにも認証情報は入れるべき)

関数が完成したら、トリガーを追加して10分おきに監視するようにしておきます

月初の自動入金処理

使っていい金額を与える必要があります

自動入金処理
function postDeposit() {
  // POST先のURLを定義
  const scriptProperties = PropertiesService.getScriptProperties();
  const POST_URL = scriptProperties.getProperty('API_GATEWAY_URL');
  
  // 現在の日時を取得してフォーマット (YYYY/MM/DD HH:mm)
  const now = new Date();
  const currentDate = Utilities.formatDate(now, Session.getScriptTimeZone(), "yyyy/MM/dd HH:mm");
  
  // POSTするリクエストボディを構築
  const payload = {
    USER: "1",
    DATE: currentDate,
    categoryName: "【入金】",
    amount: <家に入れる金額>
  };
  
  const options = {
    'method': 'post',
    'contentType': 'application/json',
    'payload': JSON.stringify(payload),
    'muteHttpExceptions': true
  };
  
  try {
    const response = UrlFetchApp.fetch(POST_URL, options);
    const responseCode = response.getResponseCode();
    
    if (responseCode >= 200 && responseCode < 300) {
      Logger.log(`✅ 入金処理POST成功 (Code: ${responseCode}) - ${JSON.stringify(payload)}`);
    } else {
      Logger.log(`❌ 入金処理POST失敗 (Code: ${responseCode}) - ${response.getContentText()}`);
    }
  } catch (e) {
    Logger.log(`致命的なPOSTエラー: ${e.toString()}`);
  }
}

こちらの関数は、月ベースのタイマーを毎月1日に実行するようにしておきます

続いてAPI GateWay + lambda

安くするためにHTTP APIを使ってみました。UIこんな感じなんや

単純なwebサイトなので、APIは以下だけです。(APIの命名は適当です)

それぞれにlambdaを統合して、実装しました
コードは適当にchatGPTに書いてもらいました

注意点として、REST APIの場合CORSを有効にするボタンが右上にあるんですけど、HTTP APIの場合CORSタブがサイドバーにあり、そこで有効にする必要がありました(今回ゆるゆるCORS)

image.png

また、POSTのmethodがあるパスに手動でOPTIONSを作成すると、CORSエラーになるので注意

DynamoDB

DynamoDB使うの初めてだったんで、全然わかんないけど
こんな感じなんや
USERをPK、DATEをSKとしてみました

履歴件数も少なく、利用者も1人なので今回はこの設計にしていますが、
履歴が増える場合は SK に種別や年月を含めた設計にした方が良さそうですかね

image.png

DynamoDBってとても便利ですね。VPCいらないし、RDSみたいに踏み台作らなくてもデータ見れるし、重複データはinsertされないのも地味に嬉しい

項目は、以下のようにしてみました。利用金額と使用可能金額を常に最新に持つようにしてます。

image.png

フロントエンド

S3 + cloudfrontを作成しました。作り方は一般的なやつ

フォルダ構成は以下の感じで最低限です
プログラムはほとんどGitub CopilotのAgentモードに書いてもらいました
Agentモードはバックエンド開発の時はあまり使わないようにしているのですが(たまにぶっ壊される)
フロントエンドだとほとんど自然言語で書けていいですね。新しいファイルとかも勝手に作ってくれてとても簡単
React全然書けなくても動作は問題なさそう

.
├── api
│   └── financeApi.ts
├── App.css
├── App.tsx
├── components
│   ├── BalanceCard.tsx
│   ├── Header.tsx
│   ├── History.tsx
│   ├── MonthSelector.tsx
│   └── TransactionModal.tsx
├── index.css
├── main.tsx
└── types
    └── finance.ts

App.tsxでコンポーネントをまとめて表示してます
メモ化など何もやってないので、再レンダリングされまくりますね

App.tsx
import React, { useEffect, useState } from "react";
import Header from "./components/Header";
import BalanceCard from "./components/BalanceCard";
import MonthSelector from "./components/MonthSelector";
import History from "./components/History";
import TransactionModal from "./components/TransactionModal";
import { fetchBalance, fetchHistory, postTransaction } from "./api/financeApi";
import type { Balance, HistoryItem } from "./types/finance";
import Box from '@mui/material/Box';
import Fab from '@mui/material/Fab';
import AddIcon from '@mui/icons-material/Add';

const App: React.FC = () => {
  const [balance, setBalance] = useState<Balance | null>(null);
  const [history, setHistory] = useState<HistoryItem[]>([]);
  const [modalOpen, setModalOpen] = useState(false);
  const [selectedMonth, setSelectedMonth] = useState<string>(() => {
    const now = new Date();
    const year = now.getFullYear();
    const month = String(now.getMonth() + 1).padStart(2, '0');
    return `${year}/${month}`;
  });

  useEffect(() => {
    const load = async () => {
      const balanceData = await fetchBalance();
      setBalance(balanceData);
    };

    load();
  }, []);

  useEffect(() => {
    const loadHistory = async () => {
      const historyData = await fetchHistory(selectedMonth);
      setHistory(historyData);
    };

    loadHistory();
  }, [selectedMonth]);

  const handleTransactionSubmit = async (categoryName: string, amount: number) => {
    try {
      await postTransaction(categoryName, amount);
      // 取引追加後、残高と履歴を再取得
      const balanceData = await fetchBalance();
      const historyData = await fetchHistory(selectedMonth);
      setBalance(balanceData);
      setHistory(historyData);
    } catch (error) {
      console.error("Transaction failed:", error);
      alert("取引の追加に失敗しました");
    }
  };

  return (
    <Box sx={{ minHeight: "100vh", width: "100vw", background: "#f5f5f5" }}>
      <Header />
      <Box
        sx={{
          padding: 2,
          boxSizing: "border-box",
        }}
      >
        {balance && <BalanceCard balance={balance} />}
        <MonthSelector selectedMonth={selectedMonth} onMonthChange={setSelectedMonth} />
        <History items={history} />
      </Box>

      <Fab
        color="primary"
        aria-label="add"
        onClick={() => setModalOpen(true)}
        sx={{
          position: "fixed",
          bottom: 16,
          right: 16,
        }}
      >
        <AddIcon />
      </Fab>

      <TransactionModal
        open={modalOpen}
        onClose={() => setModalOpen(false)}
        onSubmit={handleTransactionSubmit}
      />
    </Box>
  );
};

export default App;

環境変数を入れてbuildします。

VITE_API_GATEWAY_URL=https://example.com npm run build

できたdistフォルダを全てS3にアップロードしたら終わり

作ってみての感想

AIのおかげで1日くらいで作ることができました。
毎月やっていた手作業の集計がやらなくて済みそうです

作ってみせた親の反応はこちら

日頃面倒と思っていることを自動化できるのってやっぱ楽しいなって思いました

今後やりたいこと

  • SAMとかパイプラインとか
  • セキュリティ強化
  • メモ化
  • DynamoDBのもっと深い使い方
7
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
7
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?