Skip to content

Commit

Permalink
add protected/person-case/previous-sin route (#231)
Browse files Browse the repository at this point in the history
  • Loading branch information
Fbasham authored Feb 19, 2025
1 parent c6daec8 commit 3f840ff
Show file tree
Hide file tree
Showing 8 changed files with 335 additions and 0 deletions.
14 changes: 14 additions & 0 deletions frontend/app/.server/locales/protected-en.json
Original file line number Diff line number Diff line change
Expand Up @@ -151,5 +151,19 @@
"birth-certificate": "Birth certificate contains family name to be registered",
"citizenship-certificate": "Certificate of Canadian Citizenship contains family name to be registered"
}
},
"previous-sin": {
"page-title": "Previous SIN",
"has-previous-sin-label": "Has the applicant ever had a SIN?",
"has-previous-sin-options": {
"yes": "Yes",
"no": "No",
"unknown": "Unknown (don't recall)"
},
"social-insurance-number-label": "Social Insurance Number",
"error-messages": {
"has-previous-sin-required": "Select whether the applicant has had a previous SIN",
"sin-required": "Enter a valid SIN"
}
}
}
14 changes: 14 additions & 0 deletions frontend/app/.server/locales/protected-fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -151,5 +151,19 @@
"birth-certificate": "Acte de naissance contenant le nom de famille à enregistrer",
"citizenship-certificate": "Certificat de citoyenneté canadienne contenant le nom de famille à enregistrer"
}
},
"previous-sin": {
"page-title": "NAS précédent",
"has-previous-sin-label": "Le demandeur a-t-il déjà eu un NAS?",
"has-previous-sin-options": {
"yes": "Oui",
"no": "Non",
"unknown": "Inconnu (je ne me souviens pas)"
},
"social-insurance-number-label": "Numéro d'assurance sociale",
"error-messages": {
"has-previous-sin-required": "Indiquez si le demandeur a déjà eu un NAS.",
"sin-required": "Entrez un NAS valide"
}
}
}
4 changes: 4 additions & 0 deletions frontend/app/@types/express-session.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ declare module 'express-session' {
currentStatusInCanada: string;
documentType: string;
};
previousSin?: {
hasPreviousSin: string;
socialInsuranceNumber?: string;
};
};
}
}
Expand Down
1 change: 1 addition & 0 deletions frontend/app/errors/error-codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export const ErrorCodes = {

// validation error codes
INVALID_NUMBER: 'VAL-0001',
INVALID_SIN_FORMAT: 'VAL-0002',

// xstate error codes
MISSING_META: 'XST-0001',
Expand Down
8 changes: 8 additions & 0 deletions frontend/app/i18n-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,14 @@ export const i18nRoutes = [
fr: '/fr/protege/cas-personnel/nom-actuel',
},
},
{
id: 'PROT-0011',
file: 'routes/protected/person-case/previous-sin.tsx',
paths: {
en: '/en/protected/person-case/previous-sin',
fr: '/fr/protege/cas-personnel/previous-sin',
},
},
//
// XState-driven in-person flow (poc)
//
Expand Down
172 changes: 172 additions & 0 deletions frontend/app/routes/protected/person-case/previous-sin.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { useId, useState } from 'react';
import type { ChangeEvent } from 'react';

import { data, useFetcher } from 'react-router';
import type { RouteHandle, SessionData } from 'react-router';

import { useTranslation } from 'react-i18next';
import * as v from 'valibot';

import type { Info, Route } from './+types/previous-sin';

import { requireAuth } from '~/.server/utils/auth-utils';
import { i18nRedirect } from '~/.server/utils/route-utils';
import { Button } from '~/components/button';
import { FetcherErrorSummary } from '~/components/error-summary';
import { InputPatternField } from '~/components/input-pattern-field';
import { InputRadios } from '~/components/input-radios';
import { PageTitle } from '~/components/page-title';
import { AppError } from '~/errors/app-error';
import { getFixedT } from '~/i18n-config.server';
import { handle as parentHandle } from '~/routes/protected/layout';
import { getLanguage } from '~/utils/i18n-utils';
import { formatSin, isValidSin, sinInputPatternFormat } from '~/utils/sin-utils';

type PreviousSinSessionData = NonNullable<SessionData['inPersonSINCase']['previousSin']>;

const VALID_HAS_PREVIOUS_SIN_OPTIONS = { yes: 'yes', no: 'no', unknown: 'unknown' } as const;

export const handle = {
i18nNamespace: [...parentHandle.i18nNamespace, 'protected'],
} as const satisfies RouteHandle;

export async function loader({ context, request }: Route.LoaderArgs) {
requireAuth(context.session, new URL(request.url), ['user']);
const t = await getFixedT(request, handle.i18nNamespace);
return {
documentTitle: t('protected:previous-sin.page-title'),
defaultFormValues: context.session.inPersonSINCase?.previousSin,
};
}

