Namsang LABS
Deep Dive · #settlement #revenue-recognition #saas #kotlin

コードで読む会計:お金を受け取った日が売上日ではない

· Sangkyoon Nam

四半期決算ミーティングだった。会計法人の担当者が画面を共有し、B社の行で止まった。「ここ、1月の売上が300万ウォンで計上されてますが、合ってますか?」合っているはずだと答えると、担当者は首を振った。「購読の開始日が1月20日ですよね。1月に実際にサービスを提供したのは12日間ですから、その分だけが売上です。19万ウォンほどですね」

B社は1月20日に6ヶ月分の購読料300万ウォンを決済した。システムから決済通知が届き、ダッシュボードの1月売上に300万ウォンが加算された。決済された金額がそのまま売上だと思っていた。だが会計ではそうではなかった。購読期間は1月20日から7月19日まで。その中で1月に提供したのは12日分で、その分だけが1月の売上として認識される。およそ19万ウォンだ。残りの281万ウォンは口座に入っているが、これから5ヶ月にわたって提供すべきサービスの対価である。これは繰延収益(deferred revenue)という負債として計上される。

#現金主義と発生主義

売上をいつ認識するかについて、会計には二つの方式がある。現金主義と発生主義だ。現金主義は、入金された時点で売上を認識する。直感に近く、コードに落とすのも簡単だ。PG社のwebhookが届いたらpayment_completed_atにタイムスタンプを刻み、amountに金額を入れる。決済イベントが発生した瞬間に売上として認識される。

// 現金主義:決済時点で全額を売上計上
fun onPaymentCompleted(payment: Payment) {
    revenueRepository.save(
        Revenue(
            amount = payment.amount,         // 3,000,000ウォン全額
            recognizedAt = payment.completedAt // 決済時点
        )
    )
}

発生主義は違う。サービスを提供した時点で売上を認識する。お金を受け取った時点ではない。非同期処理に例えるなら、リクエスト(request)を受け取った時点ではなく、実際の処理(processing)が完了した時点で記録する方式だ。決済はリクエストの時点、売上は完了の時点。二つの時点は異なる。

K-IFRS第1115号(韓国)もASC 606(米国)も、この原則に従う。法人であれば、売上処理に発生主義が強制される。

// 発生主義:サービス提供期間にわたって分割計上
fun onPaymentCompleted(payment: Payment, subscription: Subscription) {
    // 決済金額は繰延収益(負債)として計上する
    deferredRevenueRepository.save(
        DeferredRevenue(
            amount = payment.amount,         // 3,000,000ウォン
            serviceStart = subscription.startDate,
            serviceEnd = subscription.endDate
        )
    )
    // 毎月、提供分だけ繰延収益→売上に振り替える(後段で具体化)
}

#300万ウォンのうち、1月分はいくらか

B社の購読条件を改めて整理するとこうなる。

  • 契約金額:3,000,000ウォン(月500,000ウォン × 6ヶ月)
  • 購読開始:2026-01-20
  • 購読終了:2026-07-19
  • 1月のサービス日数:12日(1/20 ~ 1/31)

購読開始日が1月1日であれば話は単純だった。1月の売上は月額500,000ウォンそのままだ。だが実際の開始日は1月20日で、1月にサービスを提供したのは12日間だけだ。1ヶ月分をまるごと計上することはできず、12日分だけを切り出す必要がある。日割り計算(pro-rata)が必要になる地点である。これを扱う方法は二つある。

1. 月ごとの日数で割る : 月額購読料をその月の実際の日数で割り、サービス提供日を掛ける。毎月の購読料がその月の中だけで配分される方式だ。

1月の売上 = 500,000 / 31 × 12 = 193,548.38...

2. 契約期間で平準化する : 契約総額を契約全体のサービス日数で割って日単価を先に求め、それを各月のサービス日数に掛ける。契約全体を一つの履行義務(performance obligation)として捉える方式だ。

