Edited at

AWS Lambda と AWS SESを使ってたった150行でカード有効期限お知らせサービスを書いた話

 今回は有効期限が迫っているカードの更新を顧客に促すフローを自動化する方法を紹介する。使うのはLambdaとSESだけなのでとてもお手軽にインフラが揃う。必要なのはOmiseとAWSのアカウントだけ。

レポジトリ


準備


お知らせテンプレートの作成

以下のようなテンプレートを作成

{

"Template": {
"TemplateName": "ExpiredCardNotification",
"SubjectPart": "【重要】 カードの更新をお願いします",
"TextPart": "お客様、\n お世話になります。\n あなたのカード (名義:{{name}}, 有効期限: {{month}}/{{year}}) はもうすぐ使用できなくなります。\n 更新する場合は弊社のフォームにて再登録をお願いします",
"HtmlPart": "お客様、</br> お世話になります。</br> あなたのカード (名義:{{name}}, 有効期限: {{month}}/{{year}}) はもうすぐ使用できなくなります。</br> 更新する場合は弊社のフォームにて再登録をお願いします""
}
}


お知らせテンプレートの登録

AWS SES に作成したテンプレートを登録。

aws ses create-template --cli-input-json file://<テンプレートのファイル名>

上のコマンドを叩くとテンプレート名ExpiredCardNotificationで登録される


AWS Lambda にコードをアップロード

今回は asyncrequest のライブラリが必要だったので基本ロジックのコード以外にもこれらをアップロードする必要があった。以下の方法でアップロードする:


  1. ディレクトリを作る



  2. requestrequest-promiseそしてlodashをインストール

     npm install --save request request-promise lodash
    

    レポジトリpackage.jsonを使う場合は

     npm install --production
    


  3. カード有効期限モニタリングコードの作成。名前はindex.jsにすること



  4. コードの圧縮

     zip -r ../lambda_code.zip *
    


  5. 圧縮ファイルのアップロード



  6. 以下の環境変数を登録



    • OMISE_SECRET_KEY(Omise の秘密鍵)


    • SES_SOURCE (送信元)


    • SES_AWS_REGION (AWS SES のリージョン)


    • REMAINING_MONTHS_THRESHOLD (カードの有効期限何ヶ月前に通知するか)



ちなみにこれがカードモニタリングコード

const rp = require('request-promise')

const aws = require('aws-sdk')
const _ = require('lodash')

const Config = {
AWSRegion: process.env.SES_AWS_REGION || 'us-east-1',
RemainingMonthsThreshold: parseInt(process.env.REMAINING_MONTHS_THRESHOLD || 2),
OmiseSecretKey: process.env.OMISE_SECRET_KEY || '',
OmiseCustomerApiUrl: process.env.OMISE_CUSTOMER_API_URL || 'https://api.omise.co/customers',
SesSource: process.env.SES_SOURCE || ''
}

const ses = new aws.SES({
region: Config.AWSRegion
})

const monthDiff = (month, year) => {
const today = new Date()
return (year - today.getFullYear()) * 12 + month - today.getMonth()
}

const selectUnnotifiedCards = (customer, cards) => {
const alreadyNotified = _.get(customer, 'metadata.notified')
if (alreadyNotified) {
return _.filter(cards, c => !alreadyNotified.includes(c.id))
} else {
return cards
}
}

const makeEmailParamsPerCard = (customerEmail, card) => {
const templateParams = {
card_id: card.id,
name: card.name,
month: card.expiration_month,
year: card.expiration_year,
months_til_expiration: card.months_til_expiration
}

return {
Destination: { ToAddresses: [customerEmail] },
ReplacementTemplateData: JSON.stringify(templateParams)
}
}

const customerWithEmailParams = (customer) => {
const unnotifiedCards = selectUnnotifiedCards(customer, customer.cards.data)
const cardsWithExpiration = _.map(unnotifiedCards, c => _.merge(c, Object({ months_til_expiration: monthDiff(c.expiration_month, c.expiration_year) })))
const cardsToNotify = _.filter(cardsWithExpiration, c => c.months_til_expiration <= Config.RemainingMonthsThreshold)

return {
emailParams: _.map(cardsToNotify, c => makeEmailParamsPerCard(customer.email, c)),
customer_id: customer.id,
card_ids: _.map(cardsToNotify, c => c.id),
notified: _.get(customer, 'metadata.notified', [])
}
}

