AWSでサーバーレスな「未達成なら寄付Todoアプリ」を作ってみた #2(期限切れ自動検知〜Stripeカード登録編)
前回まで
前回の記事では、以下までを構築しました。
- DynamoDB 3テーブル(Users / Tasks / PaymentHistory)
- Cognitoユーザープール
-
createTask(タスク作成)、getTasks(一覧取得)、completeTask(完了)の3つのLambda + API Gateway
今回は、**「期限切れを自動検知する仕組み」と、「Stripeでカードを安全に登録する仕組み」**の2つを作りました。
Part 1:期限切れ自動検知(checkOverdueTasks)
何をする関数か
これまでの3つのLambdaは「ユーザーが能動的にリクエストを送った時」に動く関数でしたが、checkOverdueTasksは違います。誰もリクエストを送らなくても、毎日決まったタイミングで勝手に起動して、期限切れのタスクを"failed"に変える関数です。
コード
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { ScanCommand, UpdateCommand, DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
const client = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(client);
export const handler = async (event) => {
const now = new Date().toISOString();
const result = await docClient.send(new ScanCommand({ TableName: "Tasks" }));
const overdueTasks = result.Items.filter(
(task) => task.status === "pending" && task.dueDate < now
);
for (const task of overdueTasks) {
await docClient.send(
new UpdateCommand({
TableName: "Tasks",
Key: { taskId: task.taskId },
UpdateExpression: "SET #s = :newStatus",
ExpressionAttributeNames: { "#s": "status" },
ExpressionAttributeValues: { ":newStatus": "failed" },
})
);
}
return {
statusCode: 200,
body: JSON.stringify({
message: `${overdueTasks.length}件のタスクを未達成にしました`,
overdueTaskIds: overdueTasks.map((t) => t.taskId),
}),
};
};
ちょっとした発見:ISO 8601形式は文字列のまま大小比較できる
task.dueDate < now のように、日付をDateオブジェクトに変換せず文字列のまま比較しています。dueDateとnowがどちらも"2026-08-17T23:59:00"のようなISO 8601形式で統一されていれば、文字列としての大小関係がそのまま日時としての前後関係と一致するため、この書き方で問題なく動きます。
API Gateway と EventBridge、何が違う?
初めて「ユーザーのリクエストなしで動くLambda」を作ったので、両者の役割の違いが整理できました。
| API Gateway | EventBridge | |
|---|---|---|
| きっかけ | 外部からのHTTPリクエスト | 決まった時刻・間隔 |
| 起動する主体 | ユーザー(能動的) | システム自身(自動) |
| 今回の例 | createTask, getTasks, completeTask | checkOverdueTasks |
Lambda単体では絶対に動き出さず、必ず「トリガー」が必要というのが、サーバーレスの設計で重要なポイントだと実感しました。
EventBridge Schedulerの設定
「スケジュールの種類」はrateベース(1 days)を選択。cronベースだと時刻を厳密に指定できますが、今回は「だいたい1日1回チェックできればいい」という要件なので、シンプルなrateベースで十分でした。
- スケジュール名:
checkOverdueTasksDaily - スケジュールのパターン:rateベース、
1 days - ターゲット:Lambda(
checkOverdueTasks) - IAM実行ロール:新規作成
ハマったポイント:リージョン違い
checkOverdueTasksのLambdaとEventBridge Schedulerを、間違ったリージョンで作成してしまうというミスをやりました。AWSのリソースはリージョンごとに完全に独立しているため、正しいリージョン(東京 / ap-northeast-1)に切り替えると、作ったはずの関数もスケジュールも影も形もない、という状態になり、一瞬焦りました。
幸い、DynamoDB・Cognito・他の3つのLambdaは正しいリージョンに作られていたため、被害はcheckOverdueTasksとEventBridge Schedulerの2つだけで済みました。
教訓:AWSコンソールで新しいサービスの画面を開いたら、作業前に画面右上のリージョン表示を必ず確認する。地味ですが、これを徹底するだけで防げるミスでした。
動作確認
-
createTaskのAPIで、あえて過去の日付(dueDate)を指定したタスクを作成 -
checkOverdueTasksをLambdaコンソールから手動テスト実行 - レスポンスに
"1件のタスクを未達成にしました"と表示され、対象タスクのstatusがpending→failedに変わっていることをDynamoDBで確認
無事、期限切れの自動検知が動くことを確認できました。
Part 2:Stripeカード登録の準備(createSetupIntent)
Stripeの「Setup Intent」という考え方
寄付を自動化するには「後日、ユーザー操作なしでカードから引き落とす」仕組みが必要です。ただし、カード番号そのものを自分のシステムで扱うのは絶対に避けたいので、Stripeに任せます。
流れは大きく2段階です。
- 今回作った部分:カードを登録する(Setup Intent)。今すぐは課金しない
- 次回作る部分:登録済みのカードから、あとで自動課金する(off-session決済)
Setup Intentは「これからカードを登録する予定です」という意図をStripeに事前に伝えるためのオブジェクトです。実際にカード番号を入力するのはフロントエンド側(次回以降に実装予定)ですが、その前段階として「登録の準備をする」処理を今回Lambdaで作りました。
つまずきポイント集:Lambda Layerの作成
今回一番苦労したのは、コードの中身よりも**「StripeのライブラリをLambdaで使えるようにする」準備作業**でした。
そもそもなぜ準備が必要なのか
AWS公式のライブラリ(@aws-sdk/client-dynamodbなど)はLambdaの実行環境に最初から入っていますが、外部ライブラリのstripeパッケージは自分で持ち込む必要があります。これを実現するのがLambda Layerという仕組みです。
つまずき1:npmコマンドが見つからない
'npm' は、内部コマンドまたは外部コマンド、操作可能なプログラムまたはバッチ ファイルとして認識されていません。
原因はシンプルで、Node.js自体がパソコンにインストールされていなかったためでした。nodejs.orgからLTS版をインストールし、ターミナルを開き直すことで解決しました(インストール直後は古いターミナルのままだと認識されないことがあるので注意)。
つまずき2:zipコマンドが見つからない(Windows)
'zip' は、内部コマンドまたは外部コマンド、操作可能なプログラムまたはバッチ ファイルとして認識されていません。
zipコマンドはMac/Linuxには標準で入っていますが、Windowsには入っていません。今回はエクスプローラーの「送る→圧縮(zip形式)フォルダー」機能で代用しました。
ここで重要な注意点があります。Lambda Layerのzipファイルは、zip直下にnodejsフォルダが来る構造である必要があります。nodejsフォルダの親フォルダごと圧縮してしまうと、階層が1つ深くなってLambdaが正しく認識できなくなるため、必ずnodejsフォルダ単体を選んで圧縮する必要がありました。
手順まとめ
mkdir stripe-layer
cd stripe-layer
mkdir nodejs
cd nodejs
npm init -y
npm install stripe
このあとnodejsフォルダをzip化 → Lambdaコンソールの「レイヤー」→「レイヤーの作成」からアップロード → 関数側の「レイヤーの追加」→「カスタムレイヤー」から紐付け、という流れです。
ハマりやすいポイント:Layer作成時の「互換性のあるランタイム」は、関数本体のランタイム(今回はNode.js 24.x)と一致させる必要があります。ズレていると動きません。
環境変数でシークレットキーを安全に扱う
StripeのシークレットキーをLambdaのコードに直接書くのはセキュリティ上NGなので、環境変数に保存しました。
- 設定場所:Lambda「設定」→「環境変数」
- キー:
STRIPE_SECRET_KEY - 値:Stripeのテストモードのシークレットキー(
sk_test_...)
コード側ではprocess.env.STRIPE_SECRET_KEYと書くだけで読み込めます。コードそのものにはキーの文字列が一切現れないので、後でGitHubなどにコードを公開しても安全です。
createSetupIntentのコード
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { PutCommand, GetCommand, DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
import Stripe from "stripe";
const client = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(client);
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export const handler = async (event) => {
const body = JSON.parse(event.body);
const userId = body.userId;
try {
const existingUser = await docClient.send(
new GetCommand({ TableName: "Users", Key: { userId } })
);
let stripeCustomerId;
if (existingUser.Item && existingUser.Item.stripeCustomerId) {
stripeCustomerId = existingUser.Item.stripeCustomerId;
} else {
const customer = await stripe.customers.create({});
stripeCustomerId = customer.id;
await docClient.send(
new PutCommand({
TableName: "Users",
Item: { userId, stripeCustomerId },
})
);
}
const setupIntent = await stripe.setupIntents.create({
customer: stripeCustomerId,
});
return {
statusCode: 200,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ clientSecret: setupIntent.client_secret }),
};
} catch (error) {
console.error(error);
return {
statusCode: 500,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: "エラーが発生しました" }),
};
}
};
新しく使ったGetCommand
Scan(全件走査)でもUpdate(部分更新)でもなく、今回初めてGetCommandを使いました。パーティションキー(userId)が分かっている場合、GetCommandで1件だけをピンポイントに取得するのが最も効率的です。ここでは「このユーザーは既にStripe顧客として登録済みか?」の判定に使っています。
動作確認
curl -X POST https://xxxx.execute-api.ap-northeast-1.amazonaws.com/default/createSetupIntent -H "Content-Type: application/json" -d "{\"userId\":\"test-user-1\"}"
レスポンス:
{"clientSecret":"seti_xxxxxxxxxx_secret_xxxxxxxxxx"}
clientSecretが返ってくれば成功です。DynamoDBのUsersテーブルにも、stripeCustomerId(cus_...)が保存されていることを確認しました。
現在の進捗
バックエンド
- createTask / getTasks / completeTask
- checkOverdueTasks + EventBridge Scheduler
- createSetupIntent(Stripeカード登録の準備)
- フロントエンドからの実際のカード登録
- 自動課金Lambda(未達成タスクの金額をStripeで実際に引き落とす)
フロントエンド
- 未着手
AWSだけで完結する部分(データの作成・取得・更新・自動判定)は完成し、Stripe連携も土台(Lambda Layer、環境変数、顧客作成)まで完成しました。残るは「実際にカードを登録する画面」と「未達成時に本当に引き落とす処理」の2つです。
次回予告
次回は、checkOverdueTasksに「未達成が確定したタスクの金額を、登録済みのカードから実際に寄付として引き落とす」処理を追加します。ここでStripeのoff-session決済という、もう一つの重要な概念が出てきます。
この記事はAWS SAA学習の一環として、実際に手を動かしながら書いています。今回はコードよりも環境構築(リージョン確認、Node.js、Lambda Layer)のつまずきの方が多く、AWS以外の基礎知識も同時に学べる回でした。