diff --git a/.github/workflows/build-release.yaml b/.github/workflows/build-release.yaml new file mode 100644 index 00000000..9530ace8 --- /dev/null +++ b/.github/workflows/build-release.yaml @@ -0,0 +1,119 @@ +## For each release, the value of workflow name, branches and PR_NUMBER need to be adjusted accordingly + +name: CI/CD ITVR release-1.10.0 + +on: + push: + branches: [ release-1.10.0 ] + workflow_dispatch: + workflow_call: + +env: + ## The pull request number of the Tracking pull request to merge the release branch to main + PR_NUMBER: 270 + RELEASE_NAME: release-1.10.0 + +jobs: + + ## This is the CI job + build: + + name: Build ITVR on Openshift + runs-on: ubuntu-latest + timeout-minutes: 60 + + steps: + + ## it will checkout to /home/runner/work/itvr/itvr + - name: Check out repository + uses: actions/checkout@v2 + + ## Log in to Openshift with a token of service account + - name: Log in to Openshift + uses: redhat-actions/oc-login@v1 + with: + openshift_server_url: ${{ secrets.OPENSHIFT_SERVER }} + openshift_token: ${{ secrets.OPENSHIFT_TOKEN }} + insecure_skip_tls_verify: true + namespace: ${{ secrets.OPENSHIFT_NAMESPACE_PLATE }}-tools + + ## Run build on Openshift + - name: Run build + run: | + cd .pipeline + npm install + npm run build -- --pr=${{ env.PR_NUMBER }} --env=build + + deploy-on-test: + + name: Deploy ITVR on Test Environment + runs-on: ubuntu-latest + timeout-minutes: 240 + needs: build + + steps: + + ## it will checkout to /home/runner/work/itvr/itvr + - name: Check out repository + uses: actions/checkout@v2 + + - name: Log in to Openshift + uses: redhat-actions/oc-login@v1 + with: + openshift_server_url: ${{ secrets.OPENSHIFT_SERVER }} + openshift_token: ${{ secrets.OPENSHIFT_TOKEN }} + insecure_skip_tls_verify: true + namespace: ${{ secrets.OPENSHIFT_NAMESPACE_PLATE }}-tools + + - name: Ask for approval for ITVR Test deployment + uses: trstringer/manual-approval@v1.6.0 + with: + secret: ${{ github.TOKEN }} + approvers: AlexZorkin,emi-hi,tim738745,vibhiquartech,kuanfandevops + minimum-approvals: 1 + issue-title: "ITVR ${{ env.RELEASE_NAME }} Test Deployment" + timeout-minutes: 240 + + - name: Run BCDK deployment on ITVR Test environment + run: | + cd .pipeline + echo "Deploying ITVR ${{ env.RELEASE_NAME }} on Test" + npm install + npm run deploy -- --pr=${{ env.PR_NUMBER }} --env=test + + deploy-on-prod: + + name: Deploy ITVR on Prod Environment + runs-on: ubuntu-latest + timeout-minutes: 2880 + needs: deploy-on-test + + steps: + + ## it will checkout to /home/runner/work/itvr/itvr + - name: Check out repository + uses: actions/checkout@v2 + + - name: Log in to Openshift + uses: redhat-actions/oc-login@v1 + with: + openshift_server_url: ${{ secrets.OPENSHIFT_SERVER }} + openshift_token: ${{ secrets.OPENSHIFT_TOKEN }} + insecure_skip_tls_verify: true + namespace: ${{ secrets.OPENSHIFT_NAMESPACE_PLATE }}-tools + + - name: Ask for approval for ITVR Prod deployment + uses: trstringer/manual-approval@v1.6.0 + with: + secret: ${{ github.TOKEN }} + approvers: AlexZorkin,kuanfandevops,tim738745 + minimum-approvals: 2 + issue-title: "ITVR ${{ env.RELEASE_NAME }} Prod Deployment" + timeout-minutes: 2880 + + - name: Run BCDK deployment on ITVR Test environment + run: | + cd .pipeline + echo "Deploying ITVR ${{ env.RELEASE_NAME }} on Prod" + npm install + npm run deploy -- --pr=${{ env.PR_NUMBER }} --env=prod \ No newline at end of file diff --git a/.pipeline/lib/config.js b/.pipeline/lib/config.js index 5f6d44c2..a675f600 100644 --- a/.pipeline/lib/config.js +++ b/.pipeline/lib/config.js @@ -1,7 +1,7 @@ 'use strict'; const options= require('@bcgov/pipeline-cli').Util.parseArguments() const changeId = options.pr //aka pull-request -const version = '1.0.0' +const version = '1.10.0' const name = 'itvr' const ocpName = 'apps.silver.devops' @@ -27,6 +27,9 @@ Set the limit as two times of request electric-vehicle-rebates.gov.bc.ca */ options.git.owner='bcgov' +//Have to set options.git.repository to be itvr otherwise an error will be thrown as the label github-repo +//will contain https://github.com/bcgov/itvr which is not allowed as a valid label +options.git.repository='itvr' const phases = { diff --git a/README.md b/README.md index e66d4a01..778a99f2 100644 --- a/README.md +++ b/README.md @@ -155,3 +155,7 @@ python manage.py test or to run specific test files, point to the folder or file python manage.py test api.services.tests.test_calculate_rebate + +### Scheduled Jobs + +Currently, when the task-queue application starts, it creates scheduled jobs only if those jobs don't already exist in the database. This means that if some aspects of a job are changed (e.g. its arguments concerning timeout time, etc), one has to delete the job first in the admin console before deploying, or update the job manually in the admin console after deploying. This is an open issue, see: https://apps.nrs.gov.bc.ca/int/jira/browse/ZELDA-436 diff --git a/django/api/admin.py b/django/api/admin.py index 5fdd5667..a067f712 100644 --- a/django/api/admin.py +++ b/django/api/admin.py @@ -1,6 +1,7 @@ from django.contrib import admin from .models.go_electric_rebate_application import ( GoElectricRebateApplication, + GoElectricRebateApplicationWithFailedEmail, SearchableGoElectricRebateApplication, SubmittedGoElectricRebateApplication, CancellableGoElectricRebateApplication, @@ -84,6 +85,8 @@ class SubmittedGoElectricRebateApplicationAdmin(admin.ModelAdmin): "consent_personal", "consent_tax", "is_legacy", + "confirmation_email_success", + "spouse_email_success", ) def get_queryset(self, request): @@ -159,6 +162,8 @@ class CancellableGoElectricRebateApplicationAdmin(admin.ModelAdmin): "date_of_birth", "tax_year", "is_legacy", + "confirmation_email_success", + "spouse_email_success", ) def get_queryset(self, request): @@ -223,6 +228,8 @@ class SearchableGoElectricRebateApplicationAdmin(admin.ModelAdmin): "consent_personal", "consent_tax", "is_legacy", + "confirmation_email_success", + "spouse_email_success", ) def get_queryset(self, request): @@ -233,3 +240,46 @@ def has_delete_permission(self, request, obj=None): def get_inlines(self, request, obj=None): return get_inlines(obj) + + +@admin.register(GoElectricRebateApplicationWithFailedEmail) +class GoElectricRebateApplicationWithFailedEmailAdmin(admin.ModelAdmin): + actions = None + search_fields = ["drivers_licence", "id", "status"] + exclude = ( + "sin", + "doc1", + "doc2", + "user", + "tax_year", + "consent_personal", + "consent_tax", + "is_legacy", + ) + readonly_fields = ( + "id", + "last_name", + "first_name", + "middle_names", + "status", + "email", + "user_is_bcsc", + "address", + "city", + "postal_code", + "drivers_licence", + "date_of_birth", + "application_type", + "doc1_tag", + "doc2_tag", + "confirmation_email_success", + "spouse_email_success", + ) + + def get_queryset(self, request): + return GoElectricRebateApplication.objects.filter( + Q(confirmation_email_success=False) | Q(spouse_email_success=False) + ) + + def has_delete_permission(self, request, obj=None): + return False diff --git a/django/api/apps.py b/django/api/apps.py index c4df6c00..a70b286d 100644 --- a/django/api/apps.py +++ b/django/api/apps.py @@ -16,7 +16,6 @@ def ready(self): schedule_get_ncda_redeemed_rebates, schedule_cancel_untouched_household_applications, schedule_expire_expired_applications, - schedule_send_mass_approval_email_once, ) if settings.RUN_JOBS and "qcluster" in sys.argv: @@ -24,7 +23,6 @@ def ready(self): schedule_get_ncda_redeemed_rebates() schedule_cancel_untouched_household_applications() schedule_expire_expired_applications() - schedule_send_mass_approval_email_once() class ITVRAdminConfig(AdminConfig): diff --git a/django/api/constants.py b/django/api/constants.py new file mode 100644 index 00000000..e6900951 --- /dev/null +++ b/django/api/constants.py @@ -0,0 +1,28 @@ +from enum import Enum + +# for each income tested maximum rebate ($4000, $2000, $1000), there are different rebate levels for certain ZEV types and lease terms +class FOUR_THOUSAND_REBATE(Enum): + ZEV_MAX = 4000 + ZEV_MID = 2688 + ZEV_MIN = 1332 + PHEV_MAX = 2000 + PHEV_MID = 1334 + PHEV_MIN = 666 + + +class TWO_THOUSAND_REBATE(Enum): + ZEV_MAX = 2000 + ZEV_MID = 1334 + ZEV_MIN = 666 + PHEV_MAX = 1000 + PHEV_MID = 667 + PHEV_MIN = 333 + + +class ONE_THOUSAND_REBATE(Enum): + ZEV_MAX = 1000 + ZEV_MID = 667 + ZEV_MIN = 333 + PHEV_MAX = 500 + PHEV_MID = 334 + PHEV_MIN = 167 diff --git a/django/api/hooks.py b/django/api/hooks.py new file mode 100644 index 00000000..9bba6284 --- /dev/null +++ b/django/api/hooks.py @@ -0,0 +1,18 @@ +# django_q hooks + + +from api.models.go_electric_rebate_application import GoElectricRebateApplication + + +def set_email_status(task): + func = task.func + application_id = task.args[1] + email_successful = task.success + if func == "api.tasks.send_individual_confirm": + GoElectricRebateApplication.objects.filter(pk=application_id).update( + confirmation_email_success=email_successful + ) + elif task.func == "api.tasks.send_spouse_initial_message": + GoElectricRebateApplication.objects.filter(pk=application_id).update( + spouse_email_success=email_successful + ) diff --git a/django/api/migrations/0011_goelectricrebateapplication_approval_email_sent.py b/django/api/migrations/0011_goelectricrebateapplication_approval_email_sent.py new file mode 100644 index 00000000..ed0dcb62 --- /dev/null +++ b/django/api/migrations/0011_goelectricrebateapplication_approval_email_sent.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0.1 on 2022-08-25 17:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0010_alter_cancellablegoelectricrebateapplication_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='goelectricrebateapplication', + name='approval_email_sent', + field=models.BooleanField(null=True), + ), + ] diff --git a/django/api/migrations/0012_goelectricrebateapplicationwithfailedemail_and_more.py b/django/api/migrations/0012_goelectricrebateapplicationwithfailedemail_and_more.py new file mode 100644 index 00000000..1271ac59 --- /dev/null +++ b/django/api/migrations/0012_goelectricrebateapplicationwithfailedemail_and_more.py @@ -0,0 +1,39 @@ +# Generated by Django 4.0.1 on 2022-09-06 17:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0011_goelectricrebateapplication_approval_email_sent'), + ] + + operations = [ + migrations.CreateModel( + name='GoElectricRebateApplicationWithFailedEmail', + fields=[ + ], + options={ + 'ordering': ['-created'], + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('api.goelectricrebateapplication',), + ), + migrations.RemoveField( + model_name='goelectricrebateapplication', + name='approval_email_sent', + ), + migrations.AddField( + model_name='goelectricrebateapplication', + name='confirmation_email_success', + field=models.BooleanField(null=True), + ), + migrations.AddField( + model_name='goelectricrebateapplication', + name='spouse_email_success', + field=models.BooleanField(null=True), + ), + ] diff --git a/django/api/models/go_electric_rebate_application.py b/django/api/models/go_electric_rebate_application.py index de782aba..b4d2d8d7 100644 --- a/django/api/models/go_electric_rebate_application.py +++ b/django/api/models/go_electric_rebate_application.py @@ -81,6 +81,8 @@ class Status(TextChoices): ) date_of_birth = DateField(validators=[validate_driving_age], null=True) tax_year = IntegerField(null=True) + confirmation_email_success = BooleanField(null=True) + spouse_email_success = BooleanField(null=True) doc1 = ImageField( upload_to="docs", blank=True, @@ -249,3 +251,17 @@ def admin_label(cls): @classproperty def admin_hide_view_change_buttons(cls): return True + + +class GoElectricRebateApplicationWithFailedEmail(GoElectricRebateApplication): + class Meta: + proxy = True + ordering = ["-created"] + + @classproperty + def admin_label(cls): + return "Applications with failed emails" + + @classproperty + def admin_hide_view_change_buttons(cls): + return True diff --git a/django/api/scheduled_jobs.py b/django/api/scheduled_jobs.py index 9034f1ef..53d017e7 100644 --- a/django/api/scheduled_jobs.py +++ b/django/api/scheduled_jobs.py @@ -12,6 +12,7 @@ def schedule_send_rebates_to_ncda(): name="send_rebates_to_ncda", schedule_type="C", cron="15 * * * *", + q_options={"timeout": 1200, "ack_failure": True}, ) except IntegrityError: pass @@ -52,15 +53,3 @@ def schedule_expire_expired_applications(): ) except IntegrityError: pass - - -def schedule_send_mass_approval_email_once(): - try: - schedule( - "api.tasks.send_mass_approval_email_once", - name="send_mass_approval_email_once", - schedule_type="O", - repeats=1, - ) - except IntegrityError: - pass diff --git a/django/api/services/cra.py b/django/api/services/cra.py index 49c2d468..4acce87f 100644 --- a/django/api/services/cra.py +++ b/django/api/services/cra.py @@ -33,6 +33,8 @@ def read(file): sin = line[4:13] year = line[13:17] income = line[21:30].lstrip("0") + if income == "": + income = "0" current_application.append({"sin": sin, "year": year, "income": income}) return results diff --git a/django/api/settings.py b/django/api/settings.py index 7ea45cbc..255c9285 100644 --- a/django/api/settings.py +++ b/django/api/settings.py @@ -197,12 +197,13 @@ Q_CLUSTER = { "name": "ITVR", "workers": 4, - "timeout": 1200, + "timeout": 90, "retry": 1260, "queue_limit": 50, "bulk": 10, "orm": "default", "save_limit": 20 if DEBUG else -1, + "max_attempts": 100, } CACHES = { diff --git a/django/api/signal_receivers.py b/django/api/signal_receivers.py index b9c15bfd..73b49fa7 100644 --- a/django/api/signal_receivers.py +++ b/django/api/signal_receivers.py @@ -14,7 +14,12 @@ @receiver(post_save, sender=GoElectricRebateApplication) def create_application(sender, instance, created, **kwargs): if created and settings.EMAIL["SEND_EMAIL"]: - async_task("api.tasks.send_individual_confirm", instance.email, instance.id) + async_task( + "api.tasks.send_individual_confirm", + instance.email, + instance.id, + hook="api.hooks.set_email_status", + ) @receiver(household_application_saved, sender=GoElectricRebateApplication) @@ -26,6 +31,7 @@ def after_household_application_created(sender, instance, created, **kwargs): spouse_email, instance.id, instance.email, + hook="api.hooks.set_email_status", ) diff --git a/django/api/tasks.py b/django/api/tasks.py index 0ddf9fa4..2280cca0 100644 --- a/django/api/tasks.py +++ b/django/api/tasks.py @@ -14,7 +14,14 @@ from datetime import timedelta from django.db.models.signals import post_save from api.services.ncda import notify +from api.constants import ( + FOUR_THOUSAND_REBATE, + ONE_THOUSAND_REBATE, + TWO_THOUSAND_REBATE, +) +from api.utility import get_applicant_full_name from django_q.tasks import async_task +from func_timeout import func_timeout, FunctionTimedOut def get_email_service_token() -> str: @@ -223,7 +230,7 @@ def send_reject(recipient_email, application_id): ) -def send_approve(recipient_email, application_id, rebate_amount): +def send_approve(recipient_email, application_id, applicant_full_name, rebate_amounts): message = """\ @@ -231,15 +238,37 @@ def send_approve(recipient_email, application_id, rebate_amount):

