@nauticana/sail 1.1.12 → 1.1.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -8
- package/package.json +1 -1
- package/src/component/agency/agency_accept.html +33 -0
- package/src/component/agency/agency_accept.ts +94 -0
- package/src/component/agency/agency_clients.html +97 -0
- package/src/component/agency/agency_clients.ts +182 -0
- package/src/component/agency/agency_earnings_summary.html +24 -0
- package/src/component/agency/agency_earnings_summary.ts +15 -0
- package/src/component/agency/agency_payout_history.html +19 -0
- package/src/component/agency/agency_payout_history.ts +18 -0
- package/src/component/agency/agency_payout_profile.html +49 -0
- package/src/component/agency/agency_payout_profile.ts +101 -0
- package/src/component/agency/agency_status_banner.html +9 -0
- package/src/component/agency/agency_status_banner.ts +24 -0
- package/src/component/agency/commission_ledger.html +19 -0
- package/src/component/agency/commission_ledger.ts +18 -0
- package/src/component/login/login_component.html +8 -2
- package/src/component/payout/bank_info_form.ts +9 -3
- package/src/component/payout/provider_onboarding.ts +17 -5
- package/src/config.ts +2 -2
- package/src/index.ts +9 -0
- package/src/model/agency.ts +109 -0
- package/src/model/appdata.ts +10 -6
- package/src/service/agency.service.ts +76 -0
- package/src/service/payout.service.ts +27 -1
- package/src/service/rest_service.ts +1 -0
- package/src/service/rest_url.ts +16 -0
- package/src/util/errors.ts +25 -8
- package/src/util/money.ts +15 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
@if (loading()) {
|
|
2
|
+
<mat-spinner diameter="36" aria-label="Loading payout profile"></mat-spinner>
|
|
3
|
+
} @else {
|
|
4
|
+
<section class="agency-payout-profile__selection">
|
|
5
|
+
<h2>Payout destination</h2>
|
|
6
|
+
@if (profile(); as current) {
|
|
7
|
+
<p class="agency-payout-profile__current" role="status">
|
|
8
|
+
Current: {{ current.countryCode }} / {{ current.currency }} ({{ current.provider }})
|
|
9
|
+
</p>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
@if (destinations().length > 0) {
|
|
13
|
+
<mat-form-field appearance="outline">
|
|
14
|
+
<mat-label>Destination</mat-label>
|
|
15
|
+
<mat-select [value]="selectedDestinationID()" (selectionChange)="choose($event)">
|
|
16
|
+
@for (destination of destinations(); track destination.userBankInfoId) {
|
|
17
|
+
<mat-option [value]="destination.userBankInfoId">
|
|
18
|
+
{{ destination.countryCode }} / {{ destination.currency }} ({{ destination.provider }})
|
|
19
|
+
</mat-option>
|
|
20
|
+
}
|
|
21
|
+
</mat-select>
|
|
22
|
+
</mat-form-field>
|
|
23
|
+
<button matButton="filled" type="button" [disabled]="!selectedDestinationID() || saving()" (click)="save()">
|
|
24
|
+
Use for agency payouts
|
|
25
|
+
</button>
|
|
26
|
+
} @else if (!onboarding()) {
|
|
27
|
+
<p>No fully onboarded payout destination is available for this business.</p>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
<button matButton type="button" [disabled]="saving()" (click)="onboarding.set(!onboarding())">
|
|
31
|
+
{{ onboarding() ? 'Hide account setup' : 'Set up a payout account' }}
|
|
32
|
+
</button>
|
|
33
|
+
|
|
34
|
+
@if (pending()) {
|
|
35
|
+
<p role="status">The provider is waiting for account confirmation.</p>
|
|
36
|
+
}
|
|
37
|
+
@if (errorMessage()) {
|
|
38
|
+
<p class="agency-payout-profile__error" role="alert">{{ errorMessage() }}</p>
|
|
39
|
+
}
|
|
40
|
+
</section>
|
|
41
|
+
|
|
42
|
+
@if (onboarding()) {
|
|
43
|
+
<sail-payout-provider-onboarding
|
|
44
|
+
[showBack]="false"
|
|
45
|
+
(linked)="onboardingComplete()"
|
|
46
|
+
(pending)="pending.set(true)"
|
|
47
|
+
(skipped)="onboarding.set(false)" />
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, OnInit, inject, output, signal } from '@angular/core';
|
|
2
|
+
import { MatButtonModule } from '@angular/material/button';
|
|
3
|
+
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
4
|
+
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
|
5
|
+
import { MatSelectChange, MatSelectModule } from '@angular/material/select';
|
|
6
|
+
import { AgencyPayoutDestination, AgencyPayoutProfile } from '../../model/agency';
|
|
7
|
+
import { AgencyService } from '../../service/agency.service';
|
|
8
|
+
import { errorDetail } from '../../util/errors';
|
|
9
|
+
import { PayoutProviderOnboardingComponent } from '../payout/provider_onboarding';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Self-service selection of the fully onboarded destination that receives
|
|
13
|
+
* agency commission payouts. Routes and page composition remain app-owned.
|
|
14
|
+
*/
|
|
15
|
+
@Component({
|
|
16
|
+
selector: 'sail-agency-payout-profile',
|
|
17
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
18
|
+
imports: [
|
|
19
|
+
MatButtonModule,
|
|
20
|
+
MatFormFieldModule,
|
|
21
|
+
MatProgressSpinnerModule,
|
|
22
|
+
MatSelectModule,
|
|
23
|
+
PayoutProviderOnboardingComponent,
|
|
24
|
+
],
|
|
25
|
+
templateUrl: './agency_payout_profile.html',
|
|
26
|
+
host: { class: 'agency-payout-profile' },
|
|
27
|
+
})
|
|
28
|
+
export class AgencyPayoutProfileComponent implements OnInit {
|
|
29
|
+
readonly selected = output<AgencyPayoutProfile>();
|
|
30
|
+
|
|
31
|
+
private readonly agency = inject(AgencyService);
|
|
32
|
+
|
|
33
|
+
readonly loading = signal(true);
|
|
34
|
+
readonly saving = signal(false);
|
|
35
|
+
readonly onboarding = signal(false);
|
|
36
|
+
readonly pending = signal(false);
|
|
37
|
+
readonly errorMessage = signal('');
|
|
38
|
+
readonly profile = signal<AgencyPayoutProfile | null>(null);
|
|
39
|
+
readonly destinations = signal<AgencyPayoutDestination[]>([]);
|
|
40
|
+
readonly selectedDestinationID = signal<number | null>(null);
|
|
41
|
+
|
|
42
|
+
ngOnInit(): void {
|
|
43
|
+
this.reload();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
reload(): void {
|
|
47
|
+
this.loading.set(true);
|
|
48
|
+
this.errorMessage.set('');
|
|
49
|
+
this.agency.payoutProfile().subscribe({
|
|
50
|
+
next: ({ profile, destinations }) => {
|
|
51
|
+
const available = destinations ?? [];
|
|
52
|
+
this.profile.set(profile);
|
|
53
|
+
this.destinations.set(available);
|
|
54
|
+
this.selectedDestinationID.set(
|
|
55
|
+
available.some((item) => item.userBankInfoId === profile?.userBankInfoId)
|
|
56
|
+
? profile!.userBankInfoId
|
|
57
|
+
: available[0]?.userBankInfoId ?? null,
|
|
58
|
+
);
|
|
59
|
+
this.loading.set(false);
|
|
60
|
+
},
|
|
61
|
+
error: (error: unknown) => {
|
|
62
|
+
this.loading.set(false);
|
|
63
|
+
this.errorMessage.set(errorDetail(error, 'Could not load the payout profile.'));
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
choose(event: MatSelectChange): void {
|
|
69
|
+
this.selectedDestinationID.set(Number(event.value));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
save(): void {
|
|
73
|
+
const destinationID = this.selectedDestinationID();
|
|
74
|
+
if (!destinationID || this.saving()) return;
|
|
75
|
+
this.saving.set(true);
|
|
76
|
+
this.errorMessage.set('');
|
|
77
|
+
this.agency.setPayoutProfile(destinationID).subscribe({
|
|
78
|
+
next: () => {
|
|
79
|
+
this.saving.set(false);
|
|
80
|
+
this.agency.payoutProfile().subscribe({
|
|
81
|
+
next: ({ profile, destinations }) => {
|
|
82
|
+
this.profile.set(profile);
|
|
83
|
+
this.destinations.set(destinations ?? []);
|
|
84
|
+
if (profile) this.selected.emit(profile);
|
|
85
|
+
},
|
|
86
|
+
error: (error: unknown) => this.errorMessage.set(errorDetail(error, 'The destination was saved, but its status could not be refreshed.')),
|
|
87
|
+
});
|
|
88
|
+
},
|
|
89
|
+
error: (error: unknown) => {
|
|
90
|
+
this.saving.set(false);
|
|
91
|
+
this.errorMessage.set(errorDetail(error, 'Could not select the payout destination.'));
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
onboardingComplete(): void {
|
|
97
|
+
this.onboarding.set(false);
|
|
98
|
+
this.pending.set(false);
|
|
99
|
+
this.reload();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
@if (pending()) {
|
|
2
|
+
<div class="agency-status-banner__notice" data-agency-status="pending" role="status">
|
|
3
|
+
{{ pendingMessage() }}
|
|
4
|
+
</div>
|
|
5
|
+
} @else if (suspended()) {
|
|
6
|
+
<div class="agency-status-banner__notice" data-agency-status="suspended" role="alert">
|
|
7
|
+
{{ suspendedMessage() }}
|
|
8
|
+
</div>
|
|
9
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
|
|
2
|
+
import { AgencyProfile } from '../../model/agency';
|
|
3
|
+
|
|
4
|
+
@Component({
|
|
5
|
+
selector: 'sail-agency-status-banner',
|
|
6
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
7
|
+
templateUrl: './agency_status_banner.html',
|
|
8
|
+
host: { class: 'agency-status-banner' },
|
|
9
|
+
})
|
|
10
|
+
export class AgencyStatusBannerComponent {
|
|
11
|
+
readonly profile = input<AgencyProfile | null>(null);
|
|
12
|
+
readonly pendingMessage = input('Agency approval is pending. Client management is read-only until approval.');
|
|
13
|
+
readonly suspendedMessage = input('Agency access is suspended. New invitations and earnings are paused.');
|
|
14
|
+
|
|
15
|
+
readonly pending = computed(() => {
|
|
16
|
+
const profile = this.profile();
|
|
17
|
+
return profile !== null && !profile.approvedAt && !profile.suspended;
|
|
18
|
+
});
|
|
19
|
+
readonly suspended = computed(() => this.profile()?.suspended === true);
|
|
20
|
+
readonly canManage = computed(() => {
|
|
21
|
+
const profile = this.profile();
|
|
22
|
+
return profile !== null && Boolean(profile.approvedAt) && !profile.suspended;
|
|
23
|
+
});
|
|
24
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
@if (entries().length > 0) {
|
|
2
|
+
<div class="commission-ledger__table-wrap">
|
|
3
|
+
<table class="commission-ledger__table">
|
|
4
|
+
<thead><tr><th>Client</th><th>Service period</th><th>Amount</th><th>Status</th></tr></thead>
|
|
5
|
+
<tbody>
|
|
6
|
+
@for (entry of entries(); track entry.id) {
|
|
7
|
+
<tr [class.commission-ledger__reversal]="entry.entryType === 'R'">
|
|
8
|
+
<td>{{ entry.clientName }}</td>
|
|
9
|
+
<td>{{ entry.periodStart | date: 'yyyy-MM-dd':'UTC' }} – {{ entry.periodEnd | date: 'yyyy-MM-dd':'UTC' }}</td>
|
|
10
|
+
<td>{{ format(entry.amountMinor, entry.currency) }}</td>
|
|
11
|
+
<td><sail-status-chip domain="agency_commission_status" [value]="entry.status" /></td>
|
|
12
|
+
</tr>
|
|
13
|
+
}
|
|
14
|
+
</tbody>
|
|
15
|
+
</table>
|
|
16
|
+
</div>
|
|
17
|
+
} @else if (emptyMessage()) {
|
|
18
|
+
<p class="commission-ledger__empty">{{ emptyMessage() }}</p>
|
|
19
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { DatePipe } from '@angular/common';
|
|
2
|
+
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
|
3
|
+
import { AgencyEarningEntry } from '../../model/agency';
|
|
4
|
+
import { formatMinorCurrency } from '../../util/money';
|
|
5
|
+
import { StatusChipComponent } from '../billing/status_chip';
|
|
6
|
+
|
|
7
|
+
@Component({
|
|
8
|
+
selector: 'sail-commission-ledger',
|
|
9
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
10
|
+
imports: [DatePipe, StatusChipComponent],
|
|
11
|
+
templateUrl: './commission_ledger.html',
|
|
12
|
+
host: { class: 'commission-ledger' },
|
|
13
|
+
})
|
|
14
|
+
export class CommissionLedgerComponent {
|
|
15
|
+
readonly entries = input<AgencyEarningEntry[]>([]);
|
|
16
|
+
readonly emptyMessage = input('No commission activity yet.');
|
|
17
|
+
readonly format = formatMinorCurrency;
|
|
18
|
+
}
|
|
@@ -38,11 +38,17 @@
|
|
|
38
38
|
<div class="auth-actions">
|
|
39
39
|
<a routerLink="/login/chpass">I forgot my password</a>
|
|
40
40
|
</div>
|
|
41
|
-
@if (guiConfig.loginFooterLinks
|
|
41
|
+
@if (guiConfig.loginFooterLinks?.length || guiConfig.termsUrl || guiConfig.privacyPolicyUrl) {
|
|
42
42
|
<div class="auth-footer">
|
|
43
|
-
@for (link of guiConfig.loginFooterLinks; track link.routerLink) {
|
|
43
|
+
@for (link of guiConfig.loginFooterLinks ?? []; track link.routerLink) {
|
|
44
44
|
<a [routerLink]="link.routerLink">{{ link.label }}</a>
|
|
45
45
|
}
|
|
46
|
+
@if (guiConfig.termsUrl) {
|
|
47
|
+
<a [href]="guiConfig.termsUrl" target="_blank" rel="noopener">Terms of Service</a>
|
|
48
|
+
}
|
|
49
|
+
@if (guiConfig.privacyPolicyUrl) {
|
|
50
|
+
<a [href]="guiConfig.privacyPolicyUrl" target="_blank" rel="noopener">Privacy Policy</a>
|
|
51
|
+
}
|
|
46
52
|
</div>
|
|
47
53
|
}
|
|
48
54
|
</div>
|
|
@@ -26,8 +26,10 @@ export const DEFAULT_COUNTRY_PROFILES: CountryProfile[] = [
|
|
|
26
26
|
*
|
|
27
27
|
* Captures the basis `user_bank_info` fields (country, currency, account
|
|
28
28
|
* holder, tax id, billing address, agreement). Emits the form value via
|
|
29
|
-
* `(submitted)` —
|
|
30
|
-
*
|
|
29
|
+
* `(submitted)` — first-time setup POSTs through the app's registration
|
|
30
|
+
* service; CHANGES to an existing destination go through
|
|
31
|
+
* PayoutService.replaceBankInfo (atomic version replacement), never a
|
|
32
|
+
* generic CRUD edit.
|
|
31
33
|
*
|
|
32
34
|
* Ships no CSS — the consuming app styles the classes globally.
|
|
33
35
|
*
|
|
@@ -58,7 +60,11 @@ export class PayoutBankInfoFormComponent {
|
|
|
58
60
|
/** When true, the back button renders alongside submit. */
|
|
59
61
|
readonly showBack = input(true);
|
|
60
62
|
|
|
61
|
-
/**
|
|
63
|
+
/**
|
|
64
|
+
* Emitted on submit as application-layer input. The consumer must use its
|
|
65
|
+
* registration service for first-time setup or PayoutService.replaceBankInfo
|
|
66
|
+
* for changes; never send this payload through generic CRUD.
|
|
67
|
+
*/
|
|
62
68
|
readonly submitted = output<BankInfoFormValue>();
|
|
63
69
|
/** Emitted when the user clicks Back. */
|
|
64
70
|
readonly back = output<void>();
|
|
@@ -14,12 +14,13 @@ import { ReusableAccount } from '../../model/appdata';
|
|
|
14
14
|
* two paths:
|
|
15
15
|
* 1. Reuse an existing provider account from another partner row
|
|
16
16
|
* (zero-KYC; calls /api/v1/payout/reusable/link).
|
|
17
|
-
* 2.
|
|
18
|
-
*
|
|
17
|
+
* 2. Start provider onboarding (/api/v1/payout/onboard/start), then
|
|
18
|
+
* either hand off to a hosted-KYC page or emit `pending` when the
|
|
19
|
+
* provider continues asynchronously without a hosted URL.
|
|
19
20
|
*
|
|
20
|
-
* Routing is consumer-owned via the `(linked)
|
|
21
|
-
* the component itself never calls Router.
|
|
22
|
-
* `skipLabel` inputs cover the common UX strings.
|
|
21
|
+
* Routing is consumer-owned via the `(linked)`, `(pending)`, and
|
|
22
|
+
* `(skipped)` outputs — the component itself never calls Router.
|
|
23
|
+
* Optional `title` and `skipLabel` inputs cover the common UX strings.
|
|
23
24
|
*
|
|
24
25
|
* Ships no CSS — the consuming app styles the classes globally.
|
|
25
26
|
*
|
|
@@ -47,6 +48,13 @@ export class PayoutProviderOnboardingComponent extends BaseAsync implements OnIn
|
|
|
47
48
|
readonly back = output<void>();
|
|
48
49
|
/** Emitted just before handing off to the hosted-KYC page. */
|
|
49
50
|
readonly started = output<void>();
|
|
51
|
+
/**
|
|
52
|
+
* Emitted when onboarding started but there is no hosted page to open
|
|
53
|
+
* (Wise email recipients: the account is linked and awaits the
|
|
54
|
+
* recipient's email confirmation). Consumers show a "linked, awaiting
|
|
55
|
+
* confirmation" state instead of navigating.
|
|
56
|
+
*/
|
|
57
|
+
readonly pending = output<void>();
|
|
50
58
|
|
|
51
59
|
private readonly payoutService = inject(PayoutService);
|
|
52
60
|
|
|
@@ -79,6 +87,10 @@ export class PayoutProviderOnboardingComponent extends BaseAsync implements OnIn
|
|
|
79
87
|
this.run(
|
|
80
88
|
this.payoutService.startOnboarding(),
|
|
81
89
|
(res) => {
|
|
90
|
+
if (!res.url) {
|
|
91
|
+
this.pending.emit();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
82
94
|
this.started.emit();
|
|
83
95
|
// Same-tab handoff: window.open from an async callback is popup-blocked
|
|
84
96
|
// (always on Safari), which used to leave users on a "waiting" screen
|
package/src/config.ts
CHANGED
|
@@ -53,9 +53,9 @@ export interface SailGuiConfig {
|
|
|
53
53
|
appleServiceId?: string;
|
|
54
54
|
/** Apple Sign-In redirect URI. Must match the Services ID configuration. */
|
|
55
55
|
appleRedirectUri?: string;
|
|
56
|
-
/** Public URL of your privacy policy.
|
|
56
|
+
/** Public URL of your privacy policy. Linked from ConsentGateComponent and the login footer. */
|
|
57
57
|
privacyPolicyUrl?: string;
|
|
58
|
-
/** Public URL of your terms of service.
|
|
58
|
+
/** Public URL of your terms of service. Linked from the SMS-consent disclosure and the login footer. */
|
|
59
59
|
termsUrl?: string;
|
|
60
60
|
/** Brand name shown in the SMS/10DLC consent disclosure (e.g. "Daxoom"). */
|
|
61
61
|
appName?: string;
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ export * from './model/auth';
|
|
|
4
4
|
export * from './model/common';
|
|
5
5
|
export * from './model/dashboard';
|
|
6
6
|
export * from './model/oauth_connection';
|
|
7
|
+
export * from './model/agency';
|
|
7
8
|
export * from './model/decorator';
|
|
8
9
|
export * from './component/abstract/base_table';
|
|
9
10
|
export * from './component/dashboard/dashboard_shell';
|
|
@@ -53,12 +54,20 @@ export * from './component/billing/seat_selector';
|
|
|
53
54
|
export * from './component/billing/dunning_banner';
|
|
54
55
|
export * from './component/payout/provider_onboarding';
|
|
55
56
|
export * from './component/payout/bank_info_form';
|
|
57
|
+
export * from './component/agency/agency_status_banner';
|
|
58
|
+
export * from './component/agency/agency_clients';
|
|
59
|
+
export * from './component/agency/agency_accept';
|
|
60
|
+
export * from './component/agency/agency_earnings_summary';
|
|
61
|
+
export * from './component/agency/commission_ledger';
|
|
62
|
+
export * from './component/agency/agency_payout_history';
|
|
63
|
+
export * from './component/agency/agency_payout_profile';
|
|
56
64
|
export * from './component/payment/user_payment_methods';
|
|
57
65
|
export * from './component/payment/sca_confirm';
|
|
58
66
|
export * from './service/auth.service';
|
|
59
67
|
export * from './service/sca_confirmer';
|
|
60
68
|
export * from './service/billing.service';
|
|
61
69
|
export * from './service/payout.service';
|
|
70
|
+
export * from './service/agency.service';
|
|
62
71
|
export * from './service/oauth_connection.service';
|
|
63
72
|
export * from './service/user_payment_method.service';
|
|
64
73
|
export * from './service/script_loader';
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
export interface AgencyProfile {
|
|
2
|
+
agencyPartnerId: number;
|
|
3
|
+
wholesaleAllowed: boolean;
|
|
4
|
+
defaultCommissionRateBp?: number;
|
|
5
|
+
approvedAt?: string;
|
|
6
|
+
suspended: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface AgencyClientInput {
|
|
10
|
+
name: string;
|
|
11
|
+
email?: string;
|
|
12
|
+
website?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface AgencyClient {
|
|
16
|
+
id: number;
|
|
17
|
+
name: string;
|
|
18
|
+
email?: string;
|
|
19
|
+
website?: string;
|
|
20
|
+
status: string;
|
|
21
|
+
clientPartnerId?: number;
|
|
22
|
+
acceptedBy?: number;
|
|
23
|
+
invitedAt?: string;
|
|
24
|
+
acceptedAt?: string;
|
|
25
|
+
expired?: boolean;
|
|
26
|
+
billingModel?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface AgencyInvite {
|
|
30
|
+
invitationId: number;
|
|
31
|
+
agencyName: string;
|
|
32
|
+
clientName: string;
|
|
33
|
+
status: string;
|
|
34
|
+
expired?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface AgencyDelegation {
|
|
38
|
+
id: number;
|
|
39
|
+
clientPartnerId: number;
|
|
40
|
+
clientName: string;
|
|
41
|
+
agencyPartnerId: number;
|
|
42
|
+
agencyName: string;
|
|
43
|
+
billingModel?: string;
|
|
44
|
+
grantedAt: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface AgencyEarningsBalance {
|
|
48
|
+
currency: string;
|
|
49
|
+
heldMinor: number;
|
|
50
|
+
dueMinor: number;
|
|
51
|
+
paidMinor: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface AgencyEarningEntry {
|
|
55
|
+
id: number;
|
|
56
|
+
clientName: string;
|
|
57
|
+
periodStart: string;
|
|
58
|
+
periodEnd: string;
|
|
59
|
+
amountMinor: number;
|
|
60
|
+
currency: string;
|
|
61
|
+
entryType: string;
|
|
62
|
+
status: string;
|
|
63
|
+
earnedAt: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface AgencyPayout {
|
|
67
|
+
id: number;
|
|
68
|
+
amountMinor: number;
|
|
69
|
+
currency: string;
|
|
70
|
+
status: string;
|
|
71
|
+
cutoffAt: string;
|
|
72
|
+
paidAt?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface AgencyPayoutProfile {
|
|
76
|
+
agencyPartnerId: number;
|
|
77
|
+
userBankInfoId: number;
|
|
78
|
+
countryCode: string;
|
|
79
|
+
currency: string;
|
|
80
|
+
provider: string;
|
|
81
|
+
providerAccountId: string;
|
|
82
|
+
status: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface AgencyPayoutDestination {
|
|
86
|
+
userBankInfoId: number;
|
|
87
|
+
countryCode: string;
|
|
88
|
+
currency: string;
|
|
89
|
+
provider: string;
|
|
90
|
+
providerAccountId: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface AgencyPayoutProfileOptions {
|
|
94
|
+
profile: AgencyPayoutProfile | null;
|
|
95
|
+
destinations: AgencyPayoutDestination[];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface AgencyEarnings {
|
|
99
|
+
balances: AgencyEarningsBalance[];
|
|
100
|
+
entries: AgencyEarningEntry[];
|
|
101
|
+
payouts: AgencyPayout[];
|
|
102
|
+
payingClients: number;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface AgencyClientColumn {
|
|
106
|
+
id: string;
|
|
107
|
+
caption: string;
|
|
108
|
+
value: (client: AgencyClient) => string;
|
|
109
|
+
}
|
package/src/model/appdata.ts
CHANGED
|
@@ -377,18 +377,22 @@ export interface ReusableAccount {
|
|
|
377
377
|
onboardedAt: string;
|
|
378
378
|
}
|
|
379
379
|
|
|
380
|
-
// Result of PayoutService.startOnboarding(). url is opened in a
|
|
381
|
-
// external browser
|
|
382
|
-
//
|
|
380
|
+
// Result of PayoutService.startOnboarding(). A non-empty url is opened in a
|
|
381
|
+
// webview / external browser. Some providers have no hosted flow (for example,
|
|
382
|
+
// Wise email recipients); in that case url and expiresAt are empty and
|
|
383
|
+
// PayoutProviderOnboardingComponent emits `pending` while confirmation
|
|
384
|
+
// continues asynchronously.
|
|
383
385
|
export interface PayoutOnboardingSession {
|
|
384
386
|
url: string;
|
|
385
387
|
externalAccountId: string;
|
|
386
388
|
expiresAt: string;
|
|
387
389
|
}
|
|
388
390
|
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
//
|
|
391
|
+
// Application-layer payload emitted by the bank-info form. It is not a
|
|
392
|
+
// generic-CRUD row: taxId must be sealed into basis.user_bank_info's
|
|
393
|
+
// tax_id_encrypted column. First-time setup goes through the consuming app's
|
|
394
|
+
// registration service; changes use PayoutService.replaceBankInfo so keel can
|
|
395
|
+
// atomically supersede the active destination version.
|
|
392
396
|
export interface BankInfoFormValue {
|
|
393
397
|
countryCode: string;
|
|
394
398
|
currency: string;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { Injectable } from '@angular/core';
|
|
2
|
+
import { Observable } from 'rxjs';
|
|
3
|
+
import {
|
|
4
|
+
AgencyClient,
|
|
5
|
+
AgencyClientInput,
|
|
6
|
+
AgencyDelegation,
|
|
7
|
+
AgencyEarnings,
|
|
8
|
+
AgencyInvite,
|
|
9
|
+
AgencyPayoutProfileOptions,
|
|
10
|
+
AgencyProfile,
|
|
11
|
+
} from '../model/agency';
|
|
12
|
+
import { BaseRestService } from './base_rest.service';
|
|
13
|
+
import { RestURL } from './rest_url';
|
|
14
|
+
|
|
15
|
+
/** Thin REST client for keel/agency. */
|
|
16
|
+
@Injectable({ providedIn: 'root' })
|
|
17
|
+
export class AgencyService extends BaseRestService {
|
|
18
|
+
enroll(): Observable<{ message: string }> {
|
|
19
|
+
return this.http.post<{ message: string }>(this.url(RestURL.agencyEnrollURL), {});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
profile(): Observable<{ profile: AgencyProfile | null }> {
|
|
23
|
+
return this.http.get<{ profile: AgencyProfile | null }>(this.url(RestURL.agencyProfileURL));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
clients(): Observable<{ items: AgencyClient[]; activeClients: number }> {
|
|
27
|
+
return this.http.get<{ items: AgencyClient[]; activeClients: number }>(this.url(RestURL.agencyClientsURL));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
addClients(clients: AgencyClientInput[]): Observable<{ added: number }> {
|
|
31
|
+
return this.http.post<{ added: number }>(this.url(RestURL.agencyClientsURL), { clients });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
invite(invitationId: number): Observable<{ inviteLink: string }> {
|
|
35
|
+
return this.http.post<{ inviteLink: string }>(this.url(RestURL.agencyInviteClientURL), { invitationId });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
cancel(invitationId: number): Observable<{ status: string }> {
|
|
39
|
+
return this.http.post<{ status: string }>(this.url(RestURL.agencyCancelClientURL), { invitationId });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
setBillingModel(clientPartnerId: number, billingModel: 'R' | 'W'): Observable<{ billingModel: string }> {
|
|
43
|
+
return this.http.post<{ billingModel: string }>(this.url(RestURL.agencyBillingModelURL), {
|
|
44
|
+
clientPartnerId,
|
|
45
|
+
billingModel,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
earnings(): Observable<AgencyEarnings> {
|
|
50
|
+
return this.http.get<AgencyEarnings>(this.url(RestURL.agencyEarningsURL));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
payoutProfile(): Observable<AgencyPayoutProfileOptions> {
|
|
54
|
+
return this.http.get<AgencyPayoutProfileOptions>(this.url(RestURL.agencyPayoutProfileURL));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
setPayoutProfile(userBankInfoId: number): Observable<{ status: string }> {
|
|
58
|
+
return this.http.post<{ status: string }>(this.url(RestURL.agencyPayoutProfileURL), { userBankInfoId });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
inviteInfo(token: string): Observable<AgencyInvite> {
|
|
62
|
+
return this.http.get<AgencyInvite>(this.url(RestURL.agencyInviteInfoURL), { params: { token } });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
accept(token: string): Observable<{ status: string }> {
|
|
66
|
+
return this.http.post<{ status: string }>(this.url(RestURL.agencyAcceptInviteURL), { token });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
delegation(): Observable<{ delegation: AgencyDelegation | null }> {
|
|
70
|
+
return this.http.get<{ delegation: AgencyDelegation | null }>(this.url(RestURL.agencyDelegationURL));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
revokeDelegation(clientPartnerId = 0): Observable<{ status: string }> {
|
|
74
|
+
return this.http.post<{ status: string }>(this.url(RestURL.agencyRevokeDelegationURL), { clientPartnerId });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -3,6 +3,7 @@ import { Observable } from 'rxjs';
|
|
|
3
3
|
import { RestURL } from './rest_url';
|
|
4
4
|
import { BaseRestService } from './base_rest.service';
|
|
5
5
|
import {
|
|
6
|
+
BankInfoFormValue,
|
|
6
7
|
PayoutOnboardingSession,
|
|
7
8
|
ReusableAccount,
|
|
8
9
|
} from '../model/appdata';
|
|
@@ -20,7 +21,11 @@ import {
|
|
|
20
21
|
*/
|
|
21
22
|
@Injectable({ providedIn: 'root' })
|
|
22
23
|
export class PayoutService extends BaseRestService {
|
|
23
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* Start provider onboarding. A non-empty URL is the hosted-KYC handoff;
|
|
26
|
+
* an empty URL means confirmation continues asynchronously (for example,
|
|
27
|
+
* a Wise recipient confirming from email).
|
|
28
|
+
*/
|
|
24
29
|
startOnboarding(): Observable<PayoutOnboardingSession> {
|
|
25
30
|
return this.http.post<PayoutOnboardingSession>(this.url(RestURL.payoutOnboardStartURL), {});
|
|
26
31
|
}
|
|
@@ -39,4 +44,25 @@ export class PayoutService extends BaseRestService {
|
|
|
39
44
|
status(): Observable<{ complete: boolean }> {
|
|
40
45
|
return this.http.post<{ complete: boolean }>(this.url(RestURL.payoutStatusURL), {});
|
|
41
46
|
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Replace the payout destination with a new bank-info version
|
|
50
|
+
* (atomic supersede + insert on the backend). Identity changes go
|
|
51
|
+
* through this, never through generic CRUD edits. `provider` and
|
|
52
|
+
* `providerAgreement` are server-owned and ignored here.
|
|
53
|
+
*/
|
|
54
|
+
replaceBankInfo(value: BankInfoFormValue): Observable<{ message: string }> {
|
|
55
|
+
const { countryCode, currency, accountHolderName, billingAddress, taxIdType, taxId } = value;
|
|
56
|
+
return this.http.post<{ message: string }>(this.url(RestURL.payoutBankReplaceURL),
|
|
57
|
+
{ countryCode, currency, accountHolderName, billingAddress, taxIdType, taxId });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Register a payout beneficiary from provider-collected details
|
|
62
|
+
* (Airwallex embedded beneficiary component). The payload passes
|
|
63
|
+
* through to the provider; the backend links the returned id.
|
|
64
|
+
*/
|
|
65
|
+
registerBeneficiary(beneficiary: unknown): Observable<{ beneficiaryId: string }> {
|
|
66
|
+
return this.http.post<{ beneficiaryId: string }>(this.url(RestURL.payoutBeneficiaryURL), { beneficiary });
|
|
67
|
+
}
|
|
42
68
|
}
|
package/src/service/rest_url.ts
CHANGED
|
@@ -53,6 +53,22 @@ export const RestURL = {
|
|
|
53
53
|
payoutReusableURL: '/api/v1/payout/reusable',
|
|
54
54
|
payoutReusableLinkURL: '/api/v1/payout/reusable/link',
|
|
55
55
|
payoutStatusURL: '/api/v1/payout/status',
|
|
56
|
+
payoutBankReplaceURL: '/api/v1/payout/bank/replace',
|
|
57
|
+
payoutBeneficiaryURL: '/api/v1/payout/beneficiary/register',
|
|
58
|
+
// Agency — keel/agency endpoints. The authenticated paths follow the same
|
|
59
|
+
// conventional /api/v1 mount as payout; inviteInfo is intentionally public.
|
|
60
|
+
agencyEnrollURL: '/api/v1/agency/enroll',
|
|
61
|
+
agencyProfileURL: '/api/v1/agency/profile',
|
|
62
|
+
agencyClientsURL: '/api/v1/agency/clients',
|
|
63
|
+
agencyInviteClientURL: '/api/v1/agency/clients/invite',
|
|
64
|
+
agencyCancelClientURL: '/api/v1/agency/clients/cancel',
|
|
65
|
+
agencyBillingModelURL: '/api/v1/agency/billing-model',
|
|
66
|
+
agencyEarningsURL: '/api/v1/agency/earnings',
|
|
67
|
+
agencyPayoutProfileURL: '/api/v1/agency/payout-profile',
|
|
68
|
+
agencyInviteInfoURL: '/public/agency/invite',
|
|
69
|
+
agencyAcceptInviteURL: '/api/v1/agency/invite/accept',
|
|
70
|
+
agencyDelegationURL: '/api/v1/agency/delegation',
|
|
71
|
+
agencyRevokeDelegationURL: '/api/v1/agency/delegation/revoke',
|
|
56
72
|
// User payment methods — list/delete go through keel's generic REST
|
|
57
73
|
// CRUD against the UserSpecific basis table; set-default is a
|
|
58
74
|
// TableAction (basis table_action row), mounted at the conventional
|