
It was the quarterly closing meeting. The accountant from our accounting firm shared a screen and stopped at Company B’s row. “This says January revenue is ₩3,000,000. Is that right?” I said it was. The accountant 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 much counts as revenue. About ₩190,000.”
Company B had paid ₩3,000,000 on January 20 for six months of subscription. A payment notification fired in the system and January revenue on the dashboard went up by ₩3,000,000. I’d assumed the amount paid was revenue, but in accounting it isn’t. The subscription runs from January 20 to July 19, January’s service is 12 days of that, and only that much is recognized as January revenue. About ₩190,000.
The remaining ₩2,810,000 is in the bank account, but it’s payment for service still owed over the rest of the subscription period. That money is booked as a liability called deferred revenue.
#Cash basis and accrual basis
Accounting has two ways to decide when revenue is recognized. Cash basis and accrual basis. Cash basis recognizes revenue when the money comes in. It’s close to intuition and easy to put in code. When the PG webhook arrives, stamp payment_completed_at and put the amount in amount. The payment event is recognized as revenue the moment it happens.
// Cash basis: recognize the full amount at payment time
fun onPaymentCompleted(payment: Payment) {
revenueRepository.save(
Revenue(
amount = payment.amount, // the full ₩3,000,000
recognizedAt = payment.completedAt // payment time
)
)
}
Accrual basis is different. It recognizes revenue when the service is delivered, not when the money is received. In terms of async work, it’s recording the work as it progresses rather than at the time the request arrived. Payment is the moment the request comes in, and revenue is the progress.
K-IFRS 1115 in Korea and ASC 606 in the US both follow this principle. For a corporation, accrual basis is mandatory for revenue.
// Accrual basis: recognize in portions across the service period
fun onPaymentCompleted(payment: Payment, subscription: Subscription) {
// Book the payment as deferred revenue (a liability)
deferredRevenueRepository.save(
DeferredRevenue(
amount = payment.amount, // ₩3,000,000
serviceStart = subscription.startDate,
serviceEnd = subscription.endDate
)
)
// Each month, move the delivered portion from deferred revenue to revenue (detailed below)
}
#How much of the ₩3,000,000 is January
Company B’s subscription terms.
- Contract amount: ₩3,000,000 (₩500,000 a month × 6 months)
- Subscription start: 2026-01-20
- Subscription end: 2026-07-19
- January service days: 12, from January 20 through 31
Had the subscription started on January 1, this would be simple. January revenue is the monthly fee, ₩500,000, as is. The actual start is January 20, and January got 12 days of service. You can’t recognize the whole month, so you carve out 12 days’ worth. This is where pro-rata calculation comes in. There are two ways.
1. Divide by the days in each month: Take the monthly fee, divide by the actual days in that month, and multiply by the days served. Each month’s fee is distributed within that month only.
January revenue = 500,000 / 31 × 12 = 193,548.38...
2. Smooth over the contract period: Divide the contract total by the total service days to get a daily rate first, then multiply by the service days in each month. This treats the whole contract as one 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. Same contract, and a gap of more than ₩5,000. Which one is right isn’t decided by the code. Accounting policy decides.
#How many days do you divide by
In Korean practice, where the monthly fee is the basic unit for billing and tax invoices, method 1 tends to be more common. Divide by the days in each month and the daily rate changes from month to month, because January has 31 days and February has 28.
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, and February’s daily rate is more than ₩1,700 higher. There are three policies for handling this gap.
- Actual days. Divide by the month’s actual day count, 28, 29, 30, or 31. It’s the most precise and the one audit practice prefers, and the daily rate changes every month.
- Fixed 30 days. Treat every month as 30 days, which is common in finance. The daily rate stays constant, which is easier to operate, with January recognizing one day less and February two days more.
- 365 days. Divide the annual fee by 365, or 366 in a leap year, to get the daily rate, with no concept of a monthly fee. It isn’t used for this post’s six-month contract.
enum class DayCountPolicy {
ACTUAL, // actual days in the month
FIXED_30 // treat every month as 30 days
}
fun daysInMonth(yearMonth: YearMonth, policy: DayCountPolicy): BigDecimal =
when (policy) {
DayCountPolicy.ACTUAL -> BigDecimal(yearMonth.lengthOfMonth())
DayCountPolicy.FIXED_30 -> BigDecimal("30")
}
In global SaaS, where annual and multi-year subscriptions are standard, method 2 tends to be preferred. That’s because ASC 606 and IFRS 15 naturally accept treating the whole contract as one performance obligation and smoothing it over the period. Even when you allocate across the whole contract period, the denominator is a separate decision, and the same contract yields a different daily rate when the basis changes.
- Contract days as is. 2026-01-20 through 2026-07-19 is 181 days. The actual service period is the denominator.
- Fixed 30-day conversion. 6 months × 30 = 180 days. The monthly abstraction comes back inside the smoothing.
// Contract days as is
January revenue = 3,000,000 / 181 × 12 = 198,895.02...
Changing the denominator to 180 days doesn’t by itself complete the 30-day conversion. You also have to decide how many days January 20 through 31 counts as and how month ends convert, and without those rules, multiplying by the actual 181 days as is pushes the six-month total past ₩3,000,000. So here only the calculation using the 181 contract days is a complete example. The point still holds: same contract, same 12 days, and one denominator changes January’s number.
#Where to cut the accrual
With the revenue amount settled, what’s left is which month that revenue belongs to. The boundary looks like a single point at midnight, but depending on how you close that point in code, you get records that belong to no month.
Revenue that occurs at 23:59:59 on January 31 is January. One second later, at 00:00:00 on February 1, it’s February. It looks obvious, but catching that boundary in code is fiddly. LocalDateTime in Java and Kotlin goes to nanoseconds, Python’s datetime to microseconds, JavaScript’s Date to milliseconds. That means there are more digits after 23:59:59, and if you close the boundary at the second, anything sitting in those digits lands in no month. Whether those digits are actually stored is decided by the column, not the language. MySQL DATETIME has a default precision of 0, so by default it rounds fractional seconds when storing, and truncates depending on configuration. With rounding, 23:59:59.5 can roll over into the next month’s midnight. You have to declare DATETIME(6) to keep microseconds, while PostgreSQL timestamp defaults to microseconds.
// 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)
Where does a record at 23:59:59.500 go? Query only up to 23:59:59 and anything in the milliseconds after it is dropped. Query from 00:00:00 on February 1 and it isn’t caught there either. You get revenue that 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. Drawn this way, nothing at the boundary is dropped or double counted. In accounting this boundary handling is called the cutoff.
Time zones come into it too. Even if the database uses UTC, tax invoices follow the business’s time zone, which for a Korean entity is KST. A record at 11:30pm KST on January 31 is 14:30 UTC on January 31, January either way. One at 3am KST on February 1 is 18:00 UTC on January 31. Aggregate in UTC and it lands in January, out of step with the books. The cutoff has to be drawn in KST.
The cutoff doesn’t have to be midnight. In businesses that stay open past midnight, like restaurants and convenience stores, it’s natural to push the close into the early hours. Some services offer an option to delay the settlement-day close until early morning.
#One payment table is not enough
Even with the denominator days and the cutoff decided, the payment table only holds who paid how much and when. To answer how much of that amount is recognized this month, you need 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 comes in, build the recognition schedule across the whole subscription period. For Company B that’s seven records, January through July, which under the days-in-each-month method come to ₩193,548 in January, ₩500,000 each from February through June, and the remaining ₩306,452 in July. Sum the month’s recognized revenue at month end and you have that month’s revenue, and check that the seven records add up to the contract amount.
As with where the 0.5 went in rounding, how much of a payment is this month’s revenue is decided by policy, not by code. The pro-rata denominator, the cutoff for which month revenue belongs to, the table that holds the result: every decision along the way was made by accounting policy. Revenue recognition is the accumulation of policy.
Two questions remain. If monthly truncation leaves the six-month total of recognized revenue short of ₩3,000,000, how do you make it match? And if Company B asks to cancel in month three, what happens to the unrecognized deferred revenue?
#References
Accounting standards (primary sources)
- FASB ASC 606, Revenue from Contracts with Customers: the US GAAP standard for revenue recognition.
- IFRS 15, Revenue from Contracts with Customers: the international standard for revenue recognition and the parent of K-IFRS 1115.
- K-IFRS 1115, Revenue from Contracts with Customers: the Korean adoption of the international standard for revenue recognition.
Practical guides
- PwC, Revenue Recognition: An IFRS 15 Guide for Software and SaaS Sellers 2022: walks through applying IFRS 15 to SaaS revenue recognition.
- The SaaS CFO, Guide to SaaS Revenue Recognition and Deferred Revenue: explains SaaS deferred revenue structure and the difference between daily and smoothed recognition.
- Chargebee, Ultimate Guide to SaaS Revenue Recognition: shows the five-step ASC 606 model with pro-rata examples.