Created
November 7, 2012 22:39
-
-
Save HarryR/4035012 to your computer and use it in GitHub Desktop.
Simplistic telephone call rating engine
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * License: WTFPL - http://sam.zoy.org/wtfpl/ | |
| * Copyright: (c) 2012 Derp Ltd. <openrating@derp.ltd.uk> | |
| * Version: 0.1 | |
| * Authors: Harry Roberts | |
| */ | |
| module openrating; | |
| import std.stdio; | |
| import std.datetime; | |
| import std.array; | |
| import std.math; | |
| /** | |
| * Billing/Rating information to be applied to a call | |
| */ | |
| class Billing { | |
| /** | |
| * ISO 4217 currency code | |
| */ | |
| char[3] currency; | |
| /** | |
| * Cost per second during peak hours | |
| */ | |
| real cps_peak; | |
| /** | |
| * Cost per second during offpeak hours | |
| */ | |
| real cps_offpeak; | |
| /** | |
| * Cost per second during the weekend | |
| */ | |
| real cps_weekend; | |
| /** | |
| * Minimum cost for the call | |
| */ | |
| real cost_minimum; | |
| /** | |
| * Setup Cost or Connection Fee | |
| */ | |
| real cost_setup; | |
| /** | |
| * Increments of time to bill for, in seconds. | |
| * A 7 second call with 5 second increments would cost the same | |
| * as a 10 second call. | |
| */ | |
| Duration increment; | |
| /** | |
| * Number of decimal places that all monetary values should be accurate to. | |
| * | |
| * Values will be rounded down to this. | |
| */ | |
| ushort decimal_places; | |
| this () { | |
| currency = "!!!"; | |
| decimal_places = 4; | |
| } | |
| /** | |
| * Rounds a value to N `decimal_places` | |
| */ | |
| real round(real value) { | |
| return rint(value * (10 ^ decimal_places)) / (10 ^ decimal_places); | |
| } | |
| invariant () { | |
| assert( decimal_places >= 0 ); | |
| assert( false == increment.isNegative() ); | |
| } | |
| } | |
| /** | |
| * Options to control the breakdown of call seconds | |
| */ | |
| class BreakdownOptions { | |
| /** | |
| * Params: | |
| * peak_start = Start of Peak hours (starting from, inclusive) | |
| * peak_end = End of Peak hours (up to, but not including) | |
| * weekend_days = Days of week which are considered the Weekend | |
| */ | |
| this( TimeOfDay peak_start, TimeOfDay peak_end, DayOfWeek[] weekend_days ) { | |
| m_weekend_days = weekend_days; | |
| m_peak = Interval!TimeOfDay(peak_start, peak_end); | |
| } | |
| /** | |
| * Params: | |
| * current = Point in time | |
| * | |
| * Returns: true if point in time is within 'Peak hours' | |
| */ | |
| bool isPeak( SysTime current ) { | |
| return m_peak.begin <= cast(TimeOfDay)current && m_peak.end > cast(TimeOfDay)current; | |
| } | |
| /** | |
| * Params: | |
| * current = Point in time | |
| * | |
| * Returns: whether or not the current timestamp is part of the 'weekend' | |
| */ | |
| bool isWeekend( SysTime current ) { | |
| foreach( day; m_weekend_days ) { | |
| if( day == current.dayOfWeek ) { | |
| return true; | |
| } | |
| } | |
| return false; | |
| } | |
| package: | |
| SysTime nextTodSwitch( SysTime current ) { | |
| auto one_day = dur!"days"(1); | |
| // When today is a weekend, return 00:00:00 of the next first non-weekend day | |
| if( isWeekend(current) ) { | |
| do { | |
| current += one_day; | |
| } while( isWeekend(current) ); | |
| return current - (cast(TimeOfDay)current - TimeOfDay.min()); | |
| } | |
| // During 'peak' time return the offpeak switch time | |
| if( isPeak(current) ) { | |
| return current + (m_peak.end() - cast(TimeOfDay)current); | |
| } | |
| auto midnight = current + (TimeOfDay.max() - cast(TimeOfDay)current) + dur!"seconds"(1); | |
| // If tomorrow is weekend, return midnight (start of next day) | |
| if( isWeekend(current + one_day) ) { | |
| return midnight; | |
| } | |
| // Before peak time - return peak begin time | |
| if( cast(TimeOfDay)current < m_peak.begin() ) { | |
| return current + (m_peak.begin() - cast(TimeOfDay)current); | |
| } | |
| // Currently offpeak - return peak time of next day | |
| return midnight + (m_peak.begin() - TimeOfDay.min()); | |
| } | |
| private: | |
| DayOfWeek[] m_weekend_days; | |
| Interval!TimeOfDay m_peak; | |
| unittest { | |
| // 7am to 7pm, weekend = saturday & sunday | |
| auto options = new BreakdownOptions(TimeOfDay(7, 0), TimeOfDay(20, 0, 0), [DayOfWeek.sat, DayOfWeek.sun]); | |
| SysTime current; | |
| SysTime next; | |
| // Midnight, 8th November = offpeak | |
| current = SysTime(DateTime(2012, 11, 8, 0, 0, 0), TimeZone.getTimeZone("UTC")); | |
| assert( false == options.isWeekend(current) ); | |
| assert( false == options.isPeak(current) ); | |
| // 10th November 2012 = weekend | |
| current = SysTime(DateTime(2012, 11, 10, 0, 0, 0), TimeZone.getTimeZone("UTC")); | |
| assert( true == options.isWeekend(current) ); | |
| assert( false == options.isPeak(current) ); | |
| // 07:00:00 = peak | |
| current = SysTime(DateTime(2012, 11, 8, 7, 0, 0), TimeZone.getTimeZone("UTC")); | |
| assert( false == options.isWeekend(current) ); | |
| assert( true == options.isPeak(current) ); | |
| // Next TimeOfDay switch after 7am should be 8pm exactly (offpeak) | |
| current = SysTime(DateTime(2012, 11, 8, 7, 0, 0), TimeZone.getTimeZone("UTC")); | |
| next = options.nextTodSwitch(current); | |
| assert( current.day == next.day ); | |
| assert( next.hour == 20 ); | |
| assert( next.minute == 0 ); | |
| assert( next.second == 0 ); | |
| assert( false == options.isPeak(next) ); | |
| assert( false == options.isWeekend(next) ); | |
| // Next TOD switch after 11pm, 9th Nov should be midnight 10th november (weekend) | |
| current = SysTime(DateTime(2012, 11, 9, 23, 0, 0), TimeZone.getTimeZone("UTC")); | |
| next = options.nextTodSwitch(current); | |
| assert( options.isWeekend(next) ); | |
| assert( next.day == 10 ); | |
| assert( next.hour == 0 ); | |
| assert( next.minute == 0 ); | |
| assert( next.second == 0 ); | |
| assert( false == options.isPeak(current) ); | |
| assert( false == options.isWeekend(current) ); | |
| assert( true == options.isWeekend(next) ); | |
| } | |
| } | |
| /** | |
| * Representation of how many seconds from the call fall into the different | |
| * rating categories. | |
| */ | |
| class Breakdown { | |
| this ( Duration peak, Duration offpeak, Duration weekend ) { | |
| m_peak = peak; | |
| m_offpeak = offpeak; | |
| m_weekend = weekend; | |
| } | |
| /** | |
| * Total number of seconds | |
| */ | |
| Duration total() { | |
| return (m_peak + m_offpeak) + m_weekend; | |
| } | |
| /** | |
| * Seconds occuring during 'peak' times | |
| */ | |
| Duration peak () { return m_peak; } | |
| /** | |
| * Seconds occuring during 'offpeak' times. | |
| */ | |
| Duration offpeak () { return m_offpeak; } | |
| /** | |
| * Seconds occurring during the weekend. | |
| */ | |
| Duration weekend () { return m_weekend; } | |
| private: | |
| Duration m_peak; | |
| Duration m_offpeak; | |
| Duration m_weekend; | |
| invariant () { | |
| assert( false == m_peak.isNegative() ); | |
| assert( false == m_offpeak.isNegative() ); | |
| assert( false == m_weekend.isNegative() ); | |
| } | |
| } | |
| SysTime roundUpTo( SysTime point, Duration increment ) | |
| in { | |
| // Must never be over 60 seconds | |
| assert( increment.total!"seconds" <= 60 ); | |
| // Even taking fractional seconds into account | |
| if( increment.total!"seconds" == 60 ) { | |
| assert( increment.fracSec.hnsecs == 0 ); | |
| } | |
| } | |
| body { | |
| /* When rounding up we only take the seconds and fractional seconds past | |
| * the minute into account. | |
| * | |
| * For ultimate precision we perform calculations internally with hecto- | |
| * nanoseconds. | |
| * | |
| * This still allows for weird conditions, like 57 seconds being rounded up | |
| * into the next minute. | |
| */ | |
| auto incr_hnsecs = increment.total!"hnsecs"(); | |
| auto point_hnsecs = (10000000 * point.second) + point.fracSec.hnsecs; | |
| if( (point_hnsecs % incr_hnsecs) == 0 ) { | |
| return point; | |
| } | |
| auto added_hnseconds = incr_hnsecs - (point_hnsecs % incr_hnsecs); | |
| return point + dur!"hnsecs"(added_hnseconds); | |
| } | |
| unittest { | |
| auto tz = TimeZone.getTimeZone("America/New_York"); | |
| // Round up whole seconds | |
| auto timepoint = SysTime(DateTime(2012, 11, 7, 6, 59, 1), tz); | |
| auto rounded_timepoint = roundUpTo(timepoint, dur!"seconds"(6)); | |
| assert( rounded_timepoint.second == 6 ); | |
| assert( timepoint.minute == rounded_timepoint.minute ); | |
| assert( timepoint.hour == rounded_timepoint.hour ); | |
| assert( timepoint.day == rounded_timepoint.day ); | |
| assert( timepoint.day == rounded_timepoint.day ); | |
| // Round up to the same second - should be the same | |
| // round(1.0, 1.0) = 1.0 | |
| rounded_timepoint = roundUpTo(timepoint, dur!"seconds"(1)); | |
| assert( rounded_timepoint == timepoint ); | |
| // Timepoint which has fractional seconds should be rounded upto the next full second | |
| // round(1.123, 1.0) = 2.0 | |
| timepoint = SysTime(DateTime(2012, 11, 7, 6, 59, 1), FracSec.from!"msecs"(123), tz); | |
| rounded_timepoint = roundUpTo(timepoint, dur!"seconds"(1)); | |
| assert( rounded_timepoint.fracSec == FracSec.from!"msecs"(0) ); | |
| assert( rounded_timepoint.second == 2 ); | |
| assert( timepoint.minute == rounded_timepoint.minute ); | |
| assert( timepoint.hour == rounded_timepoint.hour ); | |
| // Rounding up to nearest 500 msecs, to the next second | |
| // round(1.650, 0.5) = 2.0 | |
| timepoint = SysTime(DateTime(2012, 11, 7, 6, 59, 1), FracSec.from!"msecs"(650), tz); | |
| rounded_timepoint = roundUpTo(timepoint, dur!"msecs"(500)); | |
| assert( rounded_timepoint.second == 2 ); | |
| assert( rounded_timepoint.fracSec == FracSec.from!"msecs"(0) ); | |
| assert( timepoint.minute == rounded_timepoint.minute ); | |
| assert( timepoint.hour == rounded_timepoint.hour ); | |
| // Rounding up to nearest 500 msecs, to the next .5 of a second | |
| // round(1.123, 0.5) = 1.5 | |
| timepoint = SysTime(DateTime(2012, 11, 7, 6, 59, 1), FracSec.from!"msecs"(123), tz); | |
| rounded_timepoint = roundUpTo(timepoint, dur!"msecs"(500)); | |
| auto fs_ms = rounded_timepoint.fracSec.msecs; | |
| assert( rounded_timepoint.second == 1 ); | |
| assert( rounded_timepoint.fracSec == FracSec.from!"msecs"(500) ); | |
| assert( timepoint.minute == rounded_timepoint.minute ); | |
| assert( timepoint.hour == rounded_timepoint.hour ); | |
| // TODO: condition where it gets rounded into the next minute | |
| // TODO: leap seconds | |
| // TODO: leap years | |
| // TODO: daylight savings rollback | |
| } | |
| /** | |
| * Represents a billable call | |
| */ | |
| class Call { | |
| /** | |
| * A call occurs between two time points, start and end | |
| */ | |
| this( SysTime start, SysTime end ) | |
| in { | |
| assert( start <= end ); | |
| } | |
| body { | |
| m_start = start; | |
| m_end = end; | |
| } | |
| CallCost rate( Billing billing, BreakdownOptions options ) { | |
| Duration peak = dur!"seconds"(0); | |
| Duration offpeak = dur!"seconds"(0); | |
| Duration weekend = dur!"seconds"(0); | |
| // The Billing increment will round the end of the call upto N seconds. | |
| // e.g. a 15 second call, with 6 second increment, will be rounded up | |
| // to 18 seconds in total. | |
| auto current = m_start; | |
| auto effective_end = roundUpTo(m_end, billing.increment); | |
| while( current < effective_end ) { | |
| auto next = options.nextTodSwitch(current); | |
| if( next > effective_end ) { | |
| next = effective_end; | |
| } | |
| Duration seconds = (next - current); | |
| if( options.isWeekend(current) ) { | |
| weekend += seconds; | |
| } | |
| else if( options.isPeak(current) ) { | |
| peak += seconds; | |
| } | |
| else { | |
| offpeak += seconds; | |
| } | |
| current = next; | |
| } | |
| return new CallCost(new Breakdown(peak, offpeak, weekend), billing); | |
| } | |
| /** | |
| * Duration of call, in seconds | |
| */ | |
| Duration duration() { | |
| return m_end - m_start; | |
| } | |
| /** | |
| * Start of Call | |
| */ | |
| SysTime start () { return m_start; } | |
| /** | |
| * End of Call | |
| */ | |
| SysTime end () { return m_end; } | |
| private: | |
| SysTime m_start; | |
| SysTime m_end; | |
| unittest { | |
| auto tz = TimeZone.getTimeZone("America/New_York"); | |
| auto call = new Call( | |
| SysTime(DateTime(2012, 11, 7, 6, 59, 30), tz), | |
| SysTime(DateTime(2012, 11, 7, 7, 0, 30), tz)); | |
| assert( call.duration == dur!"seconds"(60) ); | |
| // TODO: validate validation during leap year | |
| // TODO: validate duration during leap second | |
| // TODO: validate duration during daylight savings rollback | |
| } | |
| } | |
| /** | |
| * The result of Billing applied to a Call | |
| */ | |
| class CallCost { | |
| this( Breakdown breakdown, Billing billing ) { | |
| m_breakdown = breakdown; | |
| m_billing = billing; | |
| m_cost_peak = billing.round(breakdown.peak.total!"msecs"() * (billing.cps_peak / 1000)); | |
| m_cost_offpeak = billing.round(breakdown.offpeak.total!"msecs"() * (billing.cps_offpeak / 1000)); | |
| m_cost_weekend = billing.round(breakdown.weekend.total!"msecs"() * (billing.cps_weekend / 1000)); | |
| m_cost_setup = billing.round(billing.cost_setup); | |
| m_cost = m_cost_peak + m_cost_offpeak + m_cost_weekend + m_cost_setup; | |
| if( m_cost < billing.cost_minimum ) { | |
| m_cost = billing.cost_minimum; | |
| } | |
| } | |
| /** | |
| * Billing information used to rate the call | |
| */ | |
| Billing billing () { return m_billing; } | |
| /** | |
| * Breakdown into number of seconds in peak/offpeak/weekend | |
| */ | |
| Breakdown breakdown () { return m_breakdown; } | |
| /** | |
| * ISO 4217 currency code for costs | |
| */ | |
| char[3] currency () { return m_billing.currency; } | |
| /** | |
| * Total cost of the call | |
| */ | |
| real total () { return m_cost; } | |
| /** | |
| * Cost of 'offpeak' part of call | |
| */ | |
| real peak () { return m_cost_peak; } | |
| /** | |
| * Cost of 'peak' part of call | |
| */ | |
| real offpeak () { return m_cost_offpeak; } | |
| /** | |
| * Cost of 'weekend' part of call | |
| */ | |
| real weekend () { return m_cost_weekend; } | |
| private: | |
| real m_cost; | |
| real m_cost_peak; | |
| real m_cost_offpeak; | |
| real m_cost_weekend; | |
| real m_cost_setup; | |
| Breakdown m_breakdown; | |
| Billing m_billing; | |
| unittest { | |
| auto billing = new Billing(); | |
| billing.currency = "GBP"; | |
| billing.cps_peak = 0.5f; | |
| billing.cps_offpeak = 0.5f; | |
| billing.cps_weekend = 0.5f; | |
| billing.cost_minimum = 0.5f; | |
| billing.cost_setup = 1.0f; | |
| billing.increment = dur!"seconds"(1); | |
| auto tz = TimeZone.getTimeZone("America/New_York"); | |
| auto options = new BreakdownOptions( | |
| TimeOfDay(7, 0), TimeOfDay(20, 0, 0), | |
| [DayOfWeek.sat, DayOfWeek.sun]); | |
| // 60 second call on boundary of offpeak->peak | |
| auto call = new Call( | |
| SysTime(DateTime(2012, 11, 7, 6, 59, 30), tz), | |
| SysTime(DateTime(2012, 11, 7, 7, 0, 30), tz)); | |
| auto cost = call.rate(billing, options); | |
| auto breakdown = cost.breakdown; | |
| auto total_seconds = breakdown.total.seconds; | |
| auto peak_seconds = breakdown.peak.seconds; | |
| auto offpeak_seconds = breakdown.offpeak.seconds; | |
| auto weekend_seconds = breakdown.weekend.seconds; | |
| assert( (breakdown.offpeak.seconds + breakdown.peak.seconds + breakdown.weekend.seconds) == 60 ); | |
| assert( breakdown.total == dur!"seconds"(60) ); | |
| assert( breakdown.offpeak == dur!"seconds"(30) ); | |
| assert( breakdown.peak == dur!"seconds"(30) ); | |
| assert( breakdown.weekend == dur!"seconds"(0) ); | |
| // 60 second call on boundary of peak->offpeak | |
| call = new Call( | |
| SysTime(DateTime(2012, 11, 7, 19, 59, 30), tz), | |
| SysTime(DateTime(2012, 11, 7, 20, 0, 30), tz)); | |
| cost = call.rate(billing, options); | |
| breakdown = cost.breakdown; | |
| assert( breakdown.total == dur!"seconds"(60) ); | |
| assert( breakdown.peak == dur!"seconds"(30) ); | |
| assert( breakdown.offpeak == dur!"seconds"(30) ); | |
| assert( breakdown.weekend == dur!"seconds"(0) ); | |
| // Ensure totals are being calculated correctly | |
| assert( cost.total == 31.0f ); // 'setup cost' | |
| assert( cost.peak == 15.0f ); | |
| assert( cost.offpeak == 15.0f ); | |
| assert( cost.weekend == 0.0f ); | |
| // 60 second call on boundary of offpeak->weekend | |
| // 10th November 2012 is a Saturday | |
| call = new Call( | |
| SysTime(DateTime(2012, 11, 9, 23, 59, 30), tz), | |
| SysTime(DateTime(2012, 11, 10, 0, 0, 30), tz)); | |
| cost = call.rate(billing, options); | |
| breakdown = cost.breakdown; | |
| assert( breakdown.total == dur!"seconds"(60) ); | |
| assert( breakdown.peak == dur!"seconds"(0) ); | |
| assert( breakdown.offpeak == dur!"seconds"(30) ); | |
| assert( breakdown.weekend == dur!"seconds"(30) ); | |
| // 60 second call on boundary of weekend->offpeak | |
| // 11th November 2012 is a Sunday, 12th is Monday | |
| call = new Call( | |
| SysTime(DateTime(2012, 11, 11, 23, 59, 30), tz), | |
| SysTime(DateTime(2012, 11, 12, 0, 0, 30), tz)); | |
| cost = call.rate(billing, options); | |
| breakdown = cost.breakdown; | |
| assert( breakdown.total == dur!"seconds"(60) ); | |
| assert( breakdown.peak == dur!"seconds"(0) ); | |
| assert( breakdown.offpeak == dur!"seconds"(30) ); | |
| assert( breakdown.weekend == dur!"seconds"(30) ); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment