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
25 changes: 25 additions & 0 deletions docs/reference/endpoint-inventory.json
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,31 @@
"contractorPaymentUuid"
]
},
"Contractor.CreateHistoricalPayment": {
"endpoints": [
{
"method": "GET",
"path": "/v1/companies/:companyUuid/contractors"
},
{
"method": "POST",
"path": "/v1/companies/:companyId/contractor_payment_groups"
},
{
"method": "POST",
"path": "/v1/companies/:companyId/contractor_payment_groups/preview"
},
{
"method": "GET",
"path": "/v1/contractor_payment_groups/:contractorPaymentGroupUuid"
}
],
"variables": [
"companyId",
"companyUuid",
"contractorPaymentGroupUuid"
]
},
"Employee.EmployeeList": {
"endpoints": [
{
Expand Down
4 changes: 4 additions & 0 deletions docs/reference/endpoint-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@ import inventory from '@gusto/embedded-react-sdk/endpoint-inventory.json'
| **Contractor.PaymentStatement** | GET | `/v1/contractor_payment_groups/:contractorPaymentGroupUuid` |
| | GET | `/v1/companies/:companyUuid/contractors` |
| | GET | `/v1/contractor_payments/:contractorPaymentUuid/receipt` |
| **Contractor.CreateHistoricalPayment** | GET | `/v1/companies/:companyUuid/contractors` |
| | POST | `/v1/companies/:companyId/contractor_payment_groups` |
| | POST | `/v1/companies/:companyId/contractor_payment_groups/preview` |
| | GET | `/v1/contractor_payment_groups/:contractorPaymentGroupUuid` |

## Employee components

Expand Down
3 changes: 3 additions & 0 deletions sdk-app/src/generated-registry-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ export const ENTITY_REQUIREMENTS: Record<string, string[]> = {
'Contractor.ContractorList': ['companyId'],
'Contractor.ContractorProfile': ['companyId'],
'Contractor.ContractorSubmit': ['contractorId'],
'Contractor.CreateHistoricalPayment': ['companyId'],
'Contractor.CreatePayment': ['companyId'],
'Contractor.HistoricalPaymentFlow': ['companyId'],
'Contractor.NewHireReport': ['contractorId'],
'Contractor.OnboardingFlow': ['companyId'],
'Contractor.PaymentFlow': ['companyId'],
Expand All @@ -39,6 +41,7 @@ export const ENTITY_REQUIREMENTS: Record<string, string[]> = {
'Employee.EmploymentEligibility': ['employeeId'],
'Employee.FederalTaxes': ['employeeId'],
'Employee.Landing': ['employeeId', 'companyId'],
'Employee.ManagementEmployeeList': ['companyId'],
'Employee.OnboardingFlow': ['companyId'],
'Employee.OnboardingSummary': ['employeeId'],
'Employee.PaymentMethod': ['employeeId'],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,313 @@
import { useContractorsListSuspense } from '@gusto/embedded-api/react-query/contractorsList'
import { useContractorPaymentGroupsCreateMutation } from '@gusto/embedded-api/react-query/contractorPaymentGroupsCreate'
import type {
ContractorPayments,
PostV1CompaniesCompanyIdContractorPaymentGroupsRequestBody,
} from '@gusto/embedded-api/models/operations/postv1companiescompanyidcontractorpaymentgroups'
import { useContractorPaymentGroupsPreviewMutation } from '@gusto/embedded-api/react-query/contractorPaymentGroupsPreview'
import { useMemo, useState } from 'react'
import DOMPurify from 'dompurify'
import { RFCDate } from '@gusto/embedded-api/types/rfcdate'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next'
import type { ContractorPaymentGroupPreview } from '@gusto/embedded-api/models/components/contractorpaymentgrouppreview'
import type { InternalAlert } from '../types'
import { CreateHistoricalPaymentPresentation } from './CreateHistoricalPaymentPresentation'
import { EditHistoricalPaymentPresentation } from './EditHistoricalPaymentPresentation'
import {
createEditHistoricalPaymentFormSchema,
type EditHistoricalPaymentFormValues,
} from './EditHistoricalPaymentFormSchema'
import { HistoricalPreviewPresentation } from './HistoricalPreviewPresentation'
import { calculateDefaultHistoricalDate } from './helpers'
import { useComponentDictionary } from '@/i18n'
import { BaseComponent, useBase, type BaseComponentInterface } from '@/components/Base'
import { componentEvents, ContractorOnboardingStatus } from '@/shared/constants'
import { firstLastName } from '@/helpers/formattedStrings'

const HISTORICAL_PAYMENT_METHOD = 'Historical Payment'

interface CreateHistoricalPaymentProps extends BaseComponentInterface<'Contractor.Payments.HistoricalPayments.CreateHistoricalPayment'> {
companyId: string
}

export function CreateHistoricalPayment(props: CreateHistoricalPaymentProps) {
return (
<BaseComponent
{...props}
componentName="Contractor.Payments.HistoricalPayments.CreateHistoricalPayment"
>
<Root {...props}>{props.children}</Root>
</BaseComponent>
)
}

export const Root = ({ companyId, dictionary, onEvent }: CreateHistoricalPaymentProps) => {
useComponentDictionary(
'Contractor.Payments.HistoricalPayments.CreateHistoricalPayment',
dictionary,
)
const { t } = useTranslation('Contractor.Payments.HistoricalPayments.CreateHistoricalPayment')
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we would want to create a separate dedicated domain for this, Contractor.HistoricalPayments rather than nesting this many values on the translation key

const [isModalOpen, setIsModalOpen] = useState(false)
const { baseSubmitHandler } = useBase()
const [alerts, setAlerts] = useState<Record<string, InternalAlert>>({})
const [previewData, setPreviewData] = useState<ContractorPaymentGroupPreview | null>(null)

const { mutateAsync: createContractorPaymentGroup, isPending: isCreatingContractorPaymentGroup } =
useContractorPaymentGroupsCreateMutation()
const {
mutateAsync: previewContractorPaymentGroup,
isPending: isPreviewingContractorPaymentGroup,
} = useContractorPaymentGroupsPreviewMutation()

const { data: contractorList } = useContractorsListSuspense({ companyUuid: companyId })
const contractors = (contractorList.contractors || []).filter(
contractor =>
contractor.isActive &&
contractor.onboardingStatus === ContractorOnboardingStatus.ONBOARDING_COMPLETED,
)

const [paymentDate, setPaymentDate] = useState(calculateDefaultHistoricalDate())

const initialContractorPayments: (ContractorPayments & { isTouched: boolean })[] = useMemo(
() =>
contractors.map(contractor => ({
contractorUuid: contractor.uuid,
paymentMethod: HISTORICAL_PAYMENT_METHOD,
wage: '0',
hours: '0',
bonus: '0',
reimbursement: '0',
isTouched: false,
})),
[contractors],
)
const [virtualContractorPayments, setVirtualContractorPayments] =
useState(initialContractorPayments)
const totals = useMemo(
() =>
virtualContractorPayments.reduce(
(acc, payment) => {
const contractor = contractors.find(c => c.uuid === payment.contractorUuid)
const isHourly = contractor?.wageType === 'Hourly'
const hours = Number(payment.hours || '0')
const wage = Number(payment.wage || '0')
const bonus = Number(payment.bonus || '0')
const reimbursement = Number(payment.reimbursement || '0')
const hourlyAmount = isHourly ? hours * Number(contractor.hourlyRate || '0') : 0
const fixedWage = isHourly ? 0 : wage

return {
wage: acc.wage + fixedWage,
bonus: acc.bonus + bonus,
reimbursement: acc.reimbursement + reimbursement,
total: acc.total + hourlyAmount + fixedWage + bonus + reimbursement,
}
},
{ wage: 0, bonus: 0, reimbursement: 0, total: 0 },
),
[virtualContractorPayments, contractors],
)
Comment on lines +73 to +111
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there's some reworking here that could take place from the mapping -> virtualization. i think we could update this to be driven by the modal submission rather than needing to memoize


const formMethods = useForm<EditHistoricalPaymentFormValues>({
resolver: zodResolver(createEditHistoricalPaymentFormSchema()),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We likely don't need a create function on the schema for this one

defaultValues: {
wageType: 'Hourly',
hours: 0,
wage: 0,
bonus: 0,
reimbursement: 0,
hourlyRate: 0,
contractorUuid: '',
},
})

const onCreatePaymentGroup = async () => {
await baseSubmitHandler(null, async () => {
const contractorPayments = virtualContractorPayments.filter(payment => payment.isTouched)
if (contractorPayments.length === 0 || !previewData?.creationToken) {
return
}
const creationToken = previewData.creationToken

const requestBody: PostV1CompaniesCompanyIdContractorPaymentGroupsRequestBody = {
checkDate: new RFCDate(paymentDate),
contractorPayments: contractorPayments.map(({ isTouched, ...rest }) => rest),
creationToken,
}

const response = await createContractorPaymentGroup({
request: {
companyId,
requestBody,
},
})

onEvent(
componentEvents.CONTRACTOR_HISTORICAL_PAYMENT_CREATED,
response.contractorPaymentGroup || {},
)
})
}

const onEditContractor = (contractorUuid: string) => {
const contractor = contractors.find(contractor => contractor.uuid === contractorUuid)
const contractorPayment = virtualContractorPayments.find(
payment => payment.contractorUuid === contractorUuid,
)

formMethods.reset(
{
wageType: contractor?.wageType || 'Hourly',
hours: Number(contractorPayment?.hours || '0'),
wage: Number(contractorPayment?.wage || '0'),
bonus: Number(contractorPayment?.bonus || '0'),
reimbursement: Number(contractorPayment?.reimbursement || '0'),
hourlyRate: Number(contractor?.hourlyRate || '0'),
contractorUuid: contractorUuid,
},
{ keepDirty: false, keepValues: false },
)
Comment on lines +160 to +171
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can update this to conditionally render the modal so we get the form reset out of the box rather than trying to reset form state. There are some issues that can happen with forms that are tough to track down with the attempted manual reset

setAlerts({})
setIsModalOpen(true)
onEvent(componentEvents.CONTRACTOR_HISTORICAL_PAYMENT_EDIT)
}

const onEditContractorSubmit = (data: EditHistoricalPaymentFormValues) => {
const hasAnyPayment =
(data.wage ?? 0) > 0 ||
(data.hours ?? 0) > 0 ||
(data.bonus ?? 0) > 0 ||
(data.reimbursement ?? 0) > 0

setVirtualContractorPayments(prevPayments =>
prevPayments.map(payment =>
payment.contractorUuid === data.contractorUuid
? {
contractorUuid: payment.contractorUuid,
wage: String(data.wage ?? 0),
hours: String(data.hours ?? 0),
bonus: String(data.bonus ?? 0),
reimbursement: String(data.reimbursement ?? 0),
paymentMethod: HISTORICAL_PAYMENT_METHOD,
isTouched: hasAnyPayment,
}
: payment,
),
)
const displayContractor = contractors.find(
contractor => contractor.uuid === data.contractorUuid,
)
const displayName = DOMPurify.sanitize(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe we already have a first/last name utility that is using DOMPurify under the hood

displayContractor?.type === 'Individual'
? firstLastName({
first_name: displayContractor.firstName,
last_name: displayContractor.lastName,
})
: displayContractor?.businessName || '',
)
setAlerts(prevAlerts => ({
...prevAlerts,
[data.contractorUuid]: {
type: 'success',
title: t('alerts.contractorPaymentUpdated', {
contractorName: displayName,
interpolation: { escapeValue: false },
}),
onDismiss: () => {
setAlerts(prevAlerts => {
const { [data.contractorUuid]: _, ...rest } = prevAlerts
return rest
})
},
},
}))
setIsModalOpen(false)
onEvent(componentEvents.CONTRACTOR_HISTORICAL_PAYMENT_UPDATE, data)
}

const onContinueToPreview = async () => {
await baseSubmitHandler(null, async () => {
const today = new Date().toISOString().split('T')[0]
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be able to use an existing date util

if (paymentDate >= today!) {
setAlerts({
...alerts,
error: {
type: 'error',
title: t('alerts.dateMustBeInPast'),
},
})
return
}

const contractorPayments = virtualContractorPayments.filter(payment => payment.isTouched)
if (contractorPayments.length === 0) {
setAlerts({
...alerts,
error: {
type: 'error',
title: t('alerts.noContractorPayments'),
},
})
return
}
setAlerts({})

const response = await previewContractorPaymentGroup({
request: {
companyId,
requestBody: {
contractorPayments: contractorPayments.map(({ isTouched, ...rest }) => rest),
checkDate: new RFCDate(paymentDate),
},
},
})
setPreviewData(response.contractorPaymentGroupPreview || null)
onEvent(
componentEvents.CONTRACTOR_HISTORICAL_PAYMENT_PREVIEW,
response.contractorPaymentGroupPreview,
)
})
}

const onBackToEdit = () => {
setPreviewData(null)
onEvent(componentEvents.CONTRACTOR_HISTORICAL_PAYMENT_BACK_TO_EDIT)
}

return (
<>
{previewData && (
<HistoricalPreviewPresentation
contractorPaymentGroup={previewData}
contractors={contractors}
onBackToEdit={onBackToEdit}
onSubmit={onCreatePaymentGroup}
isLoading={isCreatingContractorPaymentGroup || isPreviewingContractorPaymentGroup}
/>
)}
{!previewData && (
<CreateHistoricalPaymentPresentation
contractors={contractors}
contractorPayments={virtualContractorPayments}
paymentDate={paymentDate}
onPaymentDateChange={setPaymentDate}
onSaveAndContinue={onContinueToPreview}
onEditContractor={onEditContractor}
totals={totals}
alerts={alerts}
isLoading={isCreatingContractorPaymentGroup || isPreviewingContractorPaymentGroup}
/>
)}
Comment on lines +281 to +302
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this would need more thought from the eng side of things on how to compose these components. Having CreateHistoricalPayment fork on the preview or the create historical payment is coupling these things too tightly. I think we'd likely want the preview as a dedicated component living as a separate entry in the state machine and then we would navigate to that preview based on an event fired from this component. As is, this component is doing too much

<EditHistoricalPaymentPresentation
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think the rec here would be to supply a unique key here that can be incremented after update or similar approach to unmount the form rather than needing to run the reset

isOpen={isModalOpen}
onClose={() => {
setIsModalOpen(false)
}}
formMethods={formMethods}
onSubmit={onEditContractorSubmit}
/>
</>
)
}
Loading
Loading