💰

ClockfiyAPI と MoneyForwardAPIで毎月の請求書発行業務を自動化してみた

2024/12/24に公開

はじめに

株式会社ハックツ エンジニアのあいでんです。

私は普段バックオフィス業務も行なっています。
毎月の稼働時間を集計して請求書を発行する業務は単純でありながら、意外と時間を取られてしまいがちな業務の1つです。

今回はその煩雑な業務を自動化した事例を紹介いたします。

概要

今回のゴール

  • Clockify APIで作業時間を集計する
  • MoneyForward APIで請求書を発行する

完成イメージ

弊社では社内チャットツールとしてSlackを使用しているため、最終的には作成した請求書のPDFをSlackのチャンネルに送信します。Slack Botを作成するのは若干骨が折れるため、今回はZapierを使うことで代替しました。

本記事ではSlackZapier周りの連携や、Cloudflareへのデプロイ周りなどは取り上げず、実際に動く機能のみに絞ってご紹介いたします。

サービス紹介

Cloudflare Workersとは

Cloudflare上のエッジサーバーで動くJavaScript実行環境のことです。
今回はデプロイ先とDB先として活用しています。

https://www.cloudflare.com/ja-jp/developer-platform/products/workers/

Clockifyとは

Clockifyとは、チームがプロジェクトの作業時間を追跡できるタイムトラッキングツールです。
弊社では、このClockifyを使ってクライアントワークに関する作業時間を集計しています。

https://clockify.me/

MoneyForwardとは

マネーフォワードとは、バックオフィス全般に関するクラウド型サービスです。
会計・経理といった業務はもちろん、人事労務や電子契約などの幅広い領域にも対応しています。

https://biz.moneyforward.com/

Zapierとは

Zapierは、複数のWebサービスやアプリケーションを連携させてワークフローを自動化できるツールです。今回はProfessionalプランを利用しています。

https://zapier.com/

環境構築

今回はHonoを使って開発しました。
詳しい環境構築は公式様をご覧いただければと思います。
とりあえず、以下のコマンドを叩いておけばいい感じに環境構築できます。

pnpm create hono@latest my-app

https://hono.dev/docs/getting-started/basic

envについて

ClockifyのAPI KEYやMoneyForward APIのtokenなどはSecret Classで管理されています

export class Secret {
  readonly CLOCKIFY_API_KEY: string;
  readonly CLOCKIFY_WORKSPACE_ID: string;
  readonly MONEY_FORWARD_CLIENT_ID: string;
  readonly MONEY_FORWARD_AUTHORIZATION_CODE: string;
  readonly MONEY_FORWARD_KV: KVNamespace;

  constructor(c: Context<{ Bindings: Bindings }>) {
    const env = v.parse(EnvSchema, c.env);

    this.CLOCKIFY_API_KEY = env.CLOCKIFY_API_KEY;
    this.CLOCKIFY_WORKSPACE_ID = env.CLOCKIFY_WORKSPACE_ID;
    this.MONEY_FORWARD_CLIENT_ID = env.MONEY_FORWARD_CLIENT_ID;
    this.MONEY_FORWARD_AUTHORIZATION_CODE = env.MONEY_FORWARD_AUTHORIZATION_CODE;
    this.MONEY_FORWARD_KV = c.env.MONEY_FORWARD_KV;
  }
}

APIをラップする

今回はAPIを使いやすくするために、openapi-fetchopenapi-typescriptを使用しています。

pnpm install openapi-fetch && pnpm install -D openapi-typescript

https://openapi-ts.dev/

以下のようにAPIをラップしてから使用することで、TypeScriptが効くようになるので型安全に開発できるようになります。

moneyForward.ts
import createClient from "openapi-fetch";
import type { paths } from "../types/moneyForward/schema";

