Googleスプレッドシートで請求書のひな形を作り、Google Apps Script(GAS)で会員ごとに値を入れてPDFにし、共有ドライブに保存して、確かめてからメールで送る仕組みを作ります。例はサンプル料理教室の月謝の請求で、会員30人ほどに毎月、月謝と、持ち帰り用の食材セットの代金をまとめた請求書を出します。読み終わると、毎月25日に請求書のPDFが自動でそろい、代表が一覧で確かめて送信の印を付けるだけで送れる状態を、自分で組めます。前提は、Google Workspaceのアカウントがあり、GASのエディタの開き方を知っていることです。メールの差し込みの基本は「GASでメールを自動送信する:スプレッドシートの宛先に1通ずつ差し込む」で書いているので、ここでは請求書に固有の話を中心にします。
この記事の内容
先に決めること
請求書は、作り始めてから項目を足すと、過去の分と形が揃わなくなります。次の6つを先に決めます。
1つ目は、適格請求書発行事業者として登録しているかです。登録していれば、請求書に登録番号を載せ、税率ごとの消費税額を書く必要があります。登録していなければ登録番号は載せられません。サンプル料理教室は登録済みとして書きます。
2つ目は、誰に出すかです。料理教室の会員はほとんどが個人ですが、企業の研修として通う会員もいます。適格請求書発行事業者は、課税事業者から求められたときに適格請求書を出し、その写しを保存する決まりです。相手によって形を変えると運用が回らないので、全員に同じ形の適格請求書を出すことにします。
残りは、金額の出どころ(請求書の上で手で直すと台帳と合わなくなる)、税込か税抜か、消費税の端数処理(1枚につき税率ごとに1回)、送り方(会員にGoogleアカウントがあるとは限らないので添付)です。
| 決めること | サンプル料理教室の答え |
|---|---|
| 登録番号を載せるか | 載せる(適格請求書発行事業者) |
| 出す相手 | 会員全員に同じ形で出す |
| 金額の出どころ | 会員台帳の月謝と、請求明細シート |
| 単価の書き方 | 税込 |
| 端数処理 | 税率ごとに1回、切り捨て |
| 送り方 | PDFを添付してメール。紙を希望する会員は印刷して手渡し |
| 保存先 | 共有ドライブ「経理」の「請求書」フォルダ。税理士は閲覧で共有 |
請求書に要る項目
国税庁の説明では、適格請求書(インボイス)に必要な記載事項は次の6つです。不特定多数の人に販売する小売業・飲食店業・タクシー業などは、項目を減らした適格簡易請求書を出せます。
| 記載事項 | 適格請求書 | 適格簡易請求書 | ひな形のどこに置くか |
|---|---|---|---|
| 発行する事業者の氏名または名称と登録番号 | 要る | 要る | 右上(固定で書く) |
| 取引年月日 | 要る | 要る | 「2026年11月分」の行と発行日 |
| 取引内容(軽減税率の対象品目である旨) | 要る | 要る | 明細の品目。8%の品目に「※」 |
| 税率ごとに区分して合計した対価の額(税抜きまたは税込み)と適用税率 | 要る | 対価の額は要る。適用税率か消費税額のどちらか一方でよい | 明細の下の「10%対象」「8%対象」 |
| 税率ごとに区分した消費税額等 | 要る | 上の行と合わせてどちらか一方でよい | 「うち消費税」 |
| 書類の交付を受ける事業者の氏名または名称 | 要る | 省略できる | 左上の宛名 |
様式は法令で決まっておらず、必要な事項が書いてあれば名称は問いません。教室の事業が適格簡易請求書を出せる事業に当たるかは、顧問の税理士に確かめます。迷うなら、全項目を載せた適格請求書の形にしておけば足りない項目は出ません。
料理教室で税率が2つになるのは、レッスンの月謝(10%)と、持ち帰り用の食材セットや調味料の販売(飲食料品として8%)を同じ請求書に載せるときです。どの品目が軽減税率の対象かは品目の中身で決まるので、ここも税理士と一度決めて、請求明細のプルダウンに税率を固定しておきます。

