Marketplace API Connectors

src/app/core/api-connectors/ monitors whether Payability can still read a supplier’s marketplace data, and prompts a reconnect when it cannot. Two connectors are live: Amazon and Shopify.

Orchestration

ApiConnectorsService.init() is called from GlobalSubscriptionsService.init().

public init(): void {
    if (!this.permission$) {
        this.store.dispatch(fetchSupplierMarketplaces());

        this.permission$ = this.store.select(currentUserPermissions).pipe(
            filter(this.arePermissionsExisting),
            take(1),
            map(this.remapPermissions),
        ).subscribe((permissions: Map<number, Permission>) => {
            if (permissions.get(PermissionsEnum.SHOPIFY_API_CONNECTION)) { this.shopify.init(); }
            if (permissions.get(PermissionsEnum.AMAZON_API_CONNECTION))  { this.amazon.init(); }
        });
    }
}

Both permissions are default-granted, so both connectors normally start. The permission$ guard makes init() idempotent.

AbstractConnectorApi

The shared base class. Subclasses supply five members:

protected abstract readonly bannerSuccessKey: string;              // translation key
protected abstract readonly bannerFailKey: string;                 // translation key
protected abstract readonly bannerComponent: Type<AbstractConnectorBanner>;
protected abstract readonly modalComponent: Type<AbstractConnectorModal>;
protected abstract readonly storeKey: MarketplaceEnum;
protected abstract handleModalResult(result: boolean): void;

When the check runs

private isProperRouterEvent(event: RouterEvent): boolean {
    return !!event
        && event instanceof NavigationEnd
        && event.urlAfterRedirects.includes('/dashboard')     (1)
        && event.url
        && !event.url.includes('my-marketplaces');            (2)
}
1 The check only fires on navigation to the dashboard.
2 Navigation from /my-marketplaces is excluded — the supplier is already on the page where they would fix the connection, so interrupting them there is counterproductive.

init() subscribes to router events and also runs one immediate check.

What it checks

this.store$ = combineLatest([
    this.store.pipe(select(loadedSupplierMarketplaceSuccess),
                    filter((result: any) => !result?.supplierMarketplaces?.isLoading)),
    this.store.pipe(select(selectSupplierMarketplaceByKey, { key: this.storeKey }), filter(Boolean)),
    this.store.pipe(select(selectCurrentUser), filter(user => this.isUserNotDuringOnboarding(user))),   (1)
]).pipe(
    filter(() => this.isProperRouterEvent(this.lastEvent)),
    take(1),
).subscribe(([_, marketplace]) => this.handleMarketplace(marketplace as SupplierMarketplacesModel));
1 Suppliers still in onboarding are never prompted — connections are expected to be incomplete at that stage.

The reconnect decision

public shouldDisplayPopup(marketplace: SupplierMarketplacesModel): boolean {
    return coerceBooleanProperty(marketplace)
        && [ApiStatus.Required.valueOf(), ApiStatus.Needed.valueOf()].includes(marketplace.apiStatus?.toLowerCase())
        && !marketplace.draft
        && !marketplace.marketplaceStatus?.hidden
        && !marketplace.marketplaceStatus?.rejected;
}

Only required and needed prompt. rejected, in-review, pending, incomplete and confirmed do not — there is nothing the supplier can usefully do.

protected handleMarketplace(marketplace: SupplierMarketplacesModel): void {
    if (this.shouldDisplayPopup(marketplace)) {
        this.showReconnectBanner();
        this.verify();                    // open the guided modal
    } else {
        this.preventCheck = true;         (1)
    }
}
1 Once a connection is healthy, preventCheck stops all further checks for the session — the connector does not re-poll after a clean result.

Banners

Reconnect

BannerTypes.DANGER, message from bannerFailKey, hideClose: true (cannot be dismissed), rendering the connector’s own banner component so it can host a Reconnect CTA.

Connected

BannerTypes.SUCCESS, message from bannerSuccessKey, dismissible.

Showing one closes the other, and both are tracked in fields so they are never duplicated.

Amazon connector

bannerSuccessKey

