Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions bc_obps/compliance/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,6 @@
from .elicensing_invoices import get_elicensing_invoice_list
from .compliance_period import get_compliance_period_for_year
from . import e2e_integration_stub
from ._compliance_report_versions._compliance_report_version_id._obligation.calculate_penalty import (
get_calculated_penalty_for_obligation,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
from typing import Literal, Tuple
from datetime import datetime, timedelta
from django.http import HttpRequest
from compliance.constants import COMPLIANCE
from service.error_service.custom_codes_4xx import custom_codes_4xx
from registration.schema.generic import Message
from compliance.api.router import router
from compliance.schema.calculated_penalty import CalculatedPenaltyOut
from compliance.models import CompliancePenalty, ComplianceObligation
from compliance.service.penalty_calculation_service import PenaltyCalculationService
from compliance.api.permissions import approved_authorized_roles_compliance_report_version_composite_auth


@router.get(
"/compliance-report-versions/{compliance_report_version_id}/obligation/calculate-penalty",
response={200: CalculatedPenaltyOut, custom_codes_4xx: Message},
tags=COMPLIANCE,
description="Calculate the potential penalty for an obligation that is accruing a penalty",
auth=approved_authorized_roles_compliance_report_version_composite_auth,
)
def get_calculated_penalty_for_obligation(
request: HttpRequest, compliance_report_version_id: int, penalty_type: str, end_date: str
) -> Tuple[Literal[200], CalculatedPenaltyOut]:
date_format_string = "%Y-%m-%d"
formatted_end_date = datetime.strptime(end_date, date_format_string).date()
obligation = ComplianceObligation.objects.get(compliance_report_version_id=compliance_report_version_id)
compliance_deadline = obligation.compliance_report_version.compliance_report.compliance_period.compliance_deadline
start_date = compliance_deadline + timedelta(days=1)

if penalty_type == CompliancePenalty.PenaltyType.AUTOMATIC_OVERDUE:
# Automatic Overdue Penalty begins accruing 1 day after the compliance deadline unless it is a supplementary report that came in after the deadline.
# In that case, the Automatic Overdue Penalty begins accruing 1 day after the invoice due date
if obligation.compliance_report_version.is_supplementary and obligation.created_at.date() > compliance_deadline:
start_date = datetime.strptime(
obligation.elicensing_invoice.due_date, date_format_string
).date() + timedelta(days=1)
calculated_penalty = PenaltyCalculationService.calculate_penalty(
obligation=obligation, accrual_start_date=start_date, final_accrual_date=formatted_end_date
)
elif penalty_type == CompliancePenalty.PenaltyType.LATE_SUBMISSION:
start_date = datetime.strptime(
obligation.compliance_report_version.compliance_report.compliance_period.compliance_deadline,
date_format_string,
).date() + timedelta(days=1)
calculated_penalty = PenaltyCalculationService.calculate_penalty(
obligation=obligation, accrual_start_date=start_date, final_accrual_date=formatted_end_date
)

response = calculated_penalty

return 200, response
23 changes: 23 additions & 0 deletions bc_obps/compliance/schema/calculated_penalty.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from decimal import Decimal
from ninja import Schema
from compliance.models import CompliancePenalty


class PenaltyAccrual(Schema):
date: str
interest_rate: Decimal
daily_penalty: Decimal
daily_compounded: Decimal
accumulated_penalty: Decimal
accumulated_compounded: Decimal


class CalculatedPenaltyOut(Schema):
"""
Schema for a calculated penalty for an obligation that has not yet been met returned from the API.
"""

penalty_type: CompliancePenalty.PenaltyType
days_late: int
total_penalty: Decimal
daily_accumulated_list: list[PenaltyAccrual]
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
"use client";

import ComplianceStepButtons from "@/compliance/src/app/components/ComplianceStepButtons";
import { useState, useEffect } from "react";
import { ComplianceSummary } from "@/compliance/src/app/types";
import { PenaltyStatus } from "@bciers/utils/src/enums";
import dayjs, { Dayjs } from "dayjs";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
import PenaltyAccrualTable from "./PenaltyAccrualTable";

interface Props {
data: ComplianceSummary;
complianceReportVersionId: number;
calculatePenalty: (
penaltyType: string,
endDate: string,
) => any;
}

export function InternalCalculatePenaltyComponent({
data,
complianceReportVersionId,
calculatePenalty,
}: Readonly<Props>) {
const backUrl = "/compliance-administration/compliance-summaries";
const today = new Date().toLocaleDateString("en-CA");
const [endDate, setEndDate] = useState(today);
const [penaltyData, setPenaltyData] = useState({});

useEffect(() => {
async function fetchPenaltyData(){
const result = await calculatePenalty("Automatic Overdue", endDate)
setPenaltyData(result)
}

fetchPenaltyData()
}, [endDate]);

const handleChange = async (d: Dayjs) => {
setEndDate(d.utc().format("YYYY-MM-DD"));
};

const {
reporting_year: reportingYear,

Check warning on line 46 in bciers/apps/compliance/src/app/components/compliance-summary/manage-obligation/internal/calculate-accruing-penalty/InternalCalculatePenaltyComponent.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this useless assignment to variable "reportingYear".

See more on https://sonarcloud.io/project/issues?id=bcgov_cas-registration&issues=AZ76zIc8ydy8BW0qRl3H&open=AZ76zIc8ydy8BW0qRl3H&pullRequest=4863
has_late_submission_penalty: hasLateSubmissionPenalty,
penalty_status: penaltyStatus,
outstanding_balance_tco2e: outstandingBalance,
} = data;

const showPenalty =
Number(outstandingBalance) === 0 &&
[PenaltyStatus.NOT_PAID, PenaltyStatus.PAID].includes(
penaltyStatus as PenaltyStatus,
);

const continueUrl = hasLateSubmissionPenalty
? `/compliance-administration/compliance-summaries/${complianceReportVersionId}/review-interest-summary`
: showPenalty
? `/compliance-administration/compliance-summaries/${complianceReportVersionId}/review-penalty-summary`
: undefined;

Check warning on line 62 in bciers/apps/compliance/src/app/components/compliance-summary/manage-obligation/internal/calculate-accruing-penalty/InternalCalculatePenaltyComponent.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=bcgov_cas-registration&issues=AZ76zIc8ydy8BW0qRl3I&open=AZ76zIc8ydy8BW0qRl3I&pullRequest=4863

const currencyFormatter = new Intl.NumberFormat('en-US', {
style: 'decimal',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});

return (
<>
<div>
<h1>PENALTY CALCULATOR</h1>
<h4>Pick a date for the last day of the penalty</h4>
</div>
<div>
<LocalizationProvider dateAdapter={AdapterDayjs}>
<DatePicker
value={dayjs(endDate)}
onChange={handleChange}
format="YYYY-MM-DD"
/>
</LocalizationProvider>
</div>
<hr/>
<div>
<hr/>
<p><strong>Penalty Amount:</strong> ${currencyFormatter.format(penaltyData?.total_penalty)}</p>
<p><strong>Days Late:</strong> {penaltyData?.days_late}</p>
<hr/>
</div>
<hr/>
{penaltyData?.daily_accumulated_list && (
<div>
<h3>Accrual Data</h3>
<PenaltyAccrualTable accrualData={penaltyData?.daily_accumulated_list}/>
</div>
)}
<ComplianceStepButtons backUrl={backUrl} continueUrl={continueUrl} />
</>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import {
ActivePage,
generateReviewObligationPenaltyTaskList,
} from "@/compliance/src/app/components/taskLists/internal/reviewObligationPenaltyTaskList";
import { InternalCalculatePenaltyComponent } from "@/compliance/src/app/components/compliance-summary/manage-obligation/internal/calculate-accruing-penalty/InternalCalculatePenaltyComponent";
import CompliancePageLayout from "@/compliance/src/app/components/layout/CompliancePageLayout";
import {
ComplianceSummary,
HasComplianceReportVersion,
} from "@/compliance/src/app/types";
import { getComplianceSummary } from "@/compliance/src/app/utils/getComplianceSummary";
import { getPenaltyAccrualCalculationData } from "@/compliance/src/app/utils/getPenaltyAccrualCalculationData";

export default async function InternalCalculatePenaltyPage({
compliance_report_version_id: complianceReportVersionId,
}: Readonly<HasComplianceReportVersion>) {
const complianceSummaryReviewPageData: ComplianceSummary =
await getComplianceSummary(complianceReportVersionId);

const {
reporting_year: reportingYear,
has_late_submission_penalty: hasLateSubmissionPenalty,
has_overdue_penalty: hasOverduePenalty,
penalty_status: penaltyStatus,
outstanding_balance_tco2e: outstandingBalance,
} = complianceSummaryReviewPageData;

const taskListElements = generateReviewObligationPenaltyTaskList(
complianceReportVersionId,
{
reportingYear,
penaltyStatus,
outstandingBalance,
hasLateSubmissionPenalty,
hasOverduePenalty,
},
ActivePage.CalculateAccruingPenalty,
);

async function calculatePenalty(penaltyType: string, endDate: string) {
"use server";
const params = {
compliance_report_version_id: complianceReportVersionId,
penalty_type: penaltyType,
end_date: endDate,
};
const penaltyCalculation = await getPenaltyAccrualCalculationData(
complianceReportVersionId,
params,
);
return penaltyCalculation;
}

return (
<CompliancePageLayout
taskListElements={taskListElements}
complianceReportVersionId={complianceReportVersionId}
>
<InternalCalculatePenaltyComponent
data={complianceSummaryReviewPageData}
complianceReportVersionId={complianceReportVersionId}
calculatePenalty={calculatePenalty}
/>
</CompliancePageLayout>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import * as React from "react";
import Paper from "@mui/material/Paper";
import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
import TableCell from "@mui/material/TableCell";
import TableContainer from "@mui/material/TableContainer";
import TableHead from "@mui/material/TableHead";
import TablePagination from "@mui/material/TablePagination";
import TableRow from "@mui/material/TableRow";

interface Column {
id:
| "date"
| "daily_penalty"
| "daily_compounded"
| "accumulated_penalty"
| "accumulated_compounded"
| "interest_rate";
label: string;
minWidth?: number;
align?: "right";
format?: (value: number) => string;
}

const columns: readonly Column[] = [
{ id: "date", label: "Date", minWidth: 170 },
{ id: "daily_penalty", label: "Daily Penalty", minWidth: 100 },
{
id: "daily_compounded",
label: "Daily Compounded",
minWidth: 170,
align: "right",
},
{
id: "accumulated_penalty",
label: "Accumulated Penalty",
minWidth: 170,
align: "right",
},
{
id: "accumulated_compounded",
label: "Accumulated Compounded",
minWidth: 170,
align: "right",
},
{
id: "interest_rate",
label: "Interest Rate",
minWidth: 170,
align: "right",
},
];

interface Data {
date: string;
daily_penalty: number;
daily_compounded: number;
accumulated_penalty: number;
accumulated_compounded: number;
interest_rate: number;
}

export default function PenaltyAccrualTable(data: any) {
const { accrualData } = data;
const [page, setPage] = React.useState(0);
const [rowsPerPage, setRowsPerPage] = React.useState(100);

const handleChangePage = (event: unknown, newPage: number) => {
setPage(newPage);
};

const handleChangeRowsPerPage = (
event: React.ChangeEvent<HTMLInputElement>,
) => {
setRowsPerPage(+event.target.value);
setPage(0);
};
if (!accrualData || !Array.isArray(accrualData)) return null;
return (
<Paper sx={{ width: "100%", overflow: "hidden" }}>
<TableContainer sx={{ maxHeight: 440 }}>
<Table stickyHeader aria-label="sticky table">
<TableHead>
<TableRow>
{columns.map((column) => (
<TableCell
key={column.id}
align={column.align}
style={{ minWidth: column.minWidth }}
>
{column.label}
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{accrualData
.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage)
.map((row) => {
return (
<TableRow hover role="checkbox" tabIndex={-1} key={row.code}>
{columns.map((column) => {
const value = row[column.id];
return (
<TableCell key={column.id} align={column.align}>
{column.format && typeof value === "number"
? column.format(value)
: value}
</TableCell>
);
})}
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
<TablePagination
rowsPerPageOptions={[10, 25, 100, 500]}
component="div"
count={accrualData.length}
rowsPerPage={rowsPerPage}
page={page}
onPageChange={handleChangePage}
onRowsPerPageChange={handleChangeRowsPerPage}
/>
</Paper>
);
}
Loading
Loading