1

I was able to find a script that given a range of start and end dates, it will create new rows based on the range of dates. The problem I am running into is that for each record I have an AMOUNT field I need to properly prorate across the date range.

CREATE TABLE #TempData (Company VARCHAR(6), InvoiceDate DATE, StartPeriod DATE, EndPeriod DATE, SchoolDistrict VARCHAR(100), Amount NUMERIC(10,2))
INSERT INTO #TempData (Company,InvoiceDate,StartPeriod,EndPeriod,SchoolDistrict,Amount)

SELECT '000123','1/1/2016','12/1/2015','12/31/2015','School District 123',140 UNION ALL
SELECT '000123','12/1/2016','6/15/2015','11/30/2015','School District 123',500 

;WITH Recurse AS (
SELECT Company,InvoiceDate, StartPeriod
,CAST(DATEADD(DAY,-1,DATEADD(MONTH,DATEDIFF(MONTH,0,StartPeriod)+1,0)) AS DATE) EOM,EndPeriod
,SchoolDistrict,Amount
FROM #TempData

UNION ALL

SELECT Company,InvoiceDate
,CAST(DATEADD(MONTH,DATEDIFF(MONTH,0,StartPeriod)+1,0) AS DATE) StartPeriod
,CAST(DATEADD(DAY,-1,DATEADD(MONTH,DATEDIFF(MONTH,0,StartPeriod)+2,0)) AS DATE)
,EndPeriod
,SchoolDistrict,Amount
FROM Recurse
WHERE EOM<EndPeriod
)

SELECT Company,InvoiceDate,StartPeriod
,CASE WHEN EndPeriod<EOM THEN EndPeriod ELSE EOM END EndPeriod
,SchoolDistrict,Amount
FROM Recurse

DROP TABLE TempData

My Output looks like this:

Company InvoiceDate StartPeriod EndPeriod  SchoolDistrict       Amount
000123  2016-01-01  2015-12-01  2015-12-31 School District 123  140.00
000123  2016-12-01  2015-06-15  2015-06-30 School District 123  500.00
000123  2016-12-01  2015-07-01  2015-07-31 School District 123  500.00
000123  2016-12-01  2015-08-01  2015-08-31 School District 123  500.00
000123  2016-12-01  2015-09-01  2015-09-30 School District 123  500.00
000123  2016-12-01  2015-10-01  2015-10-31 School District 123  500.00
000123  2016-12-01  2015-11-01  2015-11-30 School District 123  500.00

As for the first record return, no need to do any prorating as it only is for 1 month, but the other records, I am needing assistance on how can I properly prorate the AMOUNT of 500 properly over the 6 records returned.

NOTE Update: On full months are equal distribution,then any StartPeriod and or EndPeriod months that are not full periods, get partial prorate distribution.

jschroedl
  • 4,916
  • 3
  • 31
  • 46

2 Answers2

2

Here is a chain of expression derived from the original input dates and amount. You can readily feed this into your Recurse method although I recommend one of the other methods for generating the months like using a numbers table, especially if the dates can range over many years.

For the partial months it calculates a fraction based on the number of days covered in that month. The divisor is the total number of days in that month. Sometimes accountants treat a month as having 30 days so you'll have to decide if this is appropriate.

The full amount is split across the full months, weighted equally regardless of length, plus the two partials weighted by their individual proportions of their respective months. The full month amount is computed first and that result is rounded; the partial months depend on that calculation and note my comment at the end regarding the consequences of rounding to the penny. The final results need to take some care to distribute the last penny correctly so that the sum is correct.

with Expr1 as (
select *,
    StartPeriod as RangeStart, EndPeriod as RangeEnd,

    case when datediff(month, StartPeriod, EndPeriod) < 1 then null else
        datediff(month, StartPeriod, EndPeriod) + 1
            - case when datepart(day, StartPeriod) <> 1
                   then 1 else 0 end
            - case when month(EndPeriod) = month(dateadd(day, 1, EndPeriod))
                   then 1 else 0 end
    end as WholeMonths,

    case when datepart(day, StartPeriod) <> 1
         then 1 else 0 end as IsPartialStart,
    case when month(EndPeriod) = month(dateadd(day, 1, EndPeriod))
         then 1 else 0 end as IsPartialEnd,

    datepart(day, StartPeriod) as StartPartialComplement,
    datepart(day, EndPeriod) as EndPartialOffset,

    datepart(day,
        dateadd(day, -1, dateadd(month, datediff(month, 0, StartPeriod) + 1, 0))
    ) as StartPartialDaysInMonth,

    datepart(day,
        dateadd(day, -1, dateadd(month, datediff(month, 0, EndPeriod) + 1, 0))
    ) as EndPartialDaysInMonth
from #TempData
),

Expr2 as (
select *,
    case when IsPartialStart = 1
         then StartPartialDaysInMonth - StartPartialComplement + 1
         else 0 end as StartPartialDays,
    case when IsPartialEnd = 1
         then EndPartialOffset else 0 end as EndPartialDays
from Expr1
),

Expr3 as (
select *,
    cast(round(Amount / (
        WholeMonths
            + StartPartialDays / cast(StartPartialDaysInMonth as float)
            + EndPartialDays / cast(EndPartialDaysInMonth as float)
    ), 2) as numeric(10, 2)) as WholeMonthAllocation,
    StartPartialDays / cast(StartPartialDaysInMonth as float) as StartPartialFraction,
    EndPartialDays / cast(EndPartialDaysInMonth as float) as EndPartialFraction
from Expr2
),

Expr4 as (
select *,
    cast(case when IsPartialEnd = 0
         then Amount - WholeMonthAllocation * WholeMonths
         else StartPartialFraction * WholeMonthAllocation
         end as numeric(10, 2)) as StartPartialAmount,
    cast(case when IsPartialEnd = 0 then 0
              else Amount 
                   - WholeMonthAllocation * WholeMonths
                   - StartPartialFraction * WholeMonthAllocation
         end as numeric(10, 2)) as EndPartialAmount
from Expr3
),
...

From those values you can determine which amount should end up in the final result after you've created all the extra rows. This expression will do the trick by incorporating your original query. (Since SQL Fiddle has been down I haven't been able to test any of this:)

... /* all of the above */
Recurse AS (
SELECT
    RangeStart, RangeEnd, IsPartialStart, IsPartialEnd,
    StartPartialAmount, EndPartialAmount, WholeMonthAllocation,
    Company, InvoiceDate, StartPeriod,
    CAST(DATEADD(DAY,-1,DATEADD(MONTH,DATEDIFF(MONTH,0,StartPeriod)+1,0)) AS DATE) EOM,
    EndPeriod, SchoolDistrict,
    case
        when datediff(month, RangeStart, RangeEnd) = 0 then Amount
        when IsPartialStart = 1 then StartPartialAmount
        else WholeMonthAllocation
    end as Amount
FROM Expr4
UNION ALL
SELECT
    RangeStart, RangeEnd, IsPartialStart, IsPartialEnd,
    StartPartialAmount, EndPartialAmount, WholeMonthAllocation,
    Company, InvoiceDate,
    CAST(DATEADD(MONTH,DATEDIFF(MONTH,0,StartPeriod)+1,0) AS DATE) AS StartPeriod,
    CAST(DATEADD(DAY,-1,DATEADD(MONTH,DATEDIFF(MONTH,0,StartPeriod)+2,0)) AS DATE) EOM,
    EndPeriod, SchoolDistrict,
    case
        -- final month is when StartPeriod is one month before RangeEnd.
        -- remember this is recursive!
        when IsPartialEnd = 1 and datediff(month, StartPeriod, RangeEnd) = 1
        then EndPartialAmount
        else WholeMonthAllocation
    end as Amount
FROM Recurse
WHERE EOM < EndPeriod
)
SELECT
    Company, InvoiceDate, StartPeriod,
    CASE WHEN EndPeriod < EOM THEN EndPeriod ELSE EOM END EndPeriod,
    SchoolDistrict, Amount
FROM Recurse  

I've added/aliased RangeStart and RangeEnd values to avoid confusion with StartPeriod and EndPeriod which you're using in both your temp table and output query. The Range- values represent the start and end of the full span and the Period- values are the computed values that break out the individual periods. Adapt as you see fit.

Edit #1: I realized that I had not handled the case where start and end fall in the same month: perhaps there's a cleaner way to do this whole thing. I just ended up nulling the WholeMonths expression to avoid a possible divide by zero. The case expression at the end catches this condition and just returns the original Amount value. Although you probably don't have to worry about dealing with start and end dates getting reversed I went ahead and roped them all together with the same < 1 test.

Edit #2: Once I had a place to try this out your test case showed that the rounding was losing a penny and was getting picked up by the final partial month calculation even when it was actually one of the whole months. So I had to adjust to look for the case where there is no final partial month. That's in Expr4. I also spotted several of the minor syntax errors that you noted.

The recursive query allows for seeing the months in order and simplifies the logic a little bit. The anchor is always going to be the start month and so none of the final month logic applies and similarly for the other half of the query. If you end up switching this out with a regular join against a numbers table you'd want to use an expression like this instead:

case
    when datediff(month, RangeStart, RangeEnd) = 0
    then Amount
    when IsPartialStart = 1 and is first month...
    then StartPartialAmount
    when IsPartialEnd = 1 and is final month...
    then EndPartialAmount
    else WholeMonthAllocation