const getCustomers = async () => {
const getCustomersParams = {
url: Config.OmiseCustomerApiUrl,
auth: {
user: Config.OmiseSecretKey,
pass: ''
},
transform: function (body) {
return _.map(JSON.parse(body).data, c => customerWithEmailParams(c))
}
}
return rp(getCustomersParams)
}

const createBulkEmailParams = (destinations) => {
return {
Destinations: destinations,
Source: Config.SesSource,
Template: 'ExpiredCardNotification',
DefaultTemplateData: '{"name":"","month":"","year":"","months_til_expiration":"","card_id":""}'
}
}

const registerNotified = async (customerId, cardIds) => {
const data = {
metadata: {
notified: cardIds
}
}
const patchCustomersParams = {
url: Config.OmiseCustomerApiUrl + '/' + customerId,
method: 'PATCH',
body: data,
json: true,
auth: {
'user': process.env.OMISE_SECRET_KEY,
'pass': ''
}
}
return rp(patchCustomersParams)
}

const markAsNotifiedPromises = (cardStatus, customers) => {
return _.map(customers, function (customer) {
const notified = customer.notified
const cardIds = _.filter(customer.card_ids, c => cardStatus[c] === true)
const newNotified = notified.concat(cardIds)
const customerId = customer.customer_id
return registerNotified(customerId, newNotified)
})
}

const main = async () => {
const customers = await getCustomers()
const toSend = _.filter(customers, c => c.card_ids.length > 0)
const destinations = _.flatMap(toSend, c => c.emailParams)
const cards = _.flatMap(toSend, c => c.card_ids)

if (cards.length === 0) {
return { cardStatus: [], customers: [] }
}

console.log('===SENDING EMAIL===')
const bulkEmailParams = createBulkEmailParams(destinations)
const sendPromise = ses.sendBulkTemplatedEmail(bulkEmailParams).promise()
return sendPromise.then((data) => {
const statuses = {}
_.zip(cards, data.Status).forEach(function ([cardId, status]) {
statuses[cardId] = status.Status === 'Success'
})
return { cardStatus: statuses, customers: toSend }
})
}

exports.handler = async (event, context) => {
console.log('Incoming: ', event)

const result = await main()
.catch(err => {
console.log('error', err)
return context.fail(err)
})
if (result.customers.length === 0) {
return context.succeed(event)
}
console.log(result.cardStatus)
console.log('===EMAIL SENT===')
const reports = await markAsNotifiedPromises(result.cardStatus, result.customers)
await Promise.all(reports)
context.succeed(event)
}


カード更新後

カードの更新通知がされるとマーチャントのメタデータに更新したという記録が残される。具体的にはnotified のアレイに通知されたカードが追加される。

const registerNotified = async (customerId, cardIds) => {

const data = {
metadata: {
notified: cardIds
}
}
const patchCustomersParams = {
url: Config.OmiseCustomerApiUrl + '/' + customerId,
method: 'PATCH',
body: data,
json: true,
auth: {
'user': process.env.OMISE_SECRET_KEY,
'pass': ''
}
}
return rp(patchCustomersParams)
}

そこで、更新が済んだらこの notifiedからカードId を削除すれば有効期限の監視を再開しなければいけない。

API だと以下のようにできる

curl -H "Content-Type:application/json" -X PATCH https://api.omise.co/customers/<customer id>   -u <secret key>: -d '{ "metadata": {} }'


最後に

Omise には omise-node や他の言語のクライアントライブラリがあり、これらを駆使して AWS Lambda のコードを書くこともできる。しかし、AWS Lambda に積められるコードの量に限度があったり、コード量が 3MB を超えると付属のナウいエディタが使えなかったりするので今回はフルスクラッチで軽量なものを作ってみた。

Lambda といえば、Lisp。LispといえばLisp Alien。

lisplogo_alien_256.png