📄

Stripeの月末締め翌月末払いの請求書を作成しよう〜GAS編〜

2024/12/22に公開

はじめに

「Stripeで月末締め翌月末払いってどうやって実現するの?」という方はまずこちらの記事をどうぞ。
https://zenn.dev/kusuke/articles/05bc1590966cb4

今回は「Stripeでの処理はわかったけど、請求書はどうやって作成するの?」という方向けに記事を書きます。
みんなだいすきGoogle Apps Script(GAS)での実装です。
freeeやMFなどの請求書SaaSをご利用中の方は、各種APIでもPDF作成が実装可能だとおもうので、必要な箇所だけお読みください。

処理の流れ

Google Sheetsで請求書のテンプレを作成する

はじめに請求書のテンプレートをGoogle Sheetsで作成します。
項目、金額など、一般的な請求書の形式でOKです。

GASでStripeの情報取得、シート作成、pdf出力

実装する処理の流れは以下の4ステップです。

  1. テンプレートをコピーする
  2. Stripeから対象のinvoice情報と銀行口座情報を取得する
  3. シートに必要な情報を埋める
  4. pdfとして出力する

ここではポイントだけ解説します。
(コード全文は末尾においておきます)

Stripeの銀行口座情報の取得

Stripeの銀行口座情報を取得するAPIは少々わかりにくいです。
https://x.com/paranishian/status/1858674885037617400
正解となるエンドポイントは customers/${customerId}/funding_instructions です。

/**
 * 銀行口座情報を取得
 */