請求書のひな形を作る
ファイルの構成
請求書の仕組みは、2つのスプレッドシートで作ります。1つは毎月の作業をする「月謝請求」、もう1つは請求書の見た目だけを持つ「請求書ひな形」です。ひな形を別ファイルにするのは、GASがひな形をまるごとコピーして値を入れ、PDFにしたらコピーを捨てる、という流れにするためです。
| ファイル | シート | 中身 |
|---|---|---|
| 月謝請求 | 設定 | B1に請求月(例: 2026-11)、B2に支払期限 |
| 月謝請求 | 会員台帳 | 会員番号・氏名・メール・コース・月謝・在籍・送付方法 |
| 月謝請求 | 請求明細 | 請求月・会員番号・品目・数量・単価(税込)・税率 |
| 月謝請求 | 請求一覧 | 会員番号・氏名・宛先・請求額・状態・PDF・確認・送信日時 |
| 請求書ひな形 | 請求書 | 見た目と式だけ。値は空 |
請求明細の「請求月」の列は、書式を「書式なしテキスト」にして 2026-11 のように入れます。日付として入ると、GASで読んだときに日付の値になり、文字の比較が合わなくなります。
ひな形のセル配置
ひな形の「請求書」シートは、A4縦1枚に収まるように、B列からF列までを使います。GASが値を入れるセルと、式や固定の文言のセルを分けておきます。
| セル | 中身 | 入れ方 |
|---|---|---|
| B2 | 請求書 | 固定の文字 |
| F2 | 請求番号 | GASが入れる(例: INV-202611-M012) |
| F3 | 発行日 | GASが入れる |
| B4 | 宛名(例: 山田 花子 様) | GASが入れる |
| B6 | 件名(例: 2026年11月分の月謝) | GASが入れる |
| E5〜E8 | サンプル料理教室・登録番号 T0000000000000・住所・電話 | 固定の文字 |
| B11〜F11 | 品目・数量・単価(税込)・税率・金額 | 見出し |
| B12〜E21 | 明細10行 | GASが入れる |
| F12〜F21 | 金額 | 式 |
| B22 | ※は軽減税率(8%)対象 | 固定の文字 |
| F23〜F27 | 税率ごとの合計・消費税・総額 | 式 |
| B29 | 振込先とお支払い期限 | GASが期限を入れる |
金額と合計の式は次のとおりです。税率の列(E列)は文字の「10%」ではなく数字の 10 と 8 を入れ、表示形式をカスタムの 0"%" にします。文字で「10%」と入れると、SUMIFの条件の書き方で合わなくなることがあります。
F12(F21までコピー): =IF(B12="","",C12*D12)
F23 10%対象(税込): =SUMIF(E12:E21,10,F12:F21)
F24 うち消費税(10%): =ROUNDDOWN(F23*10/110,0)
F25 8%対象(税込) : =SUMIF(E12:E21,8,F12:F21)
F26 うち消費税(8%): =ROUNDDOWN(F25*8/108,0)
F27 ご請求額 : =F23+F25
消費税は、明細の行ごとに計算して足すのではなく、税率ごとの合計から1回だけ出します。行ごとに端数を切り捨てて足すと、国税庁の説明にある「1枚の請求書につき税率ごとに1回」の処理になりません。切り上げにしたいなら ROUNDUP、四捨五入なら ROUND に替えます。

印刷の形を整える
ひな形ができたら、PDFにしたときの形を一度画面で確かめます。
- 「請求書ひな形」を開き、「ファイル」→「印刷」を押す
- 印刷範囲に「現在のシート」を選ぶ
- 用紙サイズに「A4」、ページの向きに「縦向き」を選ぶ
- スケールに「幅に合わせる」を選ぶ
- 「グリッド線を表示」を外す
- 右のプレビューで1枚に収まっているかを見る
- はみ出すなら、列の幅と行の高さを詰めてやり直す
ここで1枚に収まらないひな形は、GASでPDFにしても収まりません。

