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:

/onboarding/*

Four routed step pages under OnboardingComponent.

The top bar

A status strip rendered inside BaseComponent on every authenticated page while the supplier is pre-activation.

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')… },
    ],
}];

OnboardingModule also imports RepaymentInformationModule eagerly and lazy-loads it as a child route. The eager import defeats the lazy boundary for that one step — it ships in the onboarding chunk regardless.

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

selectCurrentUserId is piped through take(1), so it resolves with whatever the store holds at that moment — including undefined before userLoaded. Because the service is root-provided and calls fetchStatus() in its constructor, the outcome depends on when something first injects it. Every step component and BaseComponent therefore call fetchStatus() again explicitly, which is why the call appears scattered through the onboarding components rather than once.

Observables

details$

TopBarDetails — the whole pre-activation summary.

steps$

OnbTopBarStep[] — the stepper model.

isSupplierIsInOnboarding$

showStatusBar && !isApplicationSubmitted. Read by AsideLeftComponent to hide the support block.

title$ / description$

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

yourDetails

YOUR_DETAILS

/manage-account

marketplacesAndPlatforms

MARKETPLACES_AND_PLATFORMS

/my-marketplaces

submitApplication

SUBMIT_APPLICATION

/onboarding/underwriting-review

submitted

SUBMITTED

— (no link)

underwritingReview

UNDERWRITING_REVIEW

/onboarding/underwriting-review

salesCallSummary

SALESCALLSUMMARY

/sales-call-summary

readyForActivation

READY_FOR_ACTIVATION

— (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. LoadingPageComponent emits loaded from its setter:

@Input()
set loadingProgressValue(value: number) {
    this.value = value;
    if (value >= 100) {
        this.progressBarMode = 'determinate';
        this.loaded.emit();
    }
    this.cd.detectChanges();
}

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 Observable<void> signals "the animation finished", not "the application was submitted". Only the error path closes early. The select here is not take(1), so a later user-id emission re-submits.

product-offering — /onboarding/product-offering

Where the supplier chooses which approved products to accept. The most rule-dense screen in onboarding.

product-offering.component.ts

The page — loads offers, computes the title, validates the selection, accepts.

product-offering.service.ts

getApprovedProducts() and acceptProducts().

instant-access/, advance-line/, instant-advance/, access-plus/, new-free-access/

One card per product.

common-product-offer/

Shared card shell.

repayment-slider/

Term/repayment-rate selector.

item-with-label/

Label + value row used inside the cards.

wrappers/product-offering-wrapper-new/, …-old/

Two page layouts, old and new.

models/

ProductOffering{InstantAccess,Advance,AccessPlus}Class plus a common base.

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:

instantAccessItemsFactory / …NewFactory

Old: per-marketplace advance rate, fee, transfer fee and icon. New: three translated sentences using overallRate / overallFee.

instantAdvanceItemsFactory / …NewFactory

Approved amount, weekly cost, expected payback in weeks, marketplace icons.

advanceLineItemsFactory / …NewFactory

Approved limit, weekly fee percent, marketplace icons.

accessPlusItemsFactory

Funding info, advance rate, and the discount fee from marketplace.templates[0].discount_fee_percent.

repaymentSliderItemsFactory

Advance amount, payback after rebate, rebate, total cost, weekly cost — all currency-formatted, each with a tooltip from AdvanceLineTooltipTextService.

Two rough edges in this layer

advanceLineItemsFactory and advanceLineItemsNewFactory have identical bodies, so the newVersion flag makes no difference for Advance Line. getAccessPlusItems(product, newVersion) accepts newVersion and ignores it.

The old Instant Access factory shows a fee row when !!marketplace?.fee || isFreeAcces — a 0 fee is falsy, so a genuinely zero fee is hidden unless the supplier is free-access. That interacts with the zero-fee card selection below.

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.

loadProducts() contains a console.log(instantAccess, advance, accessPlus) that prints the full offer set — including rates and fees — to the browser console on every visit.

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.

underwritingStep.status is read without a null check. If the API omits the underwritingReview step — which it does once the supplier moves past it — find returns undefined and the component throws. Reaching this route after activation is therefore not safe, and nothing guards it.

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.

mode

'LOADING_PAGE' by default; 'ACTIVATING_ACCOUNT' when data.activatingAccount is set. Selects the copy namespace.

progressBarMode

'indeterminate' by default; 'determinate' when the caller drives progress.

loadingProgressValue

Setter input. Emits loaded at ≥ 100.

loaded

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.