MODALS.AP_AMAZON.SUCCESS

bannerFailKey

MODALS.AP_AMAZON.FAIL

storeKey

MarketplaceEnum.Amazon

Components

AmazonBannerComponent, AmazonModalComponent

Amazon’s OAuth handshake happens on Amazon’s domain, so the return is detected with a cookie rather than a query parameter.

AmazonConnectorCookieService

payability-amazon-connection

Set before leaving for Amazon Seller Central; its presence on return means "we just came back".

payability-amazon-connection-my-marketplace

Same, for a connection started from /my-marketplaces.

Domain

.payability.com

Max age

24 hours

Cookies are removed by rewriting them with max-age=-99999.

Constructor behaviour

this.isCookieAdded = this.amazonCookie.isAmazonCookieAdded;
if (this.isCookieAdded) {
    this.amazonCookie.removeAmazonCookie();
}
if (this.amazonCookie.isAmazonMyMarketplaceCookieAdded) {
    this.amazonCookie.removeAmazonMyMarketplaceCookie();
    this.router.navigate([URLS_ROUTER.MARKETPLACE.MY_MARKETPLACES],
                         { queryParams: { 'open-amazon': true } }).then();   (1)
}
1 A supplier who started the connection from /my-marketplaces is returned there with ?open-amazon=true, which reopens the Amazon panel — they land back exactly where they were.

Success detection

private isConnectionConfirmedAndUrlContainsMark(amazon: SupplierMarketplacesModel): boolean {
    return coerceBooleanProperty(amazon) && ApiStatus.Confirmed === amazon.apiStatus && this.isCookieAdded;
}

Confirmed status plus the return cookie means the reconnect just succeeded, so the success banner is shown instead of running the normal check.

handleModalResult() for Amazon unconditionally shows the reconnect banner. The supplier leaves for Amazon, so the modal closing is not evidence of success — confirmation only comes on the next load, via the cookie.

The cookie domain is hardcoded to .payability.com. On a localhost dev server the cookie is not set, so the Amazon return-detection path cannot be exercised locally.

Shopify connector

bannerSuccessKey

MODALS.AP_SHOPIFY.SUCCESS

bannerFailKey

MODALS.AP_SHOPIFY.FAIL

storeKey

MarketplaceEnum.Shopify

Components

ShopifyBannerComponent, ShopifyModalComponent

Shopify verifies inline instead — when the modal closes, the connector refetches the marketplace list and checks the result:

protected handleModalResult(): void {
    this.store.select(selectCurrentUserId).pipe(
        mergeMap(id => this.marketplacesService.getSupplierMarketplacesList(id)),
    ).subscribe((marketplaces: SupplierMarketplacesModel[]) => {
        this.store.dispatch(loadedSupplierMarketplaceSuccess({ payload: marketplaces }));
        const shopify = marketplaces?.find(m => m.marketplaceKey === MarketplaceEnum.Shopify);
        if (shopify?.apiStatus === 'confirmed') {
            this.preventCheck = true;
            this.unsubscribe();
            this.showConnectedBanner();
        } else {
            this.showReconnectBanner();
        }
    });
}

The comparison uses the string literal 'confirmed' rather than ApiStatus.Confirmed. Equivalent today, but it bypasses the enum.

DI tokens

export const AMAZON_API_CONNECTOR  = new InjectionToken<AmazonConnectorApiService>('amazon-api-connector');
export const SHOPIFY_API_CONNECTOR = new InjectionToken<ShopifyConnectorApiService>('shopify-api-connector');

Adding a connector

  1. Create core/api-connectors/<marketplace>/.

  2. Extend AbstractConnectorApi, supplying the five abstract members and handleModalResult().

  3. Build a banner component implementing AbstractConnectorBanner (verify()) and a modal implementing AbstractConnectorModal (connect()).

  4. Add MODALS.AP_<NAME>.SUCCESS and .FAIL translation keys.

  5. Add a permission to PermissionsEnum and permissions.const.ts, and grant it.

  6. Initialize the connector in ApiConnectorsService.init() behind that permission.