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



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

[レポジトリ](https://git.omise.co/shunichi/card-expiration-notifier)

## 準備

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

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

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

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

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

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



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

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

1. ディレクトリを作る

2. `request`、`request-promise`そして`lodash`をインストール

     ```bash
     npm install --save request request-promise lodash
     ```
     [レポジトリ](https://git.omise.co/shunichi/card-expiration-notifier)の `package.json`を使う場合は

     ```bash
     npm install --production
     ```

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

4. コードの圧縮

     ```bash
     zip -r ../lambda_code.zip *
     ```

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

6. 以下の環境変数を登録
    - `OMISE_SECRET_KEY`（Omise の秘密鍵）
    - `SES_SOURCE` (送信元）
    - `SES_AWS_REGION` (AWS SES のリージョン）
    - `REMAINING_MONTHS_THRESHOLD` (カードの有効期限何ヶ月前に通知するか）

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

```javascript
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` のアレイに通知されたカードが追加される。

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

Lambda といえば、Lisp。LispといえばLisp Alien。
![lisplogo_alien_256.png](https://qiita-image-store.s3.amazonaws.com/0/138674/0a8b0362-d45d-a38e-70b0-090c6b6e2961.png)