///|
/// The number of days in a month, accounting for leap years.
fn days_in_month(year : Int, month : Int) -> Int {
match month {
2 =>
if year % 400 == 0 || (year % 4 == 0 && year % 100 != 0) {
29
} else {
28
}
4 | 6 | 9 | 11 => 30
_ => 31
}
}
///|
/// Whether a `DTPOSTED`/`DTUSER` value is a calendar date this importer accepts.
///
/// The accepted shapes are `YYYYMMDD`, `YYYYMMDDHHMMSS`,
/// `YYYYMMDDHHMMSS.sss` and `YYYYMMDDHHMMSS[gmt offset:tz name]` with any
/// combination of the fractional part and the zone bracket. The date itself is
/// checked against the real calendar, including leap years, because a bank that
/// writes `20260229` has produced a value no ledger can hold. A length between
/// nine and thirteen digits matches no documented shape and is rejected rather
/// than guessed at.
fn valid_date(value : String) -> Bool {
let chars = value.iter().to_array()
if chars.length() < 8 {
return false
}
for index in 0..<8 {
if chars[index] < '0' || chars[index] > '9' {
return false
}
}
let month = (chars[4].to_int() - 48) * 10 + chars[5].to_int() - 48
let day = (chars[6].to_int() - 48) * 10 + chars[7].to_int() - 48
let year = (chars[0].to_int() - 48) * 1000 +
(chars[1].to_int() - 48) * 100 +
(chars[2].to_int() - 48) * 10 +
chars[3].to_int() -
48
if month < 1 || month > 12 || day < 1 || day > days_in_month(year, month) {
return false
}
if chars.length() > 8 && chars.length() < 14 {
return false
}
if chars.length() >= 14 {
for index in 8..<14 {
if chars[index] < '0' || chars[index] > '9' {
return false
}
}
let hour = (chars[8].to_int() - 48) * 10 + chars[9].to_int() - 48
let minute = (chars[10].to_int() - 48) * 10 + chars[11].to_int() - 48
let second = (chars[12].to_int() - 48) * 10 + chars[13].to_int() - 48
if hour > 23 || minute > 59 || second > 59 {
return false
}
if chars.length() > 14 {
let mut offset = 14
if chars[offset] == '.' {
offset += 1
let start = offset
while offset < chars.length() &&
chars[offset] >= '0' &&
chars[offset] <= '9' {
offset += 1
}
if offset == start {
return false
}
}
if offset < chars.length() {
if chars[offset] != '[' ||
chars[chars.length() - 1] != ']' ||
offset + 2 >= chars.length() {
return false
}
}
}
}
true
}
///|
/// A parsed OFX posting date.
///
/// The fields are the ones the document states, in the zone it states them in:
/// `hour`/`minute`/`second` are local to `offset_minutes`, not UTC. Call
/// [`PostedDate::to_utc_string`] to compare or sort records that a bank wrote in
/// different zones.
pub struct PostedDate {
year : Int
month : Int
day : Int
hour : Int
minute : Int
second : Int
offset_minutes : Int
}
///|
/// Parse an OFX date stamp, or return `None` when it is not one.
///
/// A date without a time is midnight; a date without a zone bracket is treated
/// as already being in UTC, which is what OFX means by omitting the offset. The
/// zone name after the colon is documentation for a human and is ignored: only
/// the numeric offset is used, because the abbreviations are not unique.
pub fn parse_posted_date(value : String) -> PostedDate? {
if !valid_date(value) {
return None
}
let chars = value.iter().to_array()
let year = (chars[0].to_int() - 48) * 1000 +
(chars[1].to_int() - 48) * 100 +
(chars[2].to_int() - 48) * 10 +
chars[3].to_int() -
48
let month = (chars[4].to_int() - 48) * 10 + chars[5].to_int() - 48
let day = (chars[6].to_int() - 48) * 10 + chars[7].to_int() - 48
let mut hour = 0
let mut minute = 0
let mut second = 0
if chars.length() >= 14 {
hour = (chars[8].to_int() - 48) * 10 + chars[9].to_int() - 48
minute = (chars[10].to_int() - 48) * 10 + chars[11].to_int() - 48
second = (chars[12].to_int() - 48) * 10 + chars[13].to_int() - 48
}
let mut offset_minutes = 0
if chars.length() > 14 {
let mut index = 14
if chars[index] == '.' {
index += 1
while index < chars.length() && chars[index] >= '0' && chars[index] <= '9' {
index += 1
}
}
if index < chars.length() && chars[index] == '[' {
let mut cursor = index + 1
let mut negative = false
if chars[cursor] == '-' || chars[cursor] == '+' {
negative = chars[cursor] == '-'
cursor += 1
}
let mut hours = 0
while cursor < chars.length() &&
chars[cursor] >= '0' &&
chars[cursor] <= '9' {
hours = hours * 10 + (chars[cursor].to_int() - 48)
cursor += 1
}
let mut fraction = 0
let mut fraction_digits = 0
if cursor < chars.length() && chars[cursor] == '.' {
cursor += 1
while cursor < chars.length() &&
chars[cursor] >= '0' &&
chars[cursor] <= '9' &&
fraction_digits < 3 {
fraction = fraction * 10 + (chars[cursor].to_int() - 48)
fraction_digits += 1
cursor += 1
}
}
while fraction_digits < 3 {
fraction = fraction * 10
fraction_digits += 1
}
let magnitude = hours * 60 + fraction * 60 / 1000
offset_minutes = if negative { -magnitude } else { magnitude }
if offset_minutes > 1440 || offset_minutes < -1440 {
return None
}
}
}
Some({ year, month, day, hour, minute, second, offset_minutes, })
}
///|
/// Whether the stamp is already in UTC, i.e. declares no offset.
pub fn PostedDate::is_utc(self : PostedDate) -> Bool {
self.offset_minutes == 0
}
///|
/// The date as `YYYY-MM-DD`, in the zone the document declared.
pub fn PostedDate::date_string(self : PostedDate) -> String {
self.year.to_string() +
"-" +
self.month.to_string().pad_start(2, '0') +
"-" +
self.day.to_string().pad_start(2, '0')
}
///|
/// The instant as `YYYYMMDDHHMMSS` in UTC, with the day, month and year carried
/// where the offset crosses a boundary.
pub fn PostedDate::to_utc_string(self : PostedDate) -> String {
let mut total = (self.hour * 60 + self.minute) * 60 +
self.second -
self.offset_minutes * 60
let mut year = self.year
let mut month = self.month
let mut day = self.day
if total < 0 {
total += 86400
let shifted = shift_days(year, month, day, -1)
year = shifted.0
month = shifted.1
day = shifted.2
} else if total >= 86400 {
total -= 86400
let shifted = shift_days(year, month, day, 1)
year = shifted.0
month = shifted.1
day = shifted.2
}
year.to_string().pad_start(4, '0') +
month.to_string().pad_start(2, '0') +
day.to_string().pad_start(2, '0') +
(total / 3600).to_string().pad_start(2, '0') +
(total / 60 % 60).to_string().pad_start(2, '0') +
(total % 60).to_string().pad_start(2, '0')
}
///|
/// The instant as `YYYY-MM-DDTHH:MM:SSZ` in UTC, for logs and JSON.
pub fn PostedDate::to_iso_string(self : PostedDate) -> String {
let utc = self.to_utc_string()
let chars = utc.iter().to_array()
let out = StringBuilder()
for index in 0.. (Int, Int, Int) {
if delta > 0 {
let last = days_in_month(year, month)
if day < last {
(year, month, day + 1)
} else if month < 12 {
(year, month + 1, 1)
} else {
(year + 1, 1, 1)
}
} else if day > 1 {
(year, month, day - 1)
} else if month > 1 {
(year, month - 1, days_in_month(year, month - 1))
} else {
(year - 1, 12, 31)
}
}
///|
/// Whether a year is a leap year in the Gregorian calendar.
pub fn is_leap_year(year : Int) -> Bool {
year % 400 == 0 || (year % 4 == 0 && year % 100 != 0)
}
///|
/// The number of days in a month, or `None` when `month` is outside 1 to 12.
pub fn month_length(year : Int, month : Int) -> Int? {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => Some(31)
4 | 6 | 9 | 11 => Some(30)
2 => Some(if is_leap_year(year) { 29 } else { 28 })
_ => None
}
}
///|
/// Whether a string is a date this importer accepts, without building one.
///
/// This is the same calendar check [`parse_posted_date`] applies, exposed for a
/// caller that only needs the yes-or-no answer, for example when validating a
/// `DTSTART`/`DTEND` pair before storing it.
pub fn is_valid_ofx_date(value : String) -> Bool {
valid_date(value)
}
///|
/// Days since 1970-01-01 for a proleptic Gregorian date.
///
/// Howard Hinnant's `days_from_civil`: it turns a calendar date into a
/// continuous day number, which is what makes comparing two instants, counting
/// the days between them and shifting a date by a number of days all integer
/// arithmetic instead of a pile of month-length special cases.
fn days_from_civil(year : Int, month : Int, day : Int) -> Int {
let y = if month <= 2 { year - 1 } else { year }
let era = (if y >= 0 { y } else { y - 399 }) / 400
let yoe = y - era * 400
let mp = (month + 9) % 12
let doy = (153 * mp + 2) / 5 + day - 1
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy
era * 146097 + doe - 719468
}
///|
/// The inverse of [`days_from_civil`]: the calendar date of a day number.
fn civil_from_days(days : Int) -> (Int, Int, Int) {
let shifted = days + 719468
let era = (if shifted >= 0 { shifted } else { shifted - 146096 }) / 146097
let doe = shifted - era * 146097
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365
let y = yoe + era * 400
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100)
let mp = (5 * doy + 2) / 153
let day = doy - (153 * mp + 2) / 5 + 1
let month = if mp < 10 { mp + 3 } else { mp - 9 }
(if month <= 2 { y + 1 } else { y }, month, day)
}
///|
/// The instant as seconds since the Unix epoch in UTC, ignoring the fractional
/// seconds an OFX stamp may carry.
pub fn PostedDate::to_epoch_seconds(self : PostedDate) -> Int64 {
let days = days_from_civil(self.year, self.month, self.day)
let seconds = days * 86400 +
self.hour * 3600 +
self.minute * 60 +
self.second -
self.offset_minutes * 60
seconds.to_int64()
}
///|
/// The weekday of the date in the zone it declares, where `0` is Sunday.
pub fn PostedDate::weekday(self : PostedDate) -> Int {
let days = days_from_civil(self.year, self.month, self.day)
(days % 7 + 11) % 7
}
///|
/// The English name of [`PostedDate::weekday`], for logs and reports.
pub fn PostedDate::weekday_name(self : PostedDate) -> String {
match self.weekday() {
0 => "Sunday"
1 => "Monday"
2 => "Tuesday"
3 => "Wednesday"
4 => "Thursday"
5 => "Friday"
_ => "Saturday"
}
}
///|
/// The date shifted by a whole number of days, carrying month and year.
///
/// `add_days(-1)` on the first of a month lands on the last day of the previous
/// one, including across a leap year.
pub fn PostedDate::add_days(self : PostedDate, days : Int) -> PostedDate {
let shifted = civil_from_days(
days_from_civil(self.year, self.month, self.day) + days,
)
{
year: shifted.0,
month: shifted.1,
day: shifted.2,
hour: self.hour,
minute: self.minute,
second: self.second,
offset_minutes: self.offset_minutes,
}
}
///|
/// Compare two instants in UTC: negative when `self` is earlier.
pub fn PostedDate::compare(self : PostedDate, other : PostedDate) -> Int {
let left = self.to_epoch_seconds()
let right = other.to_epoch_seconds()
if left < right {
-1
} else if left > right {
1
} else {
0
}
}
///|
/// Whether the two instants are the same moment, wherever they were written.
///
/// `20260924120000[-5:EST]` and `20260924170000` are the same instant and this
/// returns `true`, which is what a reconciliation between two exports of the
/// same account needs.
pub fn PostedDate::same_instant(self : PostedDate, other : PostedDate) -> Bool {
self.compare(other) == 0
}
///|
/// Whole days from `start` to `end`, comparing the instants in UTC.
///
/// A negative result means `end` is before `start`. The division floors, so a
/// difference of one hour still reports `0` days and a difference of
/// twenty-three hours reports `1` only when it crosses a UTC midnight.
pub fn days_between(start : PostedDate, end : PostedDate) -> Int {
let seconds = end.to_epoch_seconds() - start.to_epoch_seconds()
let days = seconds / 86400L
let remainder = seconds % 86400L
if remainder < 0L {
(days - 1L).to_int()
} else {
days.to_int()
}
}
///|
/// Build a date from its parts, or `None` when they are not a real calendar
/// moment.
///
/// This is the checked constructor the parser uses internally; a caller that
/// computes a date rather than reading one can use it to avoid inventing a
/// `PostedDate` that never existed.
pub fn PostedDate::of(
year : Int,
month : Int,
day : Int,
hour : Int,
minute : Int,
second : Int,
offset_minutes : Int,
) -> PostedDate? {
let length = match month_length(year, month) {
Some(days) => days
None => return None
}
if day < 1 || day > length {
return None
}
let clock_valid = hour >= 0 &&
hour <= 23 &&
minute >= 0 &&
minute <= 59 &&
second >= 0 &&
second <= 59
if !clock_valid {
return None
}
if offset_minutes > 1440 || offset_minutes < -1440 {
return None
}
Some({ year, month, day, hour, minute, second, offset_minutes, })
}
///|
/// Midnight UTC on a date, or `None` when the date does not exist.
pub fn PostedDate::at_utc(year : Int, month : Int, day : Int) -> PostedDate? {
PostedDate::of(year, month, day, 0, 0, 0, 0)
}
///|
/// The date as `YYYYMMDD`, the form OFX uses for a period boundary.
pub fn PostedDate::to_ofx_date(self : PostedDate) -> String {
self.year.to_string().pad_start(4, '0') +
self.month.to_string().pad_start(2, '0') +
self.day.to_string().pad_start(2, '0')
}
///|
/// The instant as `YYYYMMDDHHMMSS` in the zone the document declared, which is
/// the inverse of [`parse_posted_date`] for a stamp without a zone bracket.
pub fn PostedDate::to_ofx_stamp(self : PostedDate) -> String {
self.to_ofx_date() +
self.hour.to_string().pad_start(2, '0') +
self.minute.to_string().pad_start(2, '0') +
self.second.to_string().pad_start(2, '0')
}
///|
/// Every date from `start` to `end` inclusive, or an empty array when `end` is
/// before `start`.
///
/// A statement period is a date range, and a caller that wants one row per day
/// (a cash-flow chart, a zero-activity check) should not have to write the
/// loop and the month-length rules again. The range is capped at 3660 days so a
/// reversed or absurd period cannot allocate without bound.
pub fn date_range(start : PostedDate, end : PostedDate) -> Array[PostedDate] {
let dates : Array[PostedDate] = []
let last = days_from_civil(end.year, end.month, end.day)
let mut current = days_from_civil(start.year, start.month, start.day)
let mut count = 0
while current <= last && count < 3660 {
let (year, month, day) = civil_from_days(current)
match PostedDate::at_utc(year, month, day) {
Some(date) => dates.push(date)
None => ()
}
current += 1
count += 1
}
dates
}
///|
/// Parse a period boundary such as `DTSTART`/`DTEND`.
///
/// OFX writes these as a date or as a full stamp; both are accepted, and the
/// result is the instant the document declares. `None` means the value is not a
/// date this importer accepts.
pub fn parse_period_date(value : String) -> PostedDate? {
parse_posted_date(value)
}