DocuSign Contract Flows

DocuSign is used wherever a legally binding signature is required. Because signing happens on DocuSign’s own domain, every flow is a round trip out of the application and back, which is what makes this subsystem unusual.

Orchestrated by DocusignVerificationService (src/app/core/pyb/services/docusign-verification.service.ts), which extends Subscriber.

Flow types

// src/app/core/pyb/models/docusign-parameters.model.ts
export enum DocusignFlowEnum {
    AdvanceLineActive    = 'advance-line-active',
    LongTermContract     = 'long-term-contract',
    AdvanceLinePending   = 'advance-line-pending',
    InstantAdvanceActive = 'instant-advance-active',
    ReAssignContract     = 're-assign-contract',
    OnboardingActivation = 'onboarding-activation',
}

export enum DocusignEventEnum {
    SingingComplete = 'signing_complete',
    Cancel          = 'cancel',
    Decline         = 'decline',
}

export enum DocusignModeEnum {
    Dashboard            = 'dashboard',
    ProductOfferingsList = 'product-offerings-list',
}

SingingComplete is spelled that way in both the local enum and the API package’s DocusignEventTypeEnum. Do not "fix" it — the string value signing_complete is what DocuSign sends, and the member name is referenced across the codebase.

Round trip

1  App builds a return URL
        DocusignVerificationService.generateRedirectUrl(params, pageName)
        → https://app.payability.com/#/dashboard?flowType=…&event=…&mode=…&…

2  App sets the replay flag and leaves
        redirectToDocusign(url)
          localStorage['docusignFlowInitialized'] = 'true'
          window.location.href = url

3  Supplier signs / cancels / declines on DocuSign

4  DocuSign redirects back to the return URL

5  DocuSignGuard sees `event` + `flowType` in the query params
        → initializeFlow(flowType, { supplierKey, event, data: queryParams })

6  initializeFlow checks localStorage['docusignFlowInitialized']
        absent → do nothing (direct navigation / refresh)
        present → clearFlowInitialized() and run the flow

7  Flow confirms with the API, executes the business action,
   refreshes state and shows the outcome modal

8  DocuSignGuard redirects to a clean URL so the params cannot be replayed

The two replay guards

Both must pass before any business action runs:

DocuSignGuard.docusignFlowInitialized(queryParams)

Requires both event and flowType query parameters — the signature of a DocuSign redirect.

DocusignVerificationService.docusignFlowInitialized()

Requires the docusignFlowInitialized key in localStorage, which is only ever written immediately before leaving for DocuSign.

This is why bookmarking a DocuSign return URL, or refreshing after returning, cannot re-execute a withdrawal. The URL alone is not enough — the localStorage flag is single-use and cleared the moment the flow starts.

Redirect URL construction

public generateRedirectUrl<T>(params: T, pageName = 'dashboard'): string {
    const paramString = Object.keys(params).map(key => `${key}=${params[key]}`).join('&');
    return `${window.location.href.split('#')[0]}#/${pageName}?${paramString}`;
}

The split('#')[0] strips the current hash route so the return URL is built from the origin plus a fresh hash path. This depends on hash routing being enabled.

Parameter values are not URL-encoded. Any value containing &, = or # will corrupt the query string.

Handler parameters

export interface DocusignHandlerParams<D = any> {
    supplierKey: string;
    event: DocusignEventTypeEnum;
    data?: D;
}

export interface LtcDocusignHandlerParamsData {
    duration: string;
    discountedFeePercent: string;
    contractActionType: LtcContractActionType;
    mode: DocusignModeEnum;
}

Dispatch

public initializeFlow(flowType: DocusignFlowEnum, additionalParams: DocusignHandlerParams): Observable<boolean> {
    switch (flowType) {
        case DocusignFlowEnum.AdvanceLineActive:    return this.initializeAdvancedLineFlow(additionalParams);
        case DocusignFlowEnum.LongTermContract:     return this.initializeLongTermContractFlow(additionalParams);
        case DocusignFlowEnum.AdvanceLinePending:   return this.initializeAdvanceLinePendingFlow(additionalParams);
        case DocusignFlowEnum.InstantAdvanceActive: return this.initializeInstantAdvanceFlow(additionalParams);
        case DocusignFlowEnum.ReAssignContract:     return this.initializeReAssignContractFlow(additionalParams);
        case DocusignFlowEnum.OnboardingActivation: return this.initializeOnboardingActivationFlow(additionalParams);
        default: return throwError(`Flow type not supported: ${flowType}`);
    }
}
Two classes of flow