GASで会員ごとにPDFを作る
請求一覧を作る
最初に、今月請求する会員を「請求一覧」に並べます。在籍中の会員だけを会員台帳から写し、月謝を請求明細に1行ずつ足します。食材セットのような追加は、受付が月中に請求明細へ入れておきます。
const TEMPLATE_ID = 'YOUR_TEMPLATE_SPREADSHEET_ID'; // 請求書ひな形
const OUTPUT_FOLDER_ID = 'YOUR_OUTPUT_FOLDER_ID'; // 共有ドライブ「経理」/請求書/2026-11
const WORK_FOLDER_ID = 'YOUR_WORK_FOLDER_ID'; // 作業用(コピーを一時的に置く)
const FROM_ALIAS = 'info@sample-cooking.example';
// 毎月25日にトリガーから呼ぶ入口
function monthlyStart() {
prepareList();
buildInvoices();
}
function prepareList() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const month = ss.getSheetByName('設定').getRange('B1').getDisplayValue();
const members = ss.getSheetByName('会員台帳').getDataRange().getValues().slice(1);
const detail = ss.getSheetByName('請求明細');
const list = ss.getSheetByName('請求一覧');
if (list.getLastRow() > 1) throw new Error('請求一覧が空ではありません。前月分を別シートに移してから実行してください');
const listRows = [];
const detailRows = [];
members.forEach(function (m) {
const [id, name, email, course, fee, active, delivery] = m;
if (active !== '在籍') return;
listRows.push([id, name, delivery === 'メール' ? email : '(紙)', '', '', '', false, '']);
detailRows.push([month, id, course + ' 月謝', 1, fee, 10]);
});
list.getRange(2, 1, listRows.length, 8).setValues(listRows);
list.getRange(2, 7, listRows.length, 1).insertCheckboxes();
detail.getRange(detail.getLastRow() + 1, 1, detailRows.length, 6).setValues(detailRows);
}
ひな形をコピーして値を入れ、PDFにする
function buildInvoices() {
const started = Date.now();
const ss = SpreadsheetApp.getActiveSpreadsheet();
const month = ss.getSheetByName('設定').getRange('B1').getDisplayValue();
const due = ss.getSheetByName('設定').getRange('B2').getDisplayValue();
const list = ss.getSheetByName('請求一覧');
const rows = list.getDataRange().getValues();
const details = ss.getSheetByName('請求明細').getDataRange().getDisplayValues().slice(1)
.filter(function (r) { return r[0] === month; });
const out = DriveApp.getFolderById(OUTPUT_FOLDER_ID);
const work = DriveApp.getFolderById(WORK_FOLDER_ID);
const tz = Session.getScriptTimeZone();
for (let i = 1; i < rows.length; i++) {
if (Date.now() - started > 4.5 * 60 * 1000) { continueLater(); return; } // 6分の手前で切る
const [memberId, name, , , status] = rows[i];
if (status !== '') continue; // 作成済み・送信済みは飛ばす
const items = details.filter(function (r) { return r[1] === String(memberId); })
.map(function (r) {
const rate = Number(String(r[5]).replace('%', ''));
return [r[2] + (rate === 8 ? ' ※' : ''), Number(r[3]), Number(String(r[4]).replace(/,/g, '')), rate];
});
if (items.length === 0 || items.length > 10) {
list.getRange(i + 1, 5).setValue(items.length === 0 ? '明細なし' : '明細が10行超');
continue;
}
while (items.length < 10) items.push(['', '', '', '']);
const no = 'INV-' + month.replace('-', '') + '-' + memberId;
const copy = DriveApp.getFileById(TEMPLATE_ID).makeCopy('作業_' + no, work);
const sheet = SpreadsheetApp.openById(copy.getId()).getSheets()[0];
sheet.getRange('F2').setValue(no);
sheet.getRange('F3').setValue(Utilities.formatDate(new Date(), tz, 'yyyy年M月d日'));
sheet.getRange('B4').setValue(name + ' 様');
sheet.getRange('B6').setValue(month.replace('-', '年') + '月分の月謝');
sheet.getRange('B12:E21').setValues(items);
sheet.getRange('B29').setValue('お支払い期限:' + due);
SpreadsheetApp.flush(); // 式の計算を終わらせてからPDFにする
const total = sheet.getRange('F27').getValue();
const pdf = exportPdf(copy.getId(), sheet.getSheetId()).setName(no + '_' + name + '様.pdf');
const saved = out.createFile(pdf);
copy.setTrashed(true);
list.getRange(i + 1, 4, 1, 3).setValues([[total, '作成済み', saved.getUrl()]]);
SpreadsheetApp.flush();
Utilities.sleep(1000);
}
removeContinueTriggers();
}
// シート1枚をA4縦・幅に合わせてPDFにする
function exportPdf(spreadsheetId, sheetId) {
const url = 'https://docs.google.com/spreadsheets/d/' + spreadsheetId + '/export'
+ '?format=pdf&size=A4&portrait=true&fitw=true&gridlines=false'
+ '&printtitle=false&sheetnames=false&pagenum=UNDEFINED&gid=' + sheetId;
const res = UrlFetchApp.fetch(url, {
headers: { Authorization: 'Bearer ' + ScriptApp.getOAuthToken() },
muteHttpExceptions: true,
});
if (res.getResponseCode() !== 200) throw new Error('PDFの書き出しに失敗しました: ' + res.getResponseCode());
return res.getBlob();
}
// 時間切れの手前で止めたら、1分後に続きから動かす
function continueLater() {
removeContinueTriggers();
ScriptApp.newTrigger('buildInvoices').timeBased().after(60 * 1000).create();
}
function removeContinueTriggers() {
ScriptApp.getProjectTriggers().forEach(function (t) {
if (t.getHandlerFunction() === 'buildInvoices') ScriptApp.deleteTrigger(t);
});
}
流れは、ひな形をコピーする、値を入れる、SpreadsheetApp.flush() で式の計算を終わらせる、PDFにして共有ドライブに保存する、コピーをゴミ箱に入れる、請求一覧に請求額と状態とPDFのURLを書く、の順です。1人分が終わるたびに請求一覧へ書くので、途中で止まっても、次の実行は状態が空の行から続きます。
PDFにするところで使っている書き出し用のURLは、用紙サイズ・向き・幅に合わせる・グリッド線を指定できますが、Apps Scriptの公式のリファレンスには載っていない方法で、仕様が変わる可能性があります。公式の方法は DriveApp.getFileById(id).getAs('application/pdf') で、用紙や余白は選べません。ひな形を1シートにしておけば、問題が出たときにこちらへ逃げられます。
GASの1回の実行は6分までなので、4分半を過ぎたら止めて1分後に続きを動かします。会員が増えても同じコードで回ります。