export const moneyForwardClient = createClient<paths>({
  baseUrl: "https://invoice.moneyforward.com/api/v3",
});
clockify.ts
import createClient from "openapi-fetch";
import type { paths } from "../types/moneyForward/schema";

export const clockifyClient = createClient<paths>({
  baseUrl: "https://api.clockify.me/api",
});

export const clockifyReportClient = createClient<paths>({
  baseUrl: "https://reports.api.clockify.me",
});
pathsについて

createClientに渡しているpathsはOpenAPIから吐き出したものになります。

package.json
{
  "scripts": {
    "codegen:moneyforward": "npx openapi-typescript https://invoice.moneyforward.com/docs/api/v3/reference/iv_web_api.yaml -o ./src/types/moneyForward/schema.d.ts",
    "codegen:clockify": "npx openapi-typescript https://docs.clockify.me/openapi.json -o ./src/types/clockify/schema.d.ts",
    "codegen": "pnpm run codegen:moneyforward && pnpm run codegen:clockify"
  },
  "dependencies": {
    "hono": "^4.6.3",
    "openapi-fetch": "^0.13.0",
  },
  "devDependencies": {
    "openapi-typescript": "^7.4.3",
  }
}

Clockify APIで作業時間を集計する

まずはAPI KEYを発行します。
以下のURLからGenerateボタンをクリックして、API KEYを控えておいてください。
https://app.clockify.me/user/preferences#advanced

請求可能なプロジェクトID一覧を取得する

Cloclifyで管理するプロジェクトには、クライアントワークに関するものもあれば、自社開発に関するものなどがあります。つまり、請求が発生するものと請求が発生しないものがあるということです。

Clockifyでは請求可能かどうかを管理するBillableというプロパティがプロジェクトごとに生えています。まずは請求が必要なプロジェクトだけを取得します。

export const getBillableProjectIdList = async (
  secret: Secret,
): Promise<string[] | null> => {
  const res = await clockifyClient.GET(
    "/v1/workspaces/{workspaceId}/projects",
    {
      headers: {
        "X-Api-Key": secret.CLOCKIFY_API_KEY,
      },
      params: {
        path: {
          workspaceId: secret.CLOCKIFY_WORKSPACE_ID,
        },
        query: {
          archived: "false",
          billable: "true",
        },
      },
    },
  );

  if (!res.data) {
    return null;
  }

  const billableProjectIdList = res.data.map((project) => project.id as string);

  return billableProjectIdList;
};

レポートを作成する

先ほど取得したプロジェクトID一覧をもとに、一定期間における作業時間の合計を取得します。そのために通常のClockify APIではなく、Clockify Report APIを使用します。

ここでのポイントはsummaryFiltergroupsを指定することです。ここでは["PROJECT", "TAG"]を指定していますが、["PROJECT"]を設定することによりプロジェクトごとに作業時間を集計してくれます。

弊社ではプロジェクトごとの各作業にタグを付与しているため、["TAG"]を設定することによりプロジェクト内のタグごとにも作業時間を集計してくれます。プロジェクトの合計作業時間の内訳というようなイメージです。

export const getReport = async (
  secret: Secret,
  billableProjectIdList: string[],
  dateRangeStart: string,
  dateRangeEnd: string,
): Promise<GroupOne[]|null> => {
  const res = await clockifyReportClient.POST("/v1/workspaces/{workspaceId}/reports/summary", {
    headers: {
      "X-Api-Key": secret.CLOCKIFY_API_KEY,
      "Content-Type": "application/json",
    },
    params: {
      path: {
        workspaceId: secret.CLOCKIFY_WORKSPACE_ID,
      },
    },
    body: {
      dateRangeStart,
      dateRangeEnd,
      projects: {
        contains: "CONTAINS",
        ids: billableProjectIdList,
      },
      summaryFilter: {
        sortColumn: "GROUP",
        groups: ["PROJECT", "TAG"],
      },
    }
  })

  if (!res.data) {
    return null
  }

  if (!res.data.groupOne || res.data.groupOne.length === 0) {
    return null
  }

  return res.data.groupOne
};