// 契約全体のサービス日数:2026-01-20 ~ 2026-07-19 = 181日
1月の売上 = 3,000,000 / 181 × 12 = 198,895.02...

193,548ウォンと198,895ウォン。同じ契約でも5,000ウォン以上の差が出る。どちらが正しいかをコードは決めない。会計ポリシーが決める。

#何日で割るか

月額購読料が請求・税務インボイスの基本単位となる韓国の実務では、1番の方式がより一般的だ。1番を選ぶと、すぐ次の問いが続く。1月は31日、2月は28日。同じ月額なのに、月によって1日あたりの単価が変わる。

val dailyJanuary = BigDecimal("500000")
    .divide(BigDecimal("31"), 10, RoundingMode.HALF_UP)
// 16,129.03...

val dailyFebruary = BigDecimal("500000")
    .divide(BigDecimal("28"), 10, RoundingMode.HALF_UP)
// 17,857.14...

同じ料金プランなのに、2月の1日単価は1,700ウォン以上高い。この差を扱う三つのポリシーがある。

  • 実日数基準 : その月の実際の日数(28、29、30、31)で割る。最も精密で、監査実務で一般的に好まれる方式だ。ただし、すでに述べたとおり、月ごとに1日あたりの単価が変動する。
  • 30日固定基準 : すべての月を30日とみなす。金融業界でよく使われる。1日単価が一定で運用は楽になる。ただし1月は1日少なく、2月は2日多く計上することになる。
  • 365日基準 : 年間購読料を365で割って1日単価を求める。月額購読料という概念自体を使わない。1日 = 年額/365。うるう年なら366。ただし年間購読にのみ適用され、今回の事例には該当しない。
enum class DayCountPolicy {
    ACTUAL,      // 当該月の実日数
    FIXED_30,    // すべての月を30日とみなす
    ANNUAL_365   // 年間365日基準
}

fun daysInMonth(yearMonth: YearMonth, policy: DayCountPolicy): BigDecimal =
    when (policy) {
        DayCountPolicy.ACTUAL -> BigDecimal(yearMonth.lengthOfMonth())
        DayCountPolicy.FIXED_30 -> BigDecimal("30")
        DayCountPolicy.ANNUAL_365 ->
            BigDecimal("365").divide(BigDecimal("12"), 10, RoundingMode.HALF_UP)
    }

年間・複数年契約が標準のグローバルSaaSでは、2番の方式が好まれる傾向にある。契約全体を一つの履行義務として捉え、期間にわたって平準化する形が、ASC 606・IFRS 15が最も自然に受け入れる形でもあるからだ。2番を選んでも、また別の問いが続く。平準化の基準日数を何にするか。同じ契約でも基準が変われば日単価が変わる。

  • 契約日数そのまま : 2026-01-20 ~ 2026-07-19 = 181日。実際のサービス期間をそのまま分母として使う。
  • 30日固定換算 : 6ヶ月 × 30 = 180日。平準化の中にも月単位の抽象を持ち込む方式だ。
// 契約日数そのまま
1月の売上 = 3,000,000 / 181 × 12 = 198,895.02...

// 30日固定換算
1月の売上 = 3,000,000 / 180 × 12 = 200,000.00

結局、どの方式を選んでもポリシーの決定は避けられない。

#どこで時点を区切るか

前の二つのセクションは、月次の売上額をいくらにするかというポリシーだった。今回は、その売上をどの月に帰属させるかという時点ポリシーだ。発生主義はサービスを提供した時点で売上を認識する仕組みだが、問題は「時点」の精度が言語によって異なることだ。

1月31日 23:59:59に発生した売上は1月のものだ。1秒後の2月1日 00:00:00なら2月のものになる。当たり前のようでいて、コードでこの境界を捉える方法は厄介だ。Java/KotlinのLocalDateTimeはナノ秒、Pythonのdatetimeはマイクロ秒、JavaScriptのDateはミリ秒まで表現する。同じ「23:59:59」でも、言語によってその後に続く桁数が違い、この差が境界における売上の月帰属を変える。