This email was generated by the CleanBC Go Electric Passenger Vehicle Rebate program application.

-

Dear Applicant,

+

Dear {applicant_full_name},

-

- Your application has been approved for a rebate amount of up to ${rebate_amount}. - The full amount applies to purchases of battery electric and long-range plug-in hybrids. - For plug-in hybrids with ranges less than 85 km the rebate amount is half. -

+

Your application has been approved for a maximum rebate amount of up to ${zev_max}.

+ +

${zev_max} rebate for long-range ZEV purchase (BEV, FCEV, ER-EV, and PHEV with an electric range of 85 km or more)

+ -

Your rebate will expire one year from today’s date.

+

${phev_max} rebate for short-range PHEV purchase (PHEV with an electric range of less than 85 km)

+ + +

This rebate approval will expire one year from today’s date.

Next steps:

    @@ -261,7 +290,13 @@ def send_approve(recipient_email, application_id, rebate_amount): """.format( - rebate_amount=rebate_amount + applicant_full_name=applicant_full_name, + zev_max=rebate_amounts.ZEV_MAX.value, + zev_mid=rebate_amounts.ZEV_MID.value, + zev_min=rebate_amounts.ZEV_MIN.value, + phev_max=rebate_amounts.PHEV_MAX.value, + phev_mid=rebate_amounts.PHEV_MID.value, + phev_min=rebate_amounts.PHEV_MIN.value, ) send_email( recipient_email, @@ -354,35 +389,44 @@ def send_cancel(recipient_email, application_id): def send_rebates_to_ncda(max_number_of_rebates=100): - rebates = GoElectricRebate.objects.filter(ncda_id__isnull=True)[ - :max_number_of_rebates - ] - associated_applications = [] - for rebate in rebates: - try: - notify( - rebate.drivers_licence, - rebate.last_name, - rebate.expiry_date.strftime("%m/%d/%Y"), - str(rebate.rebate_max_amount), - rebate.id, - ) - application = rebate.application - if application and ( - application.status == GoElectricRebateApplication.Status.APPROVED - ): - application.rebate_amount = rebate.rebate_max_amount - associated_applications.append(application) - except requests.HTTPError as ncda_error: - pass - - for application in associated_applications: - async_task( - "api.tasks.send_approve", - application.email, - application.id, - application.rebate_amount, - ) + def inner(): + rebates = GoElectricRebate.objects.filter(ncda_id__isnull=True)[ + :max_number_of_rebates + ] + for rebate in rebates: + try: + notify( + rebate.drivers_licence, + rebate.last_name, + rebate.expiry_date.strftime("%m/%d/%Y"), + str(rebate.rebate_max_amount), + rebate.id, + ) + application = rebate.application + if application and ( + application.status == GoElectricRebateApplication.Status.APPROVED + ): + if rebate.rebate_max_amount == 4000: + rebate_amounts = FOUR_THOUSAND_REBATE + elif rebate.rebate_max_amount == 2000: + rebate_amounts = TWO_THOUSAND_REBATE + else: + rebate_amounts = ONE_THOUSAND_REBATE + async_task( + "api.tasks.send_approve", + application.email, + application.id, + get_applicant_full_name(application), + rebate_amounts, + ) + except requests.HTTPError as ncda_error: + print("error posting rebate to ncda") + + try: + func_timeout(900, inner) + except FunctionTimedOut: + print("send_rebates_to_ncda timed out") + raise Exception # check for newly redeemed rebates @@ -444,23 +488,3 @@ def expire_expired_applications(): status=GoElectricRebateApplication.Status.EXPIRED, modified=timezone.now(), ) - - -def send_mass_approval_email_once(): - rebates = GoElectricRebate.objects.filter(ncda_id__isnull=False) - approved_applications = [] - for rebate in rebates: - application = rebate.application - if application and ( - application.status == GoElectricRebateApplication.Status.APPROVED - ): - application.rebate_amount = rebate.rebate_max_amount - approved_applications.append(application) - - for application in approved_applications: - async_task( - "api.tasks.send_approve", - application.email, - application.id, - application.rebate_amount, - ) diff --git a/django/api/utility.py b/django/api/utility.py index 0c4ff930..be956c7a 100644 --- a/django/api/utility.py +++ b/django/api/utility.py @@ -34,3 +34,15 @@ def addresses_match(application, household_user): return False return True + + +def get_applicant_full_name(application): + if application.middle_names: + return ( + application.first_name + + " " + + application.middle_names + + " " + + application.last_name + ) + return application.first_name + " " + application.last_name diff --git a/django/requirements.txt b/django/requirements.txt index f9148f16..aaa5588d 100644 --- a/django/requirements.txt +++ b/django/requirements.txt @@ -23,6 +23,7 @@ django-sequences==2.6 django-storages==1.12.3 djangorestframework==3.13.1 ecdsa==0.17.0 +func-timeout==4.3.5 gunicorn==20.1.0 idna==3.3 jmespath==1.0.0 diff --git a/django/users/admin.py b/django/users/admin.py index ce1940fd..95768e53 100644 --- a/django/users/admin.py +++ b/django/users/admin.py @@ -3,6 +3,7 @@ from django.contrib.auth.admin import UserAdmin from .forms import ITVRUserCreationForm, ITVRUserChangeForm +from django.utils.translation import gettext as _ ITVRUser = get_user_model() @@ -21,7 +22,17 @@ class CustomUserAdmin(UserAdmin): "is_superuser", ] - def get_queryset(self, request): - is_superuser = request.user.is_superuser - return super().get_queryset(request) if is_superuser else super().get_queryset(request).filter(is_staff=True) + def get_fieldsets(self, request, obj=None): + if not obj: + return self.add_fieldsets + if request.user.is_superuser: + perm_fields = ('is_active', 'is_staff', 'is_superuser', + 'groups', 'user_permissions') + else: + perm_fields = ('is_active', 'is_staff', 'groups') + + return [(None, {'fields': ('username', 'password')}), + (_('Personal info'), {'fields': ('first_name', 'last_name', 'email')}), + (_('Permissions'), {'fields': perm_fields}), + (_('Important dates'), {'fields': ('last_login', 'date_joined')})] diff --git a/frontend/cypress/e2e/bceid_application.cy.js b/frontend/cypress/e2e/bceid_application.cy.js index 5814ef7b..634a66dd 100644 --- a/frontend/cypress/e2e/bceid_application.cy.js +++ b/frontend/cypress/e2e/bceid_application.cy.js @@ -22,8 +22,14 @@ describe('submit bceid application', () => { let applicationId = ''; const last_name = 'Smith'; const first_name = 'John'; - const date_of_birth_in = '01/01/2000'; - const date_of_birth_out = '2000-01-01'; + const today = new Date(); + const date_of_birth_out = new Date( + today.getFullYear() - 16, + today.getMonth(), + today.getDate() + ) + .toISOString() + .split('T')[0]; const address = '111 Cambie Street'; const city = 'Vancouver'; const postal_code = 'V1V1V1'; @@ -38,7 +44,6 @@ describe('submit bceid application', () => { cy.contains('Logged in as'); cy.get('#last_name').type(last_name); cy.get('#first_name').type(first_name); - cy.get('#date_of_birth').clear().type(date_of_birth_in); cy.get('#address').type(address); cy.get('#city').type(city); cy.get('#postal_code').type(postal_code); diff --git a/frontend/package.json b/frontend/package.json index 7e942760..2ccbccba 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "frontend", - "version": "1.9.0-1", + "version": "1.10.0", "private": true, "dependencies": { "@date-io/date-fns": "^2.14.0", diff --git a/frontend/src/components/DateBoxes.js b/frontend/src/components/DateBoxes.js new file mode 100644 index 00000000..6981ba4d --- /dev/null +++ b/frontend/src/components/DateBoxes.js @@ -0,0 +1,87 @@ +import TextField from '@mui/material/TextField'; +import { DatePicker } from '@mui/x-date-pickers/DatePicker'; +import { getFirstDayOfMonth, getLastDayOfMonth } from '../utility'; + +const DateBoxes = ({ maxDate, minDate, value, onChange }) => { + return ( + <> + { + onChange(newDate); + }} + renderInput={(params) => ( + + )} + /> + { + onChange(newDate); + }} + renderInput={(params) => ( + + )} + /> + { + onChange(newDate); + }} + renderInput={(params) => ( + + )} + /> + + ); +}; + +export default DateBoxes; diff --git a/frontend/src/components/Form.js b/frontend/src/components/Form.js index a0958ba2..fe15dae6 100644 --- a/frontend/src/components/Form.js +++ b/frontend/src/components/Form.js @@ -17,7 +17,7 @@ import FormLabel from '@mui/material/FormLabel'; import Box from '@mui/material/Box'; import Radio from '@mui/material/Radio'; import RadioGroup from '@mui/material/RadioGroup'; -import { isAgeValid, isSINValid } from '../utility'; +import { getDateWithYearOffset, isSINValid } from '../utility'; import LockIcon from '@mui/icons-material/Lock'; import InputAdornment from '@mui/material/InputAdornment'; import OutlinedInput from '@mui/material/OutlinedInput'; @@ -28,7 +28,10 @@ import InfoTable from './InfoTable'; import { addTokenFields, checkBCSC } from '../keycloak'; import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns'; import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; -import { DatePicker } from '@mui/x-date-pickers/DatePicker'; +import DateBoxes from './DateBoxes'; + +const maxDate = getDateWithYearOffset(new Date(), -16); +const minDate = getDateWithYearOffset(maxDate, -100); export const defaultValues = { sin: '', @@ -39,7 +42,7 @@ export const defaultValues = { address: '', city: '', postal_code: '', - date_of_birth: '', + date_of_birth: maxDate, drivers_licence: '', documents: [], consent_personal: false, @@ -50,7 +53,7 @@ export const defaultValues = { const Form = ({ setNumberOfErrors, setErrorsExistCounter }) => { const [loading, setLoading] = useState(false); - const [DOB, setDOB] = useState(new Date()); + const [DOB, setDOB] = useState(maxDate); const queryClient = useQueryClient(); const { keycloak } = useKeycloak(); const kcToken = keycloak.tokenParsed; @@ -87,6 +90,10 @@ const Form = ({ setNumberOfErrors, setErrorsExistCounter }) => { headers: { 'Content-Type': 'multipart/form-data' } }); }); + const onDobChange = (dob) => { + setValue('date_of_birth', dob); + setDOB(dob); + }; const onSubmit = (data) => { setNumberOfErrors(0); setLoading(true); @@ -200,6 +207,11 @@ const Form = ({ setNumberOfErrors, setErrorsExistCounter }) => { Your application information secure form submission +

    + The information you enter (name, date of birth, address and BC + Driver's Licence number) must exactly match the ID you upload or + your application will be declined. +

    {kcToken.identity_provider === 'bcsc' ? ( @@ -262,13 +274,6 @@ const Form = ({ setNumberOfErrors, setErrorsExistCounter }) => { /> - {errors?.date_of_birth?.type === 'validate' && ( -

    - Date of birth cannot be blank and you must be 16 years or - older to request a rebate, please check the date of birth - entered. -

    - )} Date of birth: @@ -277,27 +282,22 @@ const Form = ({ setNumberOfErrors, setErrorsExistCounter }) => { control={control} render={({ field }) => ( - { - setValue('date_of_birth', newDate); - setDOB(newDate); + ( - - )} - /> + > + + )} - rules={{ - validate: (inputtedDOB) => { - return isAgeValid(inputtedDOB, 16); - } - }} />
    diff --git a/frontend/src/components/SpouseForm.js b/frontend/src/components/SpouseForm.js index 9385ffd2..562764ac 100644 --- a/frontend/src/components/SpouseForm.js +++ b/frontend/src/components/SpouseForm.js @@ -11,7 +11,7 @@ import ConsentPersonal from './ConsentPersonal'; import ConsentTax from './ConsentTax'; import useAxios from '../utils/axiosHook'; import Box from '@mui/material/Box'; -import { isAgeValid, isSINValid } from '../utility'; +import { getDateWithYearOffset, isSINValid } from '../utility'; import LockIcon from '@mui/icons-material/Lock'; import Upload from './upload/Upload'; import Loading from './Loading'; @@ -20,7 +20,10 @@ import InfoTable from './InfoTable'; import { addTokenFields, checkBCSC } from '../keycloak'; import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns'; import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; -import { DatePicker } from '@mui/x-date-pickers/DatePicker'; +import DateBoxes from './DateBoxes'; + +const maxDate = getDateWithYearOffset(new Date(), -16); +const minDate = getDateWithYearOffset(maxDate, -100); export const defaultValues = { application: '', @@ -31,7 +34,7 @@ export const defaultValues = { address: '', city: '', postal_code: '', - date_of_birth: '', + date_of_birth: maxDate, documents: [], consent_personal: false, consent_tax: false @@ -60,7 +63,7 @@ const SpouseForm = ({ formState: { errors }, setValue } = methods; - const [DOB, setDOB] = useState(new Date()); + const [DOB, setDOB] = useState(maxDate); const axiosInstance = useAxios(); let bcscMissingFields = []; if (kcToken.identity_provider === 'bcsc') { @@ -112,6 +115,10 @@ const SpouseForm = ({ headers: { 'Content-Type': 'multipart/form-data' } }); }); + const onDobChange = (dob) => { + setValue('date_of_birth', dob); + setDOB(dob); + }; const onSubmit = (data) => { setNumberOfErrors(0); setLoading(true); @@ -268,13 +275,6 @@ const SpouseForm = ({ - {errors?.date_of_birth?.type === 'validate' && ( -

    - Date of birth cannot be blank and you must be 16 years or - older to request a rebate, please check the date of birth - entered. -

    - )} Date of birth: @@ -283,25 +283,22 @@ const SpouseForm = ({ control={control} render={({ field }) => ( - { - setValue('date_of_birth', newDate); - setDOB(newDate); + } - /> + > + + )} - rules={{ - validate: (inputtedDOB) => { - return isAgeValid(inputtedDOB, 16); - } - }} />
    diff --git a/frontend/src/components/upload/Upload.js b/frontend/src/components/upload/Upload.js index 91981215..779f6434 100644 --- a/frontend/src/components/upload/Upload.js +++ b/frontend/src/components/upload/Upload.js @@ -11,12 +11,14 @@ const Upload = (props) => {
    Take a picture of:
    • - The photo side of your BC Driver's Licence{' '} + The full image of the photo side of your BC Driver's Licence {applicationType === 'spouse' && ' or BC Services Card'} + . Name, address and date of birth must be visible.
    • A secondary piece of ID like a financial statement or utility bill - that has been issued in the last 90 days + that has been issued in the last 90 days. Full name, + address and issue date must be visible.

    diff --git a/frontend/src/utility.js b/frontend/src/utility.js index 5b43dd61..195d9355 100644 --- a/frontend/src/utility.js +++ b/frontend/src/utility.js @@ -32,7 +32,7 @@ export const isAgeValid = (dob, lowerBound, upperBound) => { if (!dob) { return false; } - dob = dob.toISOString().slice(0,10) + dob = dob.toISOString().slice(0, 10); const dobSplit = dob.split('-'); const dobYear = parseInt(dobSplit[0]); const dobMonthIndex = parseInt(dobSplit[1]) - 1; @@ -53,3 +53,15 @@ export const isAgeValid = (dob, lowerBound, upperBound) => { } return true; }; + +export const getDateWithYearOffset = (date, offset) => { + return new Date(date.getFullYear() + offset, date.getMonth(), date.getDate()); +}; + +export const getLastDayOfMonth = (date) => { + return new Date(date.getFullYear(), date.getMonth() + 1, 0); +}; + +export const getFirstDayOfMonth = (date) => { + return new Date(date.getFullYear(), date.getMonth(), 1); +};