end as Amount

Edit #3: Also be aware that this method is not appropriate when dealing with very small amounts where the rounding is going to skew the results. Examples:

$0.13 divided January 02 to December 01 gives [.01, .01, .01, .01, .01, .01, .01, .01, .01, .01, .01, .02] $0.08 divided January 02 to December 01 gives [.01, .01, .01, .01, .01, .01, .01, .01, .01, .01, .01, -.03] $0.08 divided January 31 to December 31 gives [-.03, .01, .01, .01, .01, .01, .01, .01, .01, .01, .01, .01] $0.05 divided January 31 to November 30 gives [.05, .00, .00, .00, .00, .00, .00, .00, .00, .00, .00] $0.05 divided January 31 to December 01 gives [.00, .00, .00, .00, .00, .00, .00, .00, .00, .00, .00, .05] $0.30 divided January 02 to March 1 gives [.15, .15, .00]

shawnt00
  • 16,443
  • 3
  • 17
  • 22
  • Thank you for providing this. This is going to take me a bit to figure out what you have. But when you say 'You can feed this into your Recurse method unless there's a limitation on recursive CTEs I'm not aware of. ' I am not sure how your code is inserted into my code. – Chuck Bernardes Mar 03 '16 at 20:00
  • I tried to add your additional information, but I am still running into problems. A few bug issues initially existed but I was able to figure it out. The part I am running into is when you add the CTE Recurse from my original post, the UNION ALL is causing me some issues due columns not equal so I cant properly figure this out how it will work. Thanks for what you have provide so far. – Chuck Bernardes Mar 04 '16 at 16:51
  • @Chuck I'm actually working on it right now and have a working version. – shawnt00 Mar 04 '16 at 16:52
  • @Chuck I posted my update but in case something got out of sync you can see the working code here: http://rextester.com/POVKY9715 – shawnt00 Mar 04 '16 at 17:01
  • 1
    Great job in clarifying the code and gotchas. This will help out immensely. – Chuck Bernardes Mar 04 '16 at 19:27
  • Interesting problem with distribution of small amounts that ends up with -$0.03 in last month. I think my answer has the same problem. Would it be better if all amounts were always rounded down, and then the last month payment would be higher to correct the total? – John Rees Mar 05 '16 at 01:27
  • @John Rounding down probably keeps all the values positive. It's not a bad idea though. I think you might find a scenario where a partial month gets a larger value than the rest though. – shawnt00 Mar 05 '16 at 04:09
1

It's an interesting problem because it requires both expanding the number of rows, and because of rounding problems that can be detected and corrected within the query.

First some fiddly date calcs are required to work out how many days in each month fall within the StartPeriod and EndPeriod.

Then an Estimate is calculated for each month as a simple proportion, but rounding errors will mean that the sum of these Estimates does not add up to the total Invoice Amount. Window functions are then used to calculated the total rounding error, so that the last payment can be adjusted.

As an aside, instead of generating a row for each month by using a recursive CTE, I recommend using a join with a simple "numbers" view. For more info see this question about number tables

-- I use the #tempdata table mentioned in the question