// 1月分の売上を集計
val start = LocalDateTime.of(2026, 1, 1, 0, 0, 0)
val end = LocalDateTime.of(2026, 1, 31, 23, 59, 59)

val januaryRecords = revenueRepository
    .findByRecognizedAtBetween(start, end)

23:59:59.500に発生したレコードはどこへ行くのか。23:59:59までで打ち切れば、それ以降のミリ秒単位は漏れる。2月1日 00:00:00から照会してもこのレコードは引っかからない。どの月にも属さない売上が生まれる。

// 半開区間(half-open interval)
val start = LocalDateTime.of(2026, 1, 1, 0, 0, 0)
val end = LocalDateTime.of(2026, 2, 1, 0, 0, 0)

val januaryRecords = revenueRepository
    .findByRecognizedAtGreaterThanEqualAndRecognizedAtLessThan(start, end)
// >= 1月1日 00:00:00, < 2月1日 00:00:00

始まりは含み、終わりは含まない。こうすれば境界で漏れも重複も起きない。会計ではこの境界処理をカットオフ(cutoff)と呼ぶ。

タイムゾーンも合わせて考慮する必要がある。DBは通常UTCだが、税務インボイスは事業所のタイムゾーン基準だ。韓国事業者であればKST。1月31日 午後11時30分(KST)に発生したレコードはUTCでは1月31日 14:30となり、どちらの基準でも1月に入る。だが2月1日 午前3時(KST)はUTCでは1月31日 18:00だ。UTCで集計すればこのレコードが1月に入ってしまい、帳簿とズレる。カットオフはKST基準で切らなければならない。

カットオフは必ずしも0時である必要はない。営業が深夜0時を越える業種(飲食店、コンビニなど)では、締めの時刻を未明にずらすほうが自然だ。サービスによっては、精算日の締めを未明まで延長するオプションを提供する場合もある。

#決済テーブル一つでは足りない

ここまでのポリシー — 分母日数とカットオフ — はすべて、月次の認識売上を算出するためのものだった。決済テーブルには「誰が、いつ、いくら払ったか」しか入っていない。「この金額のうち、今月認識する金額はいくらか」に答えるには、別のテーブルが必要だ。

// 決済 — キャッシュフロー
data class Payment(
    val id: Long,
    val customerId: Long,
    val amount: BigDecimal,       // 決済金額:3,000,000
    val completedAt: Instant      // 決済時点
)

// 売上認識スケジュール — 発生主義基準
data class RevenueSchedule(
    val paymentId: Long,
    val yearMonth: YearMonth,        // 帰属月:2026-01
    val recognizedAmount: BigDecimal, // 認識売上:193,548
    val deferredAmount: BigDecimal,  // 繰延残高:2,806,452
    val serviceDays: Int,            // サービス日数:12
    val totalDaysInMonth: Int        // 当該月の日数:31
)

決済が発生すると、購読期間全体にわたって認識スケジュールを生成する。B社の場合、1月から7月まで7件のレコードが作られる。月末ごとにその月の認識売上を合計すれば、月次売上になる。

丸めがポリシーであったのと同じく、決済金額と月次の認識売上も別の数字だ。日割り計算の分母、売上帰属のカットオフ、そしてその結果を保存するテーブル — すべての段階の決定は会計ポリシーが行った。売上認識とはポリシーの累積である。

次回扱う二つの問い。 6ヶ月分の認識売上をすべて合計すると300万ウォンになるはずだが、毎月の小数点切り捨てによって合計が不足する。そしてB社が3ヶ月目に解約を要請したら、まだ認識していない繰延収益はどうなるのか。

#参考資料

会計基準(一次出典)

実務ガイド

Share this post