The advance flows (AdvanceLineActive, AdvanceLinePending, InstantAdvanceActive) additionally require event === DocusignEventTypeEnum.SingingComplete before doing anything — a cancel or decline is a no-op.

The contract flows (LongTermContract, ReAssignContract, OnboardingActivation) run their handler on any event, and branch on SingingComplete inside the handler. The confirmation call is made either way, so the backend learns the supplier declined.

Flow 1 — Advance Line Active (withdraw)

Triggered when a supplier with an active Advance Line draws funds.

handleAdvanceLineFlow = (params: DocusignHandlerParams): Observable<boolean> => {
    const { supplierKey, event, data: { advanceLineKey, amount, offerType } } = params;
    this.store.dispatch(clearDashboardNotifications());

    return this.docusignService
        .confirmAdvanceLineDocuSignRequest(supplierKey, { event }, this.userService.isAprUserContract())
        .pipe(
            tap(() => this.eventLogger.track({ eventMessage: AdvanceLineEventTypes.DOCUSIGN_VERIFIED })),
            switchMap(() => this.advanceLineService
                .withdraw(supplierKey, advanceLineKey, amount, offerType)
                .pipe(tap(() => this.reloadCurrentState(offerType, amount)))),
            map(() => true),
        );
};

reloadCurrentState() then:

this.store.dispatch(fetchSupplierMarketplaces());
this.store.dispatch(loadBalance());
this.store.dispatch(loadSummary());
this.store.dispatch(advanceLineWithdrawSuccess());
this.store.dispatch(userStatusUpdateRequested());
this.eventLogger.track({
    eventMessage: AdvanceLineEventTypes.FUNDS_WITHDRAWN,
    additionalProperties: { offerType, amount },
});
this.showSuccessWithdrawModal(amount);   // FEATURES.ADVANCE_LINE.MODALS.SUCCESS.*

Flow 2 — Advance Line Pending (ACH agreement)

For expedited suppliers signing their ACH agreement. The handler branches on whether an amount was supplied:

return this.isNumeric(params?.data?.amount)
    ? this.handleAdvanceLinePendingFlow(params)                // draw funds now
    : this.handleAdvanceLinePendingFlowInstantAdvance(params); // ACH only

With an amount — confirmDocuSignAchRequest(), revoke ADVANCE_LINE.ADVANCE_LINE_PENDING_ACTIVATION, then withdraw() and the same reloadCurrentState() as flow 1.

Without an amount — confirmDocuSignAchRequest(), revoke the permission, and dispatch userStatusUpdateRequested(). No withdrawal.

Flow 3 — Instant Advance Active

handleInstantAdvanceFlow = (params: DocusignHandlerParams): Observable<boolean> => {
    const { supplierKey, event } = params;
    this.store.dispatch(clearDashboardNotifications());
    return this.docusignService.confirmAdvanceLineDocuSignRequest(supplierKey, { event }).pipe(
        map(() => {
            this.permissionService.removePermission('INSTANT_ADVANCE_PENDING');
            this.store.dispatch(userStatusUpdateRequested());
            this.messageModalService.openSuccessModal({
                title:       this.translateService.instant('MODALS.INSTANT_ADVANCE.SUCCESS.TITLE'),
                description: this.translateService.instant('MODALS.INSTANT_ADVANCE.SUCCESS.CONTENT'),
            });
            return true;
        }),
    );
};

Note this call omits the APR flag, unlike the Advance Line flows.

Flow 4 — Long Term Contract

handleLongTermContract = (params: DocusignHandlerParams<LtcDocusignHandlerParamsData>) => {
    const { supplierKey, event, data } = params;
    return this.docusignService.confirmLongTermContractDocuSignRequest(supplierKey, { event }).pipe(
        map(() => {
            if (event === DocusignEventTypeEnum.SingingComplete) {
                this.store.dispatch(clearDashboardNotifications());
                this.refreshData(data?.mode);
                this.openLtcDocusignSummaryModal(data);
            }
            return true;
        }),
    );
};

LtcSummaryModalComponent receives { duration, discountedFeePercent, contractActionType, mode } and its close action decides the destination:

private getUrlToRedirectAfterReturnFromDocuSign(type: LtcSummaryModalActionType): string {
    switch (type) {
        case LtcSummaryModalActionType.GoToReportsPage:          return `/${URLS_ROUTER.REPORTS.CONTRACTS_OVERVIEW_REPORT}`;
        case LtcSummaryModalActionType.GoToProductOfferingsPage: return `/${URLS_ROUTER.PRODUCT_OFFERINGS.ROOT}`;
        default:                                                 return `/${URLS_ROUTER.DASHBOARDS.ROOT}`;
    }
}

Flow 5 — Re-Assign Contract

handleReAssignContract = (params: DocusignHandlerParams) => {
    const { supplierKey, event } = params;
    return this.docusignService.confirmReAssignDocuSignRequest(supplierKey, { event }).pipe(
        map(() => {
            this.store.dispatch(userStatusUpdateRequested());
            this.refreshData(params.data?.mode);
            if (event === DocusignEventTypeEnum.SingingComplete) {
                this.openReAssignContractSummaryModal();   // MODALS.LEGAL_INFO_CONFIRM.AGREEMENT_SIGNED
            }
            return true;
        }),
    );
};

The flow is started by ReSignContractFlow, which GlobalSubscriptionsService launches when:

canResignContract({ isLegalSigned, userStatus, onboardSchema }): boolean {
    return !isLegalSigned
        && userStatus  === SupplierStatusType.Pending
        && onboardSchema !== OnboardingSchemaEnum.Expedited;
}

and the supplier is not mid-onboarding and has not just signed. If the supplier dismisses the flow (FlowTaskResultType.Terminated) a persistent banner built by reSignContractBannerFactory offers to restart it.

Flow 6 — Onboarding Activation

handleOnboardingActivation = (params: DocusignHandlerParams) => {
    const { supplierKey, event } = params;
    return this.docusignService.confirmOnboardingActivationDocuSignRequest(supplierKey, { event }).pipe(
        map(() => {
            this.store.dispatch(userStatusUpdateRequested());
            if (event === DocusignEventTypeEnum.SingingComplete) {
                this.openOnboardingActivationModal(supplierKey);
            }
            return true;
        }),
    );
};

which calls OnboardingService.startActivationAccount(), TopBarService.fetchStatus(), and opens LoadingPageComponent with { activatingAccount: true, mode: 'indeterminate' }.

refreshData(mode)

What gets reloaded after a contract flow depends on where the supplier came from:

private refreshData(mode: DocusignModeEnum) {
    switch (mode) {
        case DocusignModeEnum.ProductOfferingsList:
            this.productOfferingsService.loadProducts();
            break;

        case DocusignModeEnum.Dashboard:
        default:
            this.store.select(selectDashboardNotifications)
                .pipe(distinctUntilChanged(), skip(1), take(1))   (1)
                .subscribe(() => this.store.dispatch(ProductSummaryActions.fetchProductSummaryData()));
            this.store.dispatch(fetchDashboardNotifications());
            break;
    }
}
1 skip(1) ignores the current value and waits for the next notification emission, so the product summary is only refetched once the notifications that drive it have actually changed.

APR contracts

UserService.isAprUserContract() (derived from status.signAPRAdvanceContract) is passed to confirmAdvanceLineDocuSignRequest and confirmDocuSignAchRequest so the backend selects the APR or flat-fee contract template. It also affects whether AdvanceLineWithdrawFlow routes through DocuSign at all:

if (!status.instantAdvanceSigned || this.userService.isAprUserContract()) {
    taskFlow.addTask(this.docusignAdvanceLineTask);   // must sign
} else {
    taskFlow.addTask(this.withdrawAdvanceLineTask)…   // withdraw inline
}

Cancel and decline

For advance flows, event !== SingingComplete means initialize*Flow() returns of(false) without clearing the localStorage flag or calling the API. For contract flows the confirmation call is still made so the backend records the decline, but no modal opens and no state changes beyond userStatusUpdateRequested().

Adding a flow

  1. Add the member to DocusignFlowEnum.

  2. Add an initialize<Name>Flow() guard method following the pattern of the closest existing flow (advance-style or contract-style).

  3. Add a handle<Name>() method with the API confirmation and business action.

  4. Add the case to initializeFlow().

  5. Build the return URL with generateRedirectUrl(), targeting a page covered by DocuSignGuard (dashboard or product-offerings).

  6. Always call redirectToDocusign() rather than setting window.location directly, so the replay flag is set.