; WITH numbers AS ( -- A fast way to get a sequence of integers starting at 0
    SELECT TOP(10000) ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) - 1 as n
    FROM sys.all_columns a CROSS JOIN sys.all_columns b
),
data_with_pk AS ( -- Add a primary key so that we know how to sort output
    SELECT ROW_NUMBER() OVER (ORDER BY company, invoicedate) AS InvoiceId, *
    FROM #tempdata
),
step1 AS ( -- Calc first and last day of each month in which payment is due
    SELECT data_with_pk.*,
        CAST(DATEADD(MONTH, DATEDIFF(MONTH, 0, StartPeriod) + numbers.n, 0) 
             AS DATE) AS StartOfMonth,
        CAST(DATEADD(DAY, -1,  
                     DATEADD(MONTH, DATEDIFF(MONTH,0,StartPeriod) + numbers.n + 1, 0)) 
             AS DATE) AS EndOfMonth
    FROM data_with_pk
    -- This join is a simpler way to generate multiple rows than using a recursive CTE
    JOIN numbers ON numbers.n <= DATEDIFF(MONTH, StartPeriod, EndPeriod)
),
step2 AS ( -- Calc block of days in each month which fall within whole period 
    SELECT *,
        CASE WHEN StartPeriod > StartOfMonth THEN StartPeriod ELSE StartOfMonth END 
            AS StartOfBlock,
        CASE WHEN EndPeriod < EndOfMonth THEN EndPeriod ELSE EndOfMonth END 
            AS EndOfBlock
    FROM step1
), 
step3 AS ( -- Whole months count as 30 days for purposes of calculated proportions 
    SELECT *, 
        CASE WHEN StartOfBlock = StartOfMonth AND EndOfBlock = EndOfMonth 
             THEN 30
             ELSE DATEDIFF(DAY, StartOfBlock, EndOfBlock) + 1 END AS DaysInBlock
    FROM step2
), 
step3b AS (
    SELECT *,
        SUM(DaysInBlock) OVER (PARTITION BY InvoiceId) AS DaysInPeriod
    FROM step3
),
step4 AS ( -- Calc proportion of whole amount due in this block
    SELECT *, 
        CAST(Amount * DaysInBlock / DaysInPeriod AS NUMERIC(10,2)) AS Estimate
    FROM step3b
), 
step5 AS ( -- Calc running total of estimates
    SELECT *, 
      SUM(Estimate) OVER (PARTITION BY InvoiceId ORDER BY EndOfBlock) AS RunningEstimate
    FROM step4
), 
step6 AS ( -- Adjust last estimate to ensure final Prorata total is equal to Amount
    SELECT *, 
        CASE WHEN EndOfBlock = EndPeriod 
             THEN Estimate + amount - RunningEstimate 
             ELSE Estimate end AS Prorata
    FROM step5
),
step7 AS ( -- Just for illustration to prove that payments sum to the Invoice Amount
    SELECT *, 
        SUM(Prorata) OVER (PARTITION BY InvoiceId ORDER BY EndOfBlock) AS RunningProrata
    FROM step6
)
SELECT InvoiceId, InvoiceDate, StartPeriod, EndPeriod, Amount, DaysInBlock, EndOfBlock, 
    Estimate, RunningEstimate, Prorata, RunningProrata
FROM step7
ORDER BY InvoiceId, EndOfBlock

You can see the "Estimate" and "RunningEstimate" columns in the result set below end up being $0.01 out, but are corrected in the "Prorata" column.

+-----------+-------------+-------------+------------+--------+-------------+------------+----------+-----------------+---------+----------------+
| InvoiceId | InvoiceDate | StartPeriod | EndPeriod  | Amount | DaysInBlock | EndOfBlock | Estimate | RunningEstimate | Prorata | RunningProrata |
+-----------+-------------+-------------+------------+--------+-------------+------------+----------+-----------------+---------+----------------+
|         1 | 2016-01-01  | 2015-12-01  | 2015-12-31 | 140.00 |          30 | 2015-12-31 | 140.00   | 140.00          | 140.00  | 140.00         |
|         2 | 2016-12-01  | 2015-06-15  | 2015-11-30 | 500.00 |          16 | 2015-06-30 | 48.19    | 48.19           | 48.19   | 48.19          |
|         2 | 2016-12-01  | 2015-06-15  | 2015-11-30 | 500.00 |          30 | 2015-07-31 | 90.36    | 138.55          | 90.36   | 138.55         |
|         2 | 2016-12-01  | 2015-06-15  | 2015-11-30 | 500.00 |          30 | 2015-08-31 | 90.36    | 228.91          | 90.36   | 228.91         |
|         2 | 2016-12-01  | 2015-06-15  | 2015-11-30 | 500.00 |          30 | 2015-09-30 | 90.36    | 319.27          | 90.36   | 319.27         |
|         2 | 2016-12-01  | 2015-06-15  | 2015-11-30 | 500.00 |          30 | 2015-10-31 | 90.36    | 409.63          | 90.36   | 409.63         |
|         2 | 2016-12-01  | 2015-06-15  | 2015-11-30 | 500.00 |          30 | 2015-11-30 | 90.36    | 499.99          | 90.37   | 500.00         |
+-----------+-------------+-------------+------------+--------+-------------+------------+----------+-----------------+---------+----------------+
Community
  • 1
  • 1
John Rees
  • 1,553
  • 17
  • 24
  • Just saw your comment that full months should get equal distribution but my logic uses actual days in each month. I'll try to adjust if I have time. – John Rees Mar 04 '16 at 06:38
  • thank you for submission. I would be interested in seeing how it works out with the equal distribution logic on full months when you have time to modify it. – Chuck Bernardes Mar 04 '16 at 16:52
  • there would be also prorata distribution on partial months in the case where the startperiod is 6/15/15 and the endperiod is 11/18/15. I will look forward for your modification when you have time. – Chuck Bernardes Mar 04 '16 at 17:17
  • @Chuck I've changed it so that full months have equal distribution, and partial months are calculated correctly. Was a smaller change than I expected. – John Rees Mar 05 '16 at 01:16
  • thank you for your modification. I wish I can accept both answers. I chose his as I was able to use it right away. But yours would work just as well but with a different approach. I appreciate your followup. I did give you an up vote too. – Chuck Bernardes Mar 07 '16 at 14:17