function fetchCustomerBankAccount(customerId) {
  const options = {
    method: 'post',
    headers: {
      'Authorization': `Bearer ${CONFIG.STRIPE_SECRET_KEY}`,
      'Content-Type': 'application/x-www-form-urlencoded'
    },
    payload: {
      funding_type: 'bank_transfer',
      'bank_transfer[type]': 'jp_bank_transfer',
      currency: 'jpy'
    },
    muteHttpExceptions: true
  };

  try {
    const response = UrlFetchApp.fetch(`https://api.stripe.com/v1/customers/${customerId}/funding_instructions`, options);
    const result = JSON.parse(response.getContentText());
...

PDF出力の実装

GASには標準でPDF出力機能が用意されていますが、以下の制限があります。
1. PDFの出力オプション(用紙サイズ、向き、余白など)をカスタマイズできない
2. 特定のシートのみを出力することができない(スプレッドシート全体が出力される)
そのため、今回はGoogle Drive APIを使用して、より柔軟なPDF出力を実現しています。

/**
 * PDFとして出力
 */
function exportToPDF(spreadsheet, sheetId, fileName) {
  const exportOptions = {
    exportFormat: 'pdf',
    format: 'pdf',
    size: 'A4',
    portrait: true,
    fitw: true,
    gridlines: false,
    printtitle: false,
    sheetnames: false,
    pagenumbers: false,
    attachment: false,
    gid: sheetId
  };

  const url = `https://docs.google.com/spreadsheets/d/${spreadsheet.getId()}/export?`;
  const params = Object.entries(exportOptions)
    .map(([key, value]) => `${key}=${value}`)
    .join('&');

  const response = UrlFetchApp.fetch(url + params, {
    headers: { Authorization: `Bearer ${ScriptApp.getOAuthToken()}` }
  });

  const pdfBlob = response.getBlob().setName(`${fileName}.pdf`);
  const folder = DriveApp.getFolderById(CONFIG.PDF_FOLDER_ID);
  return folder.createFile(pdfBlob);
}

関数を実行すると、指定したドライブフォルダにPDFファイルが生成されます。

おわりに

今回は請求書作成という単機能のご紹介でした。

「そもそも月末締め翌月末払いのサブスクをどう管理するの?」(Stripeのmetadata?Google Sheets?Notion?)という前工程の話や、
「生成したPDFをどう送付するの?」(Gmailで送信?Slackに通知?各業務システムに連携?)の後工程の話もありますが、
そこはぜひ皆様の業務に合わせてカスタマイズしてお使いください。

それでは! 👋

コード全文

StripeのAPIキーをプロパティに設定したり、必要な情報を埋めると利用できます。
createInvoiceDocument の引数に請求書のもととなるinvoiceのidを指定して実行すればOKです。

// 定数定義
const CONFIG = {
  STRIPE_SECRET_KEY: PropertiesService.getScriptProperties().getProperty('STRIPE_SECRET_KEY'),
  TEMPLATE_SHEET_NAME: '#{YOUR_TEMPLATE_SHEET_NAME}',
  PDF_FOLDER_ID: '#{YOUR_PDF_FOLDER_ID}'
};

function main() {
  createInvoiceDocument('#{YOUR_STRIPE_INVOICE_ID}')
}

/**
 * Stripeの請求書データを基に請求書を作成する
 */
function createInvoiceDocument(invoiceId) {
  const invoice = fetchStripeInvoice(invoiceId);
  if (!invoice) {
    throw new Error('Failed to fetch invoice from Stripe');
  }

  return generateInvoiceDocument(invoice);
}

/**
 * Stripeから請求書データを取得
 */
function fetchStripeInvoice(invoiceId) {
  const options = {
    'method': 'get',
    'headers': {
      'Authorization': `Bearer ${CONFIG.STRIPE_SECRET_KEY}`
    },
    'muteHttpExceptions': true
  };

  try {
    const response = UrlFetchApp.fetch(`https://api.stripe.com/v1/invoices/${invoiceId}`, options);
    return JSON.parse(response.getContentText());
  } catch (error) {
    console.error('Error fetching invoice:', error);
    return null;
  }
}

/**
 * 請求書ドキュメントを生成
 */
function generateInvoiceDocument(invoice) {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const templateSheet = ss.getSheetByName(CONFIG.TEMPLATE_SHEET_NAME);
  const workingSheet = templateSheet.copyTo(ss);

  const invoiceDate = new Date(invoice.created * 1000);
  const issueDate = getMonthEndDate(invoiceDate);
  const dueDate = getMonthEndDate(new Date(invoiceDate.getFullYear(), invoiceDate.getMonth() + 1));

  // 基本情報の設定
  setBasicInvoiceInfo(workingSheet, invoice, issueDate, dueDate);

  // 明細情報の設定
  const { subtotalExTax, totalTax } = setInvoiceLineItems(workingSheet, invoice);

  // 合計金額の設定
  setTotalAmounts(workingSheet, subtotalExTax, totalTax, invoice.total);

  // 銀行口座情報の設定
  setBankAccountInfo(workingSheet, invoice.customer);

  // シート名の設定
  const timestamp = Utilities.formatDate(new Date(), 'Asia/Tokyo', 'yyyyMMddHHmmss');
  workingSheet.setName(`${timestamp}_${invoice.customer_name}`);

  // PDFの生成
  SpreadsheetApp.flush();
  Utilities.sleep(2000);
  return exportToPDF(ss, workingSheet.getSheetId(), `Invoice_${invoice.number}`);
}

/**
 * 基本情報を設定
 */
function setBasicInvoiceInfo(sheet, invoice, issueDate, dueDate) {
  sheet.getRange('G3').setValue(formatDate(issueDate));
  sheet.getRange('A5').setValue(`${invoice.customer_name} 御中`);
  sheet.getRange('B9').setValue(formatDate(dueDate));
  sheet.getRange('B36').setValue(invoice.id);
}

/**
 * 請求書明細を設定
 */
function setInvoiceLineItems(sheet, invoice) {
  let subtotalExTax = 0;
  let totalTax = 0;
  let rowIndex = 17;

  invoice.lines.data.slice(0, 10).forEach((item) => {
    const description = item.description;
    const taxAmount = calculateTaxAmount(item);
    const amountExTax = item.amount - taxAmount;
    const quantity = item.quantity || 1;

    sheet.getRange(rowIndex, 1).setValue(description);
    sheet.getRange(rowIndex, 5).setValue(quantity);
    sheet.getRange(rowIndex, 6).setValue(amountExTax / quantity);
    sheet.getRange(rowIndex, 7).setValue(amountExTax);

    subtotalExTax += amountExTax;
    totalTax += taxAmount;
    rowIndex++;
  });

  return { subtotalExTax, totalTax };
}

/**
 * 税額を計算
 */
function calculateTaxAmount(item) {
  return item.tax_amounts ? item.tax_amounts.reduce((sum, tax) => sum + tax.amount, 0) : 0;
}

/**
 * 合計金額を設定
 */
function setTotalAmounts(sheet, subtotalExTax, totalTax, total) {
  sheet.getRange('G27').setValue(subtotalExTax);
  sheet.getRange('G28').setValue(totalTax);
  sheet.getRange('G29').setValue(total);
  sheet.getRange('B11').setValue(total);
}

/**
 * 銀行口座情報を設定
 */
function setBankAccountInfo(sheet, customerId) {
  const bankAccount = fetchCustomerBankAccount(customerId);
  if (bankAccount) {
    const bankInfo = formatBankAccountInfo(bankAccount);
    sheet.getRange('B10').setValue(bankInfo);
  }
}

/**
 * 銀行口座情報を取得
 */
function fetchCustomerBankAccount(customerId) {
  const options = {
    method: 'post',
    headers: {
      'Authorization': `Bearer ${CONFIG.STRIPE_SECRET_KEY}`,
      'Content-Type': 'application/x-www-form-urlencoded'
    },
    payload: {
      funding_type: 'bank_transfer',
      'bank_transfer[type]': 'jp_bank_transfer',
      currency: 'jpy'
    },
    muteHttpExceptions: true
  };

  try {
    const response = UrlFetchApp.fetch(`https://api.stripe.com/v1/customers/${customerId}/funding_instructions`, options);
    const result = JSON.parse(response.getContentText());
    
    if (result.bank_transfer?.financial_addresses?.[0]?.zengin) {
      const zengin = result.bank_transfer.financial_addresses[0].zengin;
      return {
        bankName: zengin.bank_name,
        branchName: zengin.branch_name,
        accountType: formatAccountType(zengin.account_type),
        accountNumber: zengin.account_number,
        accountHolder: zengin.account_holder_name,
        bankCode: zengin.bank_code,
        branchCode: zengin.branch_code
      };
    }
    return null;
  } catch (error) {
    console.error('Error fetching bank account:', error);
    return null;
  }
}

/**
 * 銀行口座情報を整形
 */
function formatBankAccountInfo(account) {
  return `${account.bankName}(${account.bankCode}) ${account.branchName}(${account.branchCode})\n${account.accountType} ${account.accountNumber} ${account.accountHolder}`;
}

/**
 * 口座種別を日本語に変換
 */
function formatAccountType(type) {
  const types = {
    'futsu': '普通',
    'toza': '当座',
    'chochiku': '貯蓄'
  };
  return types[type] || type;
}

/**
 * PDFとして出力
 */
function exportToPDF(spreadsheet, sheetId, fileName) {
  const exportOptions = {
    exportFormat: 'pdf',
    format: 'pdf',
    size: 'A4',
    portrait: true,
    fitw: true,
    gridlines: false,
    printtitle: false,
    sheetnames: false,
    pagenumbers: false,
    attachment: false,
    gid: sheetId
  };

  const url = `https://docs.google.com/spreadsheets/d/${spreadsheet.getId()}/export?`;
  const params = Object.entries(exportOptions)
    .map(([key, value]) => `${key}=${value}`)
    .join('&');

  const response = UrlFetchApp.fetch(url + params, {
    headers: { Authorization: `Bearer ${ScriptApp.getOAuthToken()}` }
  });

  const pdfBlob = response.getBlob().setName(`${fileName}.pdf`);
  const folder = DriveApp.getFolderById(CONFIG.PDF_FOLDER_ID);
  return folder.createFile(pdfBlob);
}

/**
 * 月末日を取得
 */
function getMonthEndDate(date) {
  return new Date(date.getFullYear(), date.getMonth() + 1, 0);
}

/**
 * 日付をフォーマット
 */
function formatDate(date) {
  return Utilities.formatDate(date, 'Asia/Tokyo', 'yyyy/MM/dd');
}

Discussion