初回の承認と、毎月のトリガー
- 「月謝請求」のスプレッドシートで「拡張機能」→「Apps Script」を開き、上のコードを貼って保存する
- 3つのIDを自分の値に替える
- 「設定」のB1に今月を入れ、関数
monthlyStartを選んで「実行」を押す - 承認を求められたら代表のアカウントで、スプレッドシート・ドライブ・外部への接続・Gmailの権限を許可する
- 請求一覧に請求額と「作成済み」とPDFのURLが入ったのを見る
- 左の「トリガー」→「トリガーを追加」で、関数
monthlyStart、「時間主導型」、「月ベースのタイマー」、「25日」、「午前6時〜7時」を選んで保存する
「設定」のB1は月初に受付が書き換えます。先月分を作り直す事故を防ぐため、prepareList は請求一覧が空でないと止まります。先月分は「請求一覧_2026-10」のように名前を変えて残します。

送る前に人が確かめる
請求書は、送ったら取り消せません。サンプル料理教室では、PDFを作る処理と送る処理を別の関数に分け、間に代表の確認を挟んでいます。
確認用の一覧で見る
請求一覧は、そのまま確認用の表になります。代表は25日の朝に次を見ます。
- 請求額の合計を、会員台帳の月謝の合計と請求明細の追加分の合計と比べる
- 状態が「明細なし」「明細が10行超」の行が無いかを見る
- PDFのURLから3〜4人分を開き、宛名・明細・消費税が合っているかを見る
- 問題の無い行の「確認」のチェックボックスに印を付ける
合計の照合は、請求一覧の下に =SUM(D2:D11) のように一覧の行だけを足す式と、台帳側の合計を並べておけば見比べるだけで済みます。=SUM(D2:D) と列ごと指すと、合計のセル自身が範囲に入って循環参照のエラーになります。差額が出たら、まず状態が「明細なし」の行を疑います。1人分の金額を直したいときは、請求明細を直し、請求一覧のその行の状態とPDFを消してから buildInvoices を実行し直します。PDFを手で直すことはしません。