データを整形する

最後に請求書を作成するにあたって使いやすいようなデータの形に整形を行います。switch文での条件分岐は["TAG"]ごとや、請求書を作成するにあたっての分類に応じて増減すると良いと思います。

utils/calculate.ts
export const calculateTotalQuantityOfCategory = (
  taskReportList: GroupOne[],
) => {
  let totalDevHours = 0;
  let totalOtherHours = 0;

  for (const task of taskReportList) {
    switch (task.name!) {
      case "dev":
        totalDevHours += task.duration!;
        break;
      default:
        totalOtherHours += task.duration!;
        break;
    }
  }

  return [
    {
      name: "dev",
      hours: Math.ceil((totalDevHours * 100) / 3600) / 100,
    },
    {
      name: "other",
      hours: Math.ceil((totalOtherHours * 100) / 3600) / 100,
    },
  ];
};
presenters/getSummaryReportPresenter.ts
export const output = (reportList: GroupOne[]): GetSummaryReportResponse => {
  return {
    reportList: reportList.map((report) => {
      return {
        id: report.id ?? '',
        name: report.name ?? '',
        totalHours: Math.ceil((report.amount! * 100) / 3600) / 100,
        details: calculateTotalQuantityOfCategory(report.children!),
      };
    }),
  };
};

MoneyForward APIで請求書を発行する

アクセストークンを発行

マネーフォワード クラウド請求書API を利用するための準備を行います。
APIを叩く時には常にtokenが必要になります。

手続きがやや複雑なので、公式様のドキュメントを引用させていただきます🙏
最終的には、アクセストークンが発行できてればOKです!
https://biz.moneyforward.com/support/invoice/guide/api-guide/a04.html

アクセストークンを管理する

上記で発行したアクセストークンは1時間で有効期限が切れてしまいます。そのため有効期限が切れている場合にはrefresh_tokenを使ってアクセストークンを再発行する必要があります。

今回はMiddlewareでアクセストークンの有効期限をチェックし、有効期限が切れている場合のみ再発行するようにしました。また、tokenrefresh_token, expired_atなどはCloudflare Workers KVに保存されています。

これによりAPIを叩く時には常に必要なtokenのしがらみから解放されます。

https://www.cloudflare.com/ja-jp/developer-platform/products/workers-kv/

moneyForwardTokenMiddlware.ts
import { isBefore, subMinutes } from "date-fns";
import type { Context, Next } from "hono";
import { Secret } from "../config/secrets";
import type { Bindings } from "../types";
import type { RefreshTokenResponse } from "../types/moneyForward";

export const moneyForwardTokenMiddleware = async (
  c: Context<{ Bindings: Bindings }>,
  next: Next,
) => {
  try {
    const secret = new Secret(c);

    const refreshToken = await secret.MONEY_FORWARD_KV.get("refresh_token");
    const expiredAt = (await secret.MONEY_FORWARD_KV.get(
      "expired_at",
    )) as string;

    if (isBefore(new Date(), subMinutes(new Date(expiredAt), 5))) {
      return await next();
    }

    if (!refreshToken) {
      throw new Error("refresh_token is not found");
    }

    const credentials = Buffer.from(`${secret.MONEY_FORWARD_CLIENT_ID}:${secret.MONEY_FORWARD_AUTHORIZATION_CODE}`).toString("base64");

    const res = await fetch("https://api.biz.moneyforward.com/token", {
      method: "POST",
      headers: {
        Authorization: `Basic ${credentials}`,
        "Content-Type": "application/x-www-form-urlencoded",
      },
      body: new URLSearchParams({
        grant_type: "refresh_token",
        refresh_token: refreshToken,
      }),
    });

    const json = (await res.json()) as RefreshTokenResponse;

    const newExpiredAt = new Date(
      new Date().getTime() + json.expires_in * 1000,
    ).toISOString();

    await c.env.MONEY_FORWARD_KV.put("refresh_token", json.refresh_token);
    await c.env.MONEY_FORWARD_KV.put("token", json.access_token);
    await c.env.MONEY_FORWARD_KV.put("expired_at", newExpiredAt);

    return await next();
  } catch (error) {
    console.error("error", error);
    return new Response("error", { status: 500 });
  }
};

