Onboarding Pages
src/app/views/onboarding/ is the UI for pre-activation. The business rules —
schemas, which products unlock when, compliance records — are in
Onboarding. This page documents the screens and the
service that drives them.
Unlike every other feature area, onboarding has two presences:
|
Four routed step pages under |
The top bar |
A status strip rendered inside |
Route tree
OnboardingModule declares only OnboardingComponent, an empty shell whose
template is a router outlet. Every step is separately lazy-loaded:
const routes: Routes = [{
path: '',
component: OnboardingComponent,
children: [
{ path: 'repayment-information', loadChildren: () => import('./repayment-information/repayment-information.module')… },
{ path: 'underwriting-review', loadChildren: () => import('./underwriting-review/underwriting-review.module')… },
{ path: 'product-offering', loadChildren: () => import('./product-offering/product-offering.module')… },
{ path: 'sign-agreement', loadChildren: () => import('./sign-agreement/sign-agreement.module')… },
],
}];
|
|
There is no guard on any step. Navigation between them is by explicit
router.navigate() calls in the components, and the happy path is a chain:
product-offering --goNext--> repayment-information --goNext--> sign-agreement --> DocuSign
^ | |
└──────── goBack ──────────────┘ goBack ────────┘
goBack → /dashboard
underwriting-review sits outside that chain — the top bar links to it, and its
goBack() returns to /dashboard.
TopBarService
Root-provided, and the single source of pre-activation state. It fetches on construction:
constructor(/* … */) {
this.fetchStatus();
}
fetchStatus(): void {
this.store.select(selectCurrentUserId).pipe(
take(1),
switchMap(supplierKey => this.onboardingService.getPreActivationStatus(supplierKey)),
).subscribe(res => {
this.detailsSubject.next(res.details);
this.stepsSubject.next(this.prepareSteps(res.steps));
});
}
fetchStatus() depends on the user already being in the store
|
Observables
|
|
|
|
|
|
|
Copy pushed by whichever step is active. |
export interface TopBarDetails {
applicationSubmissionTs: string;
isActivated: boolean;
isApplicationSubmitted: boolean;
eligibleMarketplaces: string[];
showStatusBar: boolean;
}
isActivated is what removes the Product Offerings menu entry and lets banners
render instead of the top bar. See
Application Shell.
Steps
Server steps arrive as PreActivationStatusDto[] and are decorated with a
translated title and a client-side link:
prepareSteps(steps: PreActivationStatusDto[]): OnbTopBarStep[] {
return steps.map(step => ({
key: step.key,
title: this.getStepTitle(step.key),
status: step.status,
link: this.stepsLinks.get(step.key),
}));
}
| Step key | Title key | Link |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
— (no link) |
|
|
|
|
|
|
|
|
— (no link) |
Titles resolve under TOP_BAR_STATUS.STEPS.<key>. salesCallSummary is a
client-invented step — the type is widened for it:
export declare type ExtendedPreActivationStatusStepsKeys = PreActivationStatusStepsKeys | 'salesCallSummary';
The links are hardcoded absolute paths, not URLS_ROUTER values — the one place
in the app that bypasses that constant.
Step status
export enum OnbProcessStepStatusEnum {
Completed = 'Completed',
Skipped = 'Skipped',
CompletedNow = 'Complete Now',
InProgress = 'In Progress',
Incomplete = 'Incomplete',
NotYetReady = 'Not Yet Ready',
NotQuiteThereYet = 'Not Quite There Yet',
}
The values are human-readable strings from the API, compared by equality. Note
CompletedNow = 'Complete Now' — the member name reads as past tense but the
value is an imperative call to action. NotQuiteThereYet is the soft-decline
status.
submitApplication()
Submitting the application is the only place the loading dialog is driven manually:
submitApplication(): Observable<void> {
const dialogRef = this.dialog.open<LoadingPageComponent, LoadingPageData>(LoadingPageComponent, {
disableClose: true,
data: { mode: 'determinate' },
});
let timerValue = 0;
const minInMs = 1000 * 60;
const intervalStep = (0.25 * minInMs) / 100; (1)
const timer = interval(intervalStep).subscribe(() => {
dialogRef.componentInstance.loadingProgressValue = timerValue++;
});
this.store.pipe(
select(selectCurrentUserId),
switchMap(supplierKey => this.onboardingService.submitApplication(supplierKey)),
).subscribe(
() => { this.fetchStatus(); },
() => dialogRef.close(),
);
dialogRef.componentInstance.loaded.subscribe(() => {
dialogRef.close();
timer.unsubscribe();
});
return dialogRef.componentInstance.loaded;
}
| 1 | 150 ms per percent — the bar is timed to fill in fifteen seconds regardless of how long the request takes. |
|
The progress bar is theatre, and it gates the close
The dialog closes when the bar reaches 100, not when the request returns.
Consequences: a fast submission still shows fifteen seconds of progress; a
submission slower than fifteen seconds closes the dialog while still in flight;
and the returned |
product-offering — /onboarding/product-offering
Where the supplier chooses which approved products to accept. The most rule-dense screen in onboarding.
|
The page — loads offers, computes the title, validates the selection, accepts. |
|
|
|
One card per product. |
|
Shared card shell. |
|
Term/repayment-rate selector. |
|
Label + value row used inside the cards. |
|
Two page layouts, old and new. |
|
|
The DTOs are wrapped in classes on load, so the cards get behaviour (showOffer,
checked, setChecked()) rather than raw data:
const instantAccess = new ProductOfferingInstantAccessClass(products?.instantAccess);
const advance = new ProductOfferingAdvanceClass(products?.advance);
const accessPlus = new ProductOfferingAccessPlusClass(products?.accessPlus);
These three classes live in the views layer but are imported by
core/pyb/interfaces/approved-products.interface.ts — see
Coding Conventions.
Title selection
The heading depends on which combination was approved, via a ten-value enum:
export enum ProductOfferingTypeEnum {
ONLY_ACCESS_PLUS,
ONLY_INSTANT_ACCESS,
ONLY_FREE_ACCESS,
ONLY_INSTANT_ADVANCE,
ONLY_ADVANCE_LINE,
INSTANT_ACCESS_AND_ADVANCE_LINE,
INSTANT_ACCESS_AND_INSTANT_ADVANCE,
FREE_ACCESS_AND_INSTANT_ADVANCE,
FREE_ACCESS_AND_ADVANCE_LINE,
NONE,
}
getProductConfigurationType() resolves it from three inputs — whether both
products are offered, whether the supplier is free-access (selectIsFreeAccess),
and whether the advance product is an Advance Line or an Instant Advance
(advance.isAdvanceLineAvailable). productPageTitles maps the enum to a
translation key, interpolated with the supplier’s first name.
ONLY_ACCESS_PLUS has a title key in productPageTitles but is never returned by
getProductConfigurationType() — Access Plus is always offered alongside Instant
Access, so the copy is unreachable. productPageTitles is typed
Record<ProductOfferingTypeEnum, string>, so the compiler requires the entry to
exist.
Card configuration
ProductOfferingService composes each card’s config from a static base in
product-offering.const.ts plus per-supplier values off the product class:
getAdvanceLineConfig(product, isCheckable, initChecked, newVersion): CommonProductOfferConfig {
return {
...advanceLineConfig, (1)
expirationDate: product.advanceDetails.instantAdvanceTerms.expirationDate,
expirationDays: product.expirationDays,
isOfferActive: product.isOfferActive,
checkDisabled: product.checkDisabled,
showExpirationInfo: product.showExpirationInfo,
items: this.getAdvanceLineItems(product, newVersion), (2)
isCheckable,
initChecked,
};
}
| 1 | Logo class, header key and description key. |
| 2 | The row list, built by a factory. |
Every card body is a CommonProductOfferItem[][] — an array of rows per
marketplace — produced by a factory in product-offering.const.ts. Each factory
comes in an old and a …NewFactory variant, selected by the newVersion flag that
also picks between product-offering-wrapper-old and …-new:
|
Old: per-marketplace advance rate, fee, transfer fee and icon. New: three translated sentences using |
|
Approved amount, weekly cost, expected payback in weeks, marketplace icons. |
|
Approved limit, weekly fee percent, marketplace icons. |
|
Funding info, advance rate, and the discount fee from |
|
Advance amount, payback after rebate, rebate, total cost, weekly cost — all currency-formatted, each with a tooltip from |
|
Two rough edges in this layer
The old Instant Access factory shows a fee row when |
Data access
ProductOfferingService bypasses @payability/pyb-ng2-api and calls HttpClient
directly:
getApprovedProducts(): Observable<ApprovedProducts> {
return this.httpClient.get<ApprovedProducts>(`${this.env.apiUrl}/suppliers/${this.user.getCurrentUserId()}/approved_products`);
}
acceptProducts(products: AcceptProductDto): Observable<any> {
return this.httpClient.post<AcceptProductDto>(`${this.env.apiUrl}/suppliers/${this.user.getCurrentUserId()}/accept_products`, products);
}
Both still pass through the interceptor chain, so auth and error handling are unaffected — but the URLs are assembled here rather than in the generated client. See API Client.
Selection rules
Access Plus and Instant Access are mutually exclusive, enforced by unchecking the other:
private accessPlusValidation(productName: string | undefined): void {
if (productName === undefined) { return; }
if (productName === 'accessPlus' && this.approvedProducts.accessPlus.checked) {
this.approvedProducts.instantAccess.setChecked(false);
}
if (productName === 'instantAccess' && this.approvedProducts.instantAccess.checked) {
this.approvedProducts.accessPlus.setChecked(false);
}
}
The Next button requires a selection — but only when more than one product is on offer:
this.disabledNextBtn =
!(this.bothProductsAvailable
? instantAccess?.checked || advance?.checked || accessPlus?.checked
: instantAccess?.checked || accessPlus?.checked) ||
this.noProducts ||
this.loading;
With a single advance-only offer, advance.checked is not in the second branch,
so the button stays disabled — that path relies on the offer being auto-accepted
rather than ticked.
Acceptance mirrors the same asymmetry: with both products offered, the supplier’s
tick decides; with one, showOffer decides.
const acceptProducts: AcceptProductDto = {
advance: this.bothProductsAvailable ? advance?.checked : advance?.showOffer,
instantAccess: this.bothProductsAvailable ? instantAccess?.checked : instantAccess?.showOffer,
accessPlus: this.approvedProducts.accessPlus?.showOffer ? accessPlus?.checked : false,
};
if (acceptProducts.accessPlus === true) {
acceptProducts.instantAccess = true; (1)
}
| 1 | Access Plus implies Instant Access. Even though the two are mutually exclusive in the UI, accepting Access Plus sends both — Access Plus is a tier on top of Instant Access, not an alternative to it. |
The zero-fee special case
const totalFee = sum(instantAccess?.marketplaces.map(mkt => mkt.fee));
this.showNewFreeAccess = instantAccess?.showOffer && totalFee === 0 && !advance?.showOffer && !accessPlus?.showOffer;
A supplier offered only Instant Access, at zero fee across every marketplace, gets
the new-free-access card instead of the standard one.
|
|
repayment-information — /onboarding/repayment-information
Explains how repayment will work. It picks one of two TemplateRef s based on
whether the supplier has an autodebit payment configuration:
private handlePayment(autodebit: AutodebitPaymentConfigInterface): void {
this.autodebitPCI = autodebit;
this.activeRepayment = autodebit ? this.autodebit : this.fof;
this.loading = false;
this.cd.detectChanges();
}
autodebitPmtCfg present → the autodebit template; absent → the FOF
(first-out-of-funds) template, which repays from marketplace payouts. The marketplace
names for the FOF copy come from PaymentFlowsService.getPaymentFlows():
this.marketplaces.fof.marketplaceName = res.fof.map(paymentFlow => paymentFlow.marketplaceName).join(', ');
The initial value is the literal string '…', which is what renders until the
call returns.
Status is read with filter(Boolean), take(1) in ngAfterViewInit — after the
view exists, because the template refs must be resolvable before one is chosen.
sign-agreement — /onboarding/sign-agreement
The last step. It hands off to DocuSign with the OnboardingActivation flow:
private handleDocusign(userId: string): void {
const redirectUrl = this.docusignVerificationService.generateRedirectUrl({
flowType: DocusignFlowEnum.OnboardingActivation,
});
this.docusignService.getOnboardingActivationDocuSignUrl(userId, redirectUrl).subscribe(response => {
this.docusignVerificationService.redirectToDocusign(response.url);
this.topBarService.fetchStatus();
});
}
redirectToDocusign() is what sets the docusignFlowInitialized replay flag, so
this must never be replaced with a direct window.location assignment. The full
round trip is in DocuSign Contract Flows.
A local loading flag guards against a double click; it is never reset, which is
correct here because the page is leaving for DocuSign.
underwriting-review — /onboarding/underwriting-review
A status page with exactly two states, derived from the stepper rather than from the user:
private handleUnderwriting(steps: OnbTopBarStep[]): void {
const underwritingStep = steps?.find(step => step?.key === TopBarAvailableSteps.UnderwritingReview);
const isNotYetReady = OnbProcessStepStatusEnum.NotQuiteThereYet === underwritingStep.status;
this.status = isNotYetReady ? 'DECLINED' : 'PENDING';
this.updateTitle();
this.updateStatus();
this.cd.detectChanges();
}
'Not Quite There Yet' → DECLINED with warning colours; anything else →
PENDING with primary colours. Copy comes from
FEATURES.ONBOARDING.UNDERWRITING_REVIEW.{PENDING|DECLINED}.TITLE.
|
|
The page emits OnboardingEventType.LoadedApplicationStatus on view init. See
Event Catalog.
loading-page
A dialog, not a route, despite the name. Used by submitApplication() and by the
activation flow.
|
|
|
|
|
Setter input. Emits |
|
Output — the "animation complete" signal callers await. |
MAT_DIALOG_DATA is injected @Optional(), so the component also works as a plain
inline element with [loadingProgressValue] bound.
Related pages
-
Onboarding — schemas, compliance, which products unlock when
-
DocuSign Contract Flows — the activation signature
-
Product Offerings — the post-activation cross-sell page, a different feature
-
Application Shell — where the top bar renders
-
Manage Account — the
yourDetailsstep target