export function meta({ data }: Route.MetaArgs) {
return [{ title: data.documentTitle }];
}

export async function action({ context, request }: Route.ActionArgs) {
requireAuth(context.session, new URL(request.url), ['user']);
const lang = getLanguage(request);
const t = await getFixedT(request, handle.i18nNamespace);

const formData = await request.formData();
const action = formData.get('action');

switch (action) {
case 'back': {
//TODO: replace with correct route
throw i18nRedirect('routes/protected/person-case/privacy-statement.tsx', request);
}

case 'next': {
const schema = v.pipe(
v.object({
hasPreviousSin: v.picklist(
Object.keys(VALID_HAS_PREVIOUS_SIN_OPTIONS),
t('protected:previous-sin.error-messages.has-previous-sin-required'),
),
socialInsuranceNumber: v.optional(
v.pipe(
v.string(),
v.trim(),
v.check((sin) => isValidSin(sin), t('protected:previous-sin.error-messages.sin-required')),
v.transform((sin) => formatSin(sin, '')),
),
),
}),
v.forward(
v.partialCheck(
[['hasPreviousSin'], ['socialInsuranceNumber']],
(input) =>
input.socialInsuranceNumber === undefined ||
(input.hasPreviousSin === VALID_HAS_PREVIOUS_SIN_OPTIONS.yes && isValidSin(input.socialInsuranceNumber ?? '')),
t('protected:previous-sin.error-messages.sin-required'),
),
['socialInsuranceNumber'],
),
) satisfies v.GenericSchema<PreviousSinSessionData>;

const input = {
hasPreviousSin: formData.get('hasPreviousSin') as string,
socialInsuranceNumber:
formData.get('hasPreviousSin') === VALID_HAS_PREVIOUS_SIN_OPTIONS.yes
? formData.get('socialInsuranceNumber')
: undefined,
} satisfies Partial<PreviousSinSessionData>;

const parseResult = v.safeParse(schema, input, { lang });

if (!parseResult.success) {
return data({ errors: v.flatten<typeof schema>(parseResult.issues).nested }, { status: 400 });
}

context.session.inPersonSINCase ??= {};
context.session.inPersonSINCase.previousSin = parseResult.output;

//TODO: replace with correct route
throw i18nRedirect('routes/protected/person-case/primary-docs.tsx', request);
}

default: {
throw new AppError(`Unrecognized action: ${action}`);
}
}
}

export default function PreviousSin({ loaderData, actionData, params }: Route.ComponentProps) {
const { t } = useTranslation(handle.i18nNamespace);
const fetcherKey = useId();
const fetcher = useFetcher<Info['actionData']>({ key: fetcherKey });
const isSubmitting = fetcher.state !== 'idle';
const errors = fetcher.data?.errors;

const [hasPreviousSin, setHasPreviousSin] = useState<string | undefined>(loaderData.defaultFormValues?.hasPreviousSin);
function handleHasPreviousSinChanged(event: ChangeEvent<HTMLInputElement>) {
setHasPreviousSin(event.target.value);
}

const hasPreviousSinOptions = Object.values(VALID_HAS_PREVIOUS_SIN_OPTIONS).map((value) => ({
value: value,
children: t(`protected:previous-sin.has-previous-sin-options.${value}`),
defaultChecked: value === loaderData.defaultFormValues?.hasPreviousSin,
onChange: handleHasPreviousSinChanged,
}));

return (
<>
<PageTitle subTitle={t('protected:in-person.title')}>{t('protected:previous-sin.page-title')}</PageTitle>
<FetcherErrorSummary fetcherKey={fetcherKey}>
<fetcher.Form method="post" noValidate>
<div className="space-y-6">
<InputRadios
id="has-previous-sin"
legend={t('protected:previous-sin.has-previous-sin-label')}
name="hasPreviousSin"
options={hasPreviousSinOptions}
required
errorMessage={errors?.hasPreviousSin?.at(0)}
/>
{hasPreviousSin === VALID_HAS_PREVIOUS_SIN_OPTIONS.yes && (
<InputPatternField
defaultValue={loaderData.defaultFormValues?.socialInsuranceNumber ?? ''}
inputMode="numeric"
format={sinInputPatternFormat}
id="social-insurance-number"
name="socialInsuranceNumber"
label={t('protected:previous-sin.social-insurance-number-label')}
errorMessage={errors?.socialInsuranceNumber?.at(0)}
/>
)}
</div>
<div className="mt-8 flex flex-row-reverse flex-wrap items-center justify-end gap-3">
<Button name="action" value="next" variant="primary" id="continue-button" disabled={isSubmitting}>
{t('protected:person-case.next')}
</Button>
<Button name="action" value="back" id="back-button" disabled={isSubmitting}>
{t('protected:person-case.previous')}
</Button>
</div>
</fetcher.Form>
</FetcherErrorSummary>
</>
);
}
69 changes: 69 additions & 0 deletions frontend/app/utils/sin-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { AppError } from '~/errors/app-error';
import { ErrorCodes } from '~/errors/error-codes';

