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',
}
|
|
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:
|
Requires both |
|
Requires the |
|
This is why bookmarking a DocuSign return URL, or refreshing after returning,
cannot re-execute a withdrawal. The URL alone is not enough — the
|
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 |
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 ( The contract flows ( |
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}`;
}
}
See Long Term Contract.
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
-
Add the member to
DocusignFlowEnum. -
Add an
initialize<Name>Flow()guard method following the pattern of the closest existing flow (advance-style or contract-style). -
Add a
handle<Name>()method with the API confirmation and business action. -
Add the
casetoinitializeFlow(). -
Build the return URL with
generateRedirectUrl(), targeting a page covered byDocuSignGuard(dashboardorproduct-offerings). -
Always call
redirectToDocusign()rather than settingwindow.locationdirectly, so the replay flag is set.