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

Accounting Through Code: The Day You Got Paid Is Not the Day You Earned It

· Sangkyoon Nam

It was the quarterly closing meeting. The auditor from our accounting firm shared their screen, then stopped at Company B’s row. “This says January revenue is ₩3,000,000 — is that right?” I said yes. They shook their head. “The subscription started on January 20, didn’t it? You only delivered the service for 12 days in January, so only that portion counts as revenue. About ₩190,000.”

Company B paid ₩3,000,000 on January 20 for a 6-month subscription. The payment notification fired, and the dashboard added ₩3,000,000 to January revenue. I assumed the amount received equaled revenue. In accounting, it does not. The subscription runs from January 20 through July 19. Only the 12 days of service delivered in January count as January revenue — about ₩190,000. The remaining ₩2,810,000 is in our account, but it represents service we owe over the next five months. It sits on the books as a liability called deferred revenue.

#Cash Basis and Accrual Basis

Accounting offers two ways to decide when revenue is recognized: cash basis and accrual basis. Cash basis recognizes revenue at the moment money comes in. It is intuitive and easy to translate into code. When a PG webhook fires, you stamp payment_completed_at and put the amount in amount. The payment event becomes the revenue event the instant it happens.

// Cash basis: recognize the full amount at the moment of payment
fun onPaymentCompleted(payment: Payment) {
    revenueRepository.save(
        Revenue(
            amount = payment.amount,         // ₩3,000,000 in full
            recognizedAt = payment.completedAt // payment time
        )
    )
}

Accrual basis is different. Revenue is recognized at the moment service is delivered — not when the money arrives. By analogy to async work, you record when the actual processing completes, not when the request was received. Payment is the request; revenue is the completion. Two different timestamps.

Both K-IFRS 1115 (Korea) and ASC 606 (US) follow this principle. For an incorporated entity, accrual basis is mandatory for revenue accounting.

// Accrual basis: recognized in portions across the service period
fun onPaymentCompleted(payment: Payment, subscription: Subscription) {
    // The paid amount is recorded as deferred revenue (a liability)
    deferredRevenueRepository.save(
        DeferredRevenue(
            amount = payment.amount,         // ₩3,000,000
            serviceStart = subscription.startDate,
            serviceEnd = subscription.endDate
        )
    )
    // Each month, the served portion moves from deferred revenue to revenue (detailed below)
}

#How Much of the ₩3,000,000 Belongs to January

Company B’s subscription terms:

  • Contract amount: ₩3,000,000 (₩500,000/month × 6 months)
  • Subscription start: 2026-01-20
  • Subscription end: 2026-07-19
  • January service days: 12 (1/20 ~ 1/31)

If the subscription had started on January 1, this would be simple — January revenue is ₩500,000, the monthly subscription fee as is. But the actual start is January 20, and only 12 days were served in January. You cannot recognize a full month, so you have to carve out 12 days. This is where pro-rata calculation enters the picture. There are two ways to handle it.

1. Divide by the days in each month : Take the monthly subscription fee, divide by the actual number of days in that month, and multiply by the days served. The monthly fee is distributed within its own month only.

January revenue = 500,000 / 31 × 12 = 193,548.38...

2. Smooth over the entire contract period : Divide the total contract amount by the total service days to derive a daily rate, then multiply by the service days in each month. This treats the whole contract as a single performance obligation.

// Total service days: 2026-01-20 ~ 2026-07-19 = 181 days
January revenue = 3,000,000 / 181 × 12 = 198,895.02...

₩193,548 versus ₩198,895. The same contract produces a difference of over ₩5,000. Which one is correct is not for code to decide. Accounting policy decides.

#What Do You Divide By?

In Korean practice, where monthly subscription fees are the basic unit on invoices and tax invoices, method 1 tends to be more common. Once you choose method 1, the next question follows immediately. January has 31 days; February has 28. The same monthly fee yields a different daily rate depending on the month.

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...

Same plan, but February’s daily rate is more than ₩1,700 higher. There are three policies for handling this gap.

  • Actual days : Divide by the actual number of days in the month (28, 29, 30, 31). The most precise, and the method generally preferred in audit practice. The trade-off, as noted, is that the daily rate varies from month to month.
  • Fixed 30 days : Treat every month as 30 days. Common in finance. The daily rate stays constant, which simplifies operations. The cost is that January recognizes one day less than reality, and February recognizes two days more.
  • 365 days : Divide the annual subscription fee by 365 to derive the daily rate. The concept of a monthly fee disappears — one day equals annual / 365. Leap years use 366. This applies only to annual subscriptions and does not apply to this case.