/**
* Regular expression to validate Canadian SIN (Social Insurance Number) format.
*
* The SIN must follow the format XXXXXXXXX or XXX XXX XXX or XXX-XXX-XXX.
* The SIN number cannot consist entirely of zeros (e.g., 000000000 or 000 000 000 or 000-000-000 is not valid).
*
* Note: This regular expression only validates the format of the SIN.
* Consumers must validate the SIN against the Luhn algorithm separately.
*
* Examples of valid SIN formats:
* - 123-456-789
* - 123 456 789
* - 123456789
* - 000-000-010
*
* Examples of invalid SIN formats:
* - 000-000-000
* - 000000000
* - 123-45-6789
* - ABC-DEF-GHI
*/
const sinFormatRegex = /^(?!0{3}[ -]?0{3}[ -]?0{3})\d{3}[ -]?\d{3}[ -]?\d{3}$/;

/**
* This pattern is intended for use with the `format` property of the `InputPatternField` component.
*
* Example:
* ```typescript
* // Usage with InputPatternField
* <InputPatternField format={sinInputPatternFormat} />
* ```
*/
export const sinInputPatternFormat = '### ### ###';

/**
*
* @param sin - the Social Insurance Number (SIN)
* @returns a boolean indicating if a SIN is valid using Luhn's Algorithm
*
* Luhn's Alogrithm (also known as "mod 10")
* Social Insurance Numbers can be validated through a simple check digit process called the Luhn algorithm.
* 046 454 286 <--- A fictitious, but valid, SIN.
* 121 212 121 <--- Multiply every second digit by 2.
* The result of the multiplication is:
* 0 8 6 8 5 8 2 16 6
* Then, add all of the digits together (note that 16 is 1+6):
* 0 + 8 + 6 + 8 + 5 + 8 + 2 + 1+6 + 6 = 50
* If the SIN is valid, this number will be evenly divisible by 10.
*
*/
export function isValidSin(sin: string): boolean {
if (!sinFormatRegex.test(sin)) return false;
const multDigitString = [...sin.replace(/\D/g, '')].map((digit, index) => Number(digit) * (index % 2 === 0 ? 1 : 2)).join('');
const digitSum = [...multDigitString].reduce((acc, cur) => acc + Number(cur), 0);
return digitSum % 10 === 0;
}

/**
*
* @param sin - the Social Insurance Number (SIN)
* @returns a formatted SIN using a supplied separator
*/
export function formatSin(sin: string, separator = ' '): string {
if (!isValidSin(sin)) throw new AppError('Invalid SIN format', ErrorCodes.INVALID_SIN_FORMAT);
return (sin.replace(/\D/g, '').match(/.../g) ?? []).join(separator);
}
53 changes: 53 additions & 0 deletions frontend/tests/utils/sin-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';

import { formatSin, isValidSin, sinInputPatternFormat } from '~/utils/sin-utils';

describe('sin-utils', () => {
describe('isValidSin', () => {
it.each([['000000042'], ['000 000 042'], ['000-000-042'], ['800000002'], ['800 000 002'], ['800-000-002']])(
'should return true for valid SIN "%s"',
(sin) => {
expect(isValidSin(sin)).toEqual(true);
},
);

it.each([['000000000'], ['000 000 000'], ['000-000-000'], ['800000003'], ['800 000 003'], ['800-000-003']])(
'should return false for invalid SIN "%s"',
(sin) => {
expect(isValidSin(sin)).toEqual(false);
},
);

it('should return false for an invalid SIN of incorrect length', () => {
expect(isValidSin('123456')).toEqual(false);
});

it('should return false for an invalid SIN of incorrect form', () => {
expect(isValidSin('123abc&^+')).toEqual(false);
});

it('should return false when passed an empty string', () => {
expect(isValidSin('')).toEqual(false);
});
});

describe('formatSin', () => {
it('should format a SIN using the default separator', () => {
expect(formatSin('800000002')).toEqual('800 000 002');
});

it('should format a SIN using the a supplied separator', () => {
expect(formatSin('800000002', '-')).toEqual('800-000-002');
});

it('should throw an error for invalid SIN', () => {
expect(() => formatSin('123456789')).toThrowError();
});
});

describe('sinInputPatternFormat', () => {
it('should have correct format', () => {
expect(sinInputPatternFormat).toBe('### ### ###');
});
});
});

0 comments on commit 3f840ff

Please sign in to comment.