確認した行だけ送る
function sendInvoices() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const month = ss.getSheetByName('設定').getRange('B1').getDisplayValue();
const list = ss.getSheetByName('請求一覧');
const rows = list.getDataRange().getValues();
for (let i = 1; i < rows.length; i++) {
const [memberId, name, to, total, status, pdfUrl, checked] = rows[i];
if (status !== '作成済み' || checked !== true) continue;
if (to === '(紙)') continue; // 紙の人は印刷して手渡し
const fileId = String(pdfUrl).match(/[-\w]{25,}/)[0];
const pdf = DriveApp.getFileById(fileId).getBlob();
const subject = '【サンプル料理教室】' + month.replace('-', '年') + '月分の請求書';
const body = name + ' 様\n\nいつもありがとうございます。サンプル料理教室です。\n'
+ month.replace('-', '年') + '月分の請求書をお送りします。添付のPDFをご確認ください。\n'
+ 'ご請求額:' + Number(total).toLocaleString('ja-JP') + '円\n\n'
+ 'ご不明な点は、このメールにご返信ください。\n\nサンプル料理教室 受付';
GmailApp.sendEmail(to, subject, body, {
from: FROM_ALIAS, name: 'サンプル料理教室', replyTo: FROM_ALIAS, attachments: [pdf],
});
list.getRange(i + 1, 5).setValue('送信済み');
list.getRange(i + 1, 8).setValue(new Date());
SpreadsheetApp.flush();
}
}
送る関数はトリガーに入れず、確認を終えた代表が手で実行します。スプレッドシートのメニューから押せるように、onOpen で独自のメニューを足します。
function onOpen() {
SpreadsheetApp.getUi().createMenu('請求書')
.addItem('確認済みの請求書を送る', 'sendInvoices')
.addToUi();
}
初めての月や金額を改定した月は、sendEmail を createDraft に替えて、添付付きの下書きを1通ずつ開いてから送ります。
会計ソフトの請求書機能との使い分け
freeeのような会計ソフトにも請求書を作って送る機能があります。freeeの場合は「freee請求書」で適格請求書を作り、画面からメールで送り、内容を会計に連携できます。スプレッドシートとGASで作るか、会計ソフトで作るかは、次で分けます。
| 比べること | スプレッドシートとGAS | 会計ソフトの請求書機能 |
|---|---|---|
| 向く件数と形 | 毎月同じ形を、決まった会員に一括で出す | 取引先ごとに中身が違う請求を都度出す |
| 金額の出どころ | 自分の台帳・出席・明細から自由に組める | 会計ソフトの取引先・品目に入れ直す |
| インボイスの項目 | 自分で漏れなく作る責任がある | 製品が項目をそろえている |
| 入金の消し込み・売上の計上 | 別に会計ソフトへ入れる | 請求から会計まで1つで流れる |
| 送り方 | 自分のGmail(info@ など)から送れる | 会計ソフトの送信の仕組み(送信元が製品のアドレスになることがある) |
| 費用 | Google Workspaceの範囲 | 会計ソフトのプランによる |
| 作り込みと保守 | GASを直せる人が要る | 製品側が保守する |
サンプル料理教室では、月謝の請求書はスプレッドシートとGASで作り、請求一覧の合計を売上として会計ソフトに入れています。法人の研修のように見積もりから始まる請求は会計ソフトで作ります。混ぜるときは、請求番号の頭の文字を分けて重ならないようにします。
応用
保存と共有
作ったPDFは、共有ドライブの「経理」フォルダに月ごとに置きます。外部の税理士には「経理」フォルダだけを閲覧で共有しているので、税理士は毎月そこを見れば請求書の写しを確かめられます。適格請求書発行事業者は写しを保存する決まりがあり、PDFを電子のまま保存する場合の要件は電子帳簿保存法が絡むので、保存の方法とファイル名の付け方は税理士と一度決めておきます。サンプル料理教室では、ファイル名を「請求番号_氏名様.pdf」にそろえ、請求番号で探せるようにしています。
請求書番号を台帳に残す
請求一覧は毎月リセットするので、送った行は sendInvoices の最後で「請求履歴」シートに appendRow で追記します。入金は通帳と請求履歴を突き合わせて、受付が入金日を埋めます。
できないことと、その代わり
| できないこと | 代わりの手 |
|---|---|
| 入金の自動消し込み | 会計ソフトの銀行連携を使う。スプレッドシートでは入金日を手で入れる |
| 口座振替やカードでの自動引き落とし | 決済サービスを使う。請求書は「お支払い済み」の通知として出す |
| 電子インボイス(標準化された形式のデータ)で送る | 対応する会計ソフトを使う。GASで作るのはPDF |
| 1ミリ単位で揃ったレイアウト | Googleドキュメントで請求書のひな形を作り、差し込んでPDFにする |
| 明細が10行を超える請求書 | ひな形の行を増やすか、2枚目のシートを作る。料理教室なら明細は数行で収まる |
| 数百人分を数分で作る | 続きから動かすトリガーで分けて作る。それでも遅いなら会計ソフトの一括作成を検討する |
| 書き出し用URLの仕様が変わらない保証 | 公式の getAs('application/pdf') に切り替えられるよう、ひな形を1シートにしておく |
よく詰まるところ
| 症状 | 原因 | 直し方 |
|---|---|---|
| PDFが2枚に分かれる・右端が切れる | ひな形が「幅に合わせる」で1枚に収まっていない | 印刷のプレビューで1枚に収まるまで列幅を詰める。fitw=true を付ける |
| 用紙がA4ではなくレターになる | 用紙サイズを指定していない | 書き出しのURLに size=A4 を付ける |
| PDFにセルの枠線が薄く出る | グリッド線が残っている | gridlines=false を付け、ひな形の罫線は自分で引く |
| PDFの合計が0円や前の会員の金額になる | 値を入れた直後にPDFにして、式の計算が終わっていない | 値を入れたあと SpreadsheetApp.flush() を呼んでからPDFにする |
| 消費税が会計ソフトと1円合わない | 明細ごとに消費税を出して足している | 税率ごとの合計から1回だけ出す式にする |
| 「最大実行時間を超えました」で途中で止まる | 1回6分の上限に当たった | 4分半で止めて続きを動かすトリガーを作る。状態が空の行から再開する |
書き出しで 429 が返る | 実際に運用していると、短い間に何十回も書き出したときに返ることがある | 1人ごとに Utilities.sleep(1000) 以上の間を入れる |
| 宛名が「山田 花子 様 様」になる | 会員台帳の氏名に「様」が入っている | 台帳には氏名だけを入れ、敬称はGASで付ける |
| 請求月が合わず明細が拾われない | 請求明細の請求月が日付として入っている | 列の書式を「書式なしテキスト」にして 2026-11 と入れ直す |
| 単価が文字として読まれて金額が出ない | getDisplayValues() でカンマ付きの「12,000」を読んだ | カンマを外して数に直す(コードの replace(/,/g, '')) |
| 共有ドライブにPDFを保存できない・作業用のコピーを消せない | ファイルの追加は「投稿者」以上、移動と削除は「コンテンツ管理者」以上が要る | 代表を「経理」の共有ドライブに「投稿者」以上で入れる。作業用フォルダを共有ドライブに置くなら「コンテンツ管理者」以上にする |
次にやること
- GASでメールを自動送信する:スプレッドシートの宛先に1通ずつ差し込む
- GASのトリガー:定期実行・フォーム送信時・編集時
- GASが止まったとき:エラー・実行時間・上限の見方
- スプレッドシートの関数:業務で使うXLOOKUP・QUERY・IMPORTRANGE