enum class DayCountPolicy {
    ACTUAL,      // actual days in the month
    FIXED_30,    // treat every month as 30 days
    ANNUAL_365   // annual basis, 365 days
}

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)
    }

In global SaaS, where annual or multi-year subscriptions are the norm, method 2 tends to be preferred. Treating the whole contract as one performance obligation and recognizing it ratably over the period is the form ASC 606 and IFRS 15 most naturally accommodate. Even with method 2, another question follows: what number of days do you use as the denominator for smoothing? The same contract produces different daily rates depending on the basis.

  • Contract days as-is : 2026-01-20 ~ 2026-07-19 = 181 days. The actual service period is used directly as the denominator.
  • Fixed 30-day months : 6 months × 30 = 180 days. Brings the monthly abstraction inside the smoothing calculation.
// Contract days as-is
January revenue = 3,000,000 / 181 × 12 = 198,895.02...

// Fixed 30-day months
January revenue = 3,000,000 / 180 × 12 = 200,000.00

Whichever method you choose, the policy decision is unavoidable.

#Where Do You Cut the Boundary?

The previous two sections were about the policy for the amount of monthly revenue. This section is about the policy for the timing — which month a given revenue event belongs to. Accrual basis recognizes revenue at the moment service is delivered. The problem is that the precision of “the moment” varies by language.

January 31, 23:59:59 — that revenue is January. One second later, February 1, 00:00:00 — that’s February. Obvious enough, but the way code captures this boundary is tricky. Java/Kotlin’s LocalDateTime goes to nanoseconds, Python’s datetime to microseconds, JavaScript’s Date to milliseconds. Even for the same “23:59:59,” the trailing digits differ by language, and that difference changes which month the revenue belongs to.

// Aggregating January revenue
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)

What happens to a record at 23:59:59.500? If you query up to 23:59:59 only, anything in the milliseconds after that is missed. Querying from 00:00:00 on February 1 does not catch it either. The revenue belongs to no month.

// 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)
// >= January 1 00:00:00, < February 1 00:00:00

Start inclusive, end exclusive. With this convention, no record is dropped or double-counted at the boundary. In accounting, this boundary treatment is called the cutoff.

Time zones need consideration too. The database is usually UTC, but tax invoices use the business’s local time zone. For a Korean entity, that’s KST. A record at 23:30 KST on January 31 lands at 14:30 UTC on January 31 — January from either angle. But 03:00 KST on February 1 is 18:00 UTC on January 31. Aggregate in UTC, and this record falls into January, contradicting the books. The cutoff must be drawn in KST.

The cutoff is not always at midnight. In industries whose operating hours extend past midnight (restaurants, convenience stores), it is natural to push the closing time into the early morning. Some services offer the option to delay the sales day cutoff to the early morning hours.

#A Payment Table Alone Is Not Enough

The policies so far — denominator days and the cutoff — were all for the purpose of producing monthly recognized revenue. The payment table only holds “who, when, how much paid.” It cannot answer “of this amount, how much is recognized for this month.” Answering that requires a separate table.

// Payment — cash flow
data class Payment(
    val id: Long,
    val customerId: Long,
    val amount: BigDecimal,       // Payment amount: 3,000,000
    val completedAt: Instant      // Payment time
)

// Revenue recognition schedule — accrual basis
data class RevenueSchedule(
    val paymentId: Long,
    val yearMonth: YearMonth,        // Recognition month: 2026-01
    val recognizedAmount: BigDecimal, // Recognized revenue: 193,548
    val deferredAmount: BigDecimal,  // Deferred balance: 2,806,452
    val serviceDays: Int,            // Service days: 12
    val totalDaysInMonth: Int        // Days in the month: 31
)

When a payment occurs, a recognition schedule is generated across the entire subscription period. For Company B, that means seven records, one each from January through July. Sum the recognized revenue at the end of each month, and you get the monthly revenue.

Just as rounding was a policy, the payment amount and the monthly recognized revenue are different numbers. The denominator days, the revenue cutoff, and the table that holds the results — every decision along the way was made by accounting policy. Revenue recognition is the accumulation of policy.

Two questions for the next post. Summing the six months of recognized revenue should yield ₩3,000,000, but month-by-month truncation leaves the total short. And if Company B asks to cancel in the third month, what happens to the unrecognized deferred revenue?

#Further Reading

Accounting Standards (Primary Sources)

Practical Guides

Share this post