請求書を発行する企業情報を取得する

まずは取引先情報を取得します。

export const getPartnerList = async (secret: Secret): Promise<PartnerType[] | null> => {
  const token = await secret.MONEY_FORWARD_KV.get("token");

  const res = await moneyForwardClient.GET(
    "/partners",
    {
      headers: {
        Authorization: `Bearer ${token}`,
      },
    },
  );

  if (!res.data) {
    return null;
  }

  return res.data.data;
};

次に取引先情報から部署情報を取得します。
請求書を発行する際には、最終的に部署IDが必要になります。

マネフォ請求書側で部署が1つしか存在しない(複数設定した認識がない)場合には、res.data.data[0]を返却するとデータとして扱いやすいかもしれません。
弊社の場合もそのケースに該当するので、最初の1つだけを返却しています。

そもそもローカルで一覧を取得して必要な部署IDを控え、以降は"/partners/{partner_id}/departments/{department_id}"を叩く方がいい気がしています……。

export const getDepartment = async (
  secret: Secret,
  partnerId: string,
): Promise<DepartmentType | null> => {
  const token = await secret.MONEY_FORWARD_KV.get("token");

  const res = await moneyForwardClient.GET(
    "/partners/{partner_id}/departments",
    {
      headers: {
        Authorization: `Bearer ${token}`,
      },
      params: {
        path: {
          partner_id: partnerId,
        },
      },
    },
  );

  if (!res.data || !res.data.data.length === 0) {
    return null;
  }

  return res.data.data[0];
};

請求書を発行する

請求書を作成するpathは複数存在するのですが、今回は適格請求書を作成するためのpathを使用します。
これまでは請求書の作成と明細の追加を同時に行うことができなかったのですが、新しく作成された適格請求書を作成するためのpathでは同時に行えるようになりました!

明細(items)を追加すると請求金額も自動で計算してくれます。
ポイントは適格請求書の仕様に対応するため、納品日(delivery_date)と税率(excise)を指定しておくことです。

export const createInvoice = async (
  secret: Secret,
  departmentId: string,
  billingDate: string,
  dueDate: string,
  body: GetSummaryReportResponse,
): Promise<Billing | undefined> => {
  const token = await secret.MONEY_FORWARD_KV.get("token");

  const res = await moneyForwardClient.POST("/invoice_template_billings", {
    headers: {
      Authorization: `Bearer ${token}`,
      "Content-Type": "application/json",
    },
    body: {
      department_id: departmentId,
      billing_date: billingDate,
      due_date: dueDate,
      items: body.report.details.map((detail) => ({
        name: INVOICE_TEMPLATE_ITEM[detail.name as keyof typeof INVOICE_TEMPLATE_ITEM],
        delivery_date: billingDate,
        price: UNIT_PRICE,
        quantity: detail.hours,
        unit: "h",
        excise: "ten_percent" as const,
      })),
    },
  });

  return res.data;
};

完成!!!

マネフォ請求書を確認しにいくと、ちゃんと請求書が作成できていました👏

おわりに

本記事では、Clockify APIMoneyForward APIを活用することで、毎月の稼働時間を集計して請求書を発行するバックオフィス業務を自動化した事例を紹介しました。

今後も自動化できそうな業務があれば、APIやツールを駆使して実現していきたいと思います!

参考文献

https://zenn.dev/moutend/articles/97c98a277f4bae

Hackz Inc.

Discussion