@nauticana/sail 1.1.13 → 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 +72 -3
- 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/index.ts +9 -0
- package/src/model/agency.ts +109 -0
- package/src/service/agency.service.ts +76 -0
- package/src/service/rest_service.ts +1 -0
- package/src/service/rest_url.ts +14 -0
- package/src/util/errors.ts +25 -8
- package/src/util/money.ts +15 -0
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
A shared Angular component library for building CRUD-based admin frontends. Provides table management, form handling, navigation, authentication, two-factor authentication, and trusted device management — all driven by metadata from a [keel](https://github.com/nauticana/keel) Go backend.
|
|
4
4
|
|
|
5
|
-
> **Compatibility:** sail and keel are versioned in lock-step. The current line is **sail v1.1.x ↔ keel v1.2.x
|
|
5
|
+
> **Compatibility:** sail and keel are versioned in lock-step. The current line is **sail v1.1.x ↔ keel v1.2.x**; the agency components in sail v1.1.14 require keel v1.2.41 (sail v1.1.9 needs keel v1.2.16 for the OAuth-connect layer). Earlier lines: **sail v0.5.x ↔ keel v0.5.x**, **sail v0.6.x / v0.7.x ↔ keel v0.7.x**, **sail v0.8.x ↔ keel v0.8.x**, **sail v0.9.x ↔ keel v0.9.x**. Newer sail releases extend the contract — older keel servers reject unknown endpoints with HTTP 404 / 400. The v0.8.x line additionally ships the `table_action` framework (per-table custom buttons surfaced in `TableList` / `TableSearch` / `TableEdit` / `TableDetail`); see the [Migrating to v0.7.0 §5 — TableAction](#migrating-to-v070--payout-user-payment-methods-table-actions) section for the seed shape (basis `table_action` + `authorization_object` + `authorization_object_action` rows) and the [keel/README Table Actions](https://github.com/nauticana/keel#table-actions) section for backend wiring via `handler.WrapTableAction`.
|
|
6
6
|
|
|
7
7
|
**Recent additions:** `BaseAuthService.acceptToken(jwt)` — adopt an externally-minted JWT (registration / SSO-handoff flows) and run the full post-login sequence (store under the canonical `jwt` key, load appdata, init routes); apps must use this instead of writing `localStorage` directly. `BaseRestService.analytic<T>(endpoint, params?)` — GET a keel `analytic/<endpoint>` report and return its rows. `passwordPolicyValidator` + `BaseAuthService.ensurePasswordPolicy()` — validate passwords against keel's policy (via `SailGuiConfig.passwordPolicyUrl`); see Configuration reference.
|
|
8
8
|
|
|
@@ -22,13 +22,14 @@ A shared Angular component library for building CRUD-based admin frontends. Prov
|
|
|
22
22
|
| **Billing** | `PlanSelectorComponent`, `PriceSelectorComponent`, `CheckoutButtonComponent`, `PaymentMethodsComponent`, `PortalButtonComponent`, `UsageMeterComponent`, `StatusChipComponent`, `TrialBannerComponent`, `SeatSelectorComponent`, `DunningBannerComponent` |
|
|
23
23
|
| **Dashboard** | `DashboardShellComponent`, `DataCardComponent`, `LockedOverlayComponent`, `ActionCenterComponent`, `EntitySelectorComponent`, `VerificationFlowComponent` |
|
|
24
24
|
| **Payout** | `PayoutProviderOnboardingComponent`, `PayoutBankInfoFormComponent` |
|
|
25
|
+
| **Agency** | `AgencyClientsComponent`, `AgencyStatusBannerComponent`, `AgencyAcceptComponent`, `AgencyEarningsSummaryComponent`, `CommissionLedgerComponent`, `AgencyPayoutHistoryComponent`, `AgencyPayoutProfileComponent` |
|
|
25
26
|
| **Payments / SCA** | `UserPaymentMethodsComponent`, `ScaConfirmComponent`, `ScaConfirmer` (port), `SCA_CONFIRMER` (token) |
|
|
26
|
-
| **Services** | `BaseAuthService` (OTP / social / push / deleteAccount / logoutEverywhere / profile: `getProfile`, `updateProfile`, `request`+`confirmEmailChange`, `request`+`confirmPhoneChange`), `BillingService`, `BackendService`, `PayoutService`, `UserPaymentMethodService`, `loadScript()`, `authInterceptor`, `apiResponseInterceptor` |
|
|
27
|
+
| **Services** | `BaseAuthService` (OTP / social / push / deleteAccount / logoutEverywhere / profile: `getProfile`, `updateProfile`, `request`+`confirmEmailChange`, `request`+`confirmPhoneChange`), `BillingService`, `AgencyService`, `BackendService`, `PayoutService`, `UserPaymentMethodService`, `loadScript()`, `authInterceptor`, `apiResponseInterceptor` |
|
|
27
28
|
| **Abstracts** | `BaseTable`, `BaseForm`, `BaseView`, `AbstractEditableView`, `BaseAsync`, `BaseRestService` |
|
|
28
29
|
| **Config** | `SAIL_GUI_CONFIG`, `SailGuiConfig`, `configureRestUrls()` |
|
|
29
30
|
| **Models** | `ApplicationData`, `TableDefinition`, `SiudAction`, `ApplicationMenu`, `ConstantValue`, `UserAccount`, `RestReport`, `ReportParam`, `TrustedDevice`, `PublicPlan`, `PlanPrice`, `PaymentMethod`, `Subscription`, `Invoice`, `CheckoutRequest`/`CheckoutResponse`, `PortalResponse`, `UsageMeter`, `ChargeResult`, `UserPaymentMethod`, `TableAction`, `ReusableAccount`, `PayoutOnboardingSession`, `BankInfoFormValue`, `CountryProfile`, `OtpRequest`/`OtpResponse`, `SignupConsent`, `ConsentState`, `ConsentOption`, `SocialProvider`, `PushPlatform`, 2FA types, etc. |
|
|
30
31
|
| **Decorators** | `@IsSailString()`, `@IsSailNumeric()` (class-validator based) |
|
|
31
|
-
| **Utils** | `titleCase()`, `fromPriceLabel()` |
|
|
32
|
+
| **Utils** | `titleCase()`, `fromPriceLabel()`, `formatMinorCurrency()` |
|
|
32
33
|
|
|
33
34
|
## Quick start
|
|
34
35
|
|
|
@@ -590,6 +591,74 @@ A complete screen is the pieces above assembled over `BillingService` — no met
|
|
|
590
591
|
<!-- invoices: render listInvoices() rows directly -->
|
|
591
592
|
```
|
|
592
593
|
|
|
594
|
+
## Agency / reseller UI (v1.1.14)
|
|
595
|
+
|
|
596
|
+
sail v1.1.14 supplies composable, unstyled agency components over keel v1.2.41.
|
|
597
|
+
The app owns routes, route guards, brand copy, global CSS, and any columns that
|
|
598
|
+
refer to an app-specific managed entity.
|
|
599
|
+
|
|
600
|
+
`AgencyService` covers profile enrollment, invitation staging/acceptance,
|
|
601
|
+
delegation/revocation, per-client referral/wholesale selection, earnings, and
|
|
602
|
+
payout-profile selection. Its DTOs use the upstream names
|
|
603
|
+
`agency_client_invitation`, `agency_client_delegation`, and
|
|
604
|
+
`agency_client_rate`; there is no client-side rule-schedule model.
|
|
605
|
+
The enrollment method is `enroll()`; downstream `enrollReferral()` calls must
|
|
606
|
+
be renamed when adopting this release.
|
|
607
|
+
|
|
608
|
+
Compose an agency page from the pieces you need:
|
|
609
|
+
|
|
610
|
+
```html
|
|
611
|
+
<sail-agency-clients
|
|
612
|
+
[extraColumns]="clientColumns"
|
|
613
|
+
[showBillingModelControls]="true" />
|
|
614
|
+
<sail-agency-earnings-summary [earnings]="earnings()" [activeClients]="activeClients()" />
|
|
615
|
+
<sail-commission-ledger [entries]="earnings()?.entries ?? []" />
|
|
616
|
+
<sail-agency-payout-history [payouts]="earnings()?.payouts ?? []" />
|
|
617
|
+
<sail-agency-payout-profile />
|
|
618
|
+
```
|
|
619
|
+
|
|
620
|
+
`AgencyClientsComponent` renders status values through the shared
|
|
621
|
+
`StatusChipComponent` and backend constant catalogues. It accepts app-specific
|
|
622
|
+
columns rather than assuming the managed entity. Accepted clients begin in
|
|
623
|
+
referral mode; wholesale controls appear only when the profile permits them and
|
|
624
|
+
the app opts in with `showBillingModelControls`.
|
|
625
|
+
|
|
626
|
+
Load keel's v1.2.40 basis seed so status chips receive these canonical domains:
|
|
627
|
+
|
|
628
|
+
| Domain | Values |
|
|
629
|
+
|---|---|
|
|
630
|
+
| `agency_client_status` | `S` Staged, `I` Invited, `V` Client, `X` Cancelled |
|
|
631
|
+
| `agency_billing_model` | `R` Referral, `W` Wholesale |
|
|
632
|
+
| `agency_commission_status` | `H` Held, `P` Payable, `R` Reserved, `D` Paid, `O` Offset |
|
|
633
|
+
| `agency_payout_status` | `C` Created, `P` Processing, `D` Paid, `F` Failed, `R` Returned, `V` Reversed, `M` Manual review |
|
|
634
|
+
|
|
635
|
+
Do not substitute app-local domain names such as `billing_model` or
|
|
636
|
+
`commission_status`; a missing canonical domain deliberately renders the raw
|
|
637
|
+
code so configuration drift remains visible.
|
|
638
|
+
|
|
639
|
+
`AgencyAcceptComponent` keeps consent explicit and persists an invitation token
|
|
640
|
+
when the authenticated user still needs to create a business partner. Consumers
|
|
641
|
+
handle its navigation outputs and route back after business setup. Other 409
|
|
642
|
+
conflicts, such as an existing delegation or self-delegation, remain on the
|
|
643
|
+
invitation screen as errors instead of entering the business-setup flow. The
|
|
644
|
+
branch uses keel's stable RFC 7807 `code: "agency_no_partner"` (keel v1.2.41),
|
|
645
|
+
not error prose.
|
|
646
|
+
|
|
647
|
+
The payout-profile component composes the existing provider-onboarding surface
|
|
648
|
+
and only allows a fully onboarded destination returned by keel. Mount it only
|
|
649
|
+
for an approved, non-suspended agency; keel intentionally rejects its GET for a
|
|
650
|
+
pending profile. The ledger and payout-history components show default empty
|
|
651
|
+
messages; pass `[emptyMessage]="''"` when a composed page should omit empty
|
|
652
|
+
regions entirely.
|
|
653
|
+
|
|
654
|
+
All accounting values are integer minor units. `formatMinorCurrency()` uses the
|
|
655
|
+
currency exponent reported by `Intl.NumberFormat`, so zero- and three-decimal
|
|
656
|
+
currencies render correctly; earnings stay separated by currency rather than
|
|
657
|
+
being summed into a misleading total.
|
|
658
|
+
|
|
659
|
+
The agency URL defaults use `/api/v1/agency/*`. Apps mounting Keel elsewhere
|
|
660
|
+
must override the `RestURL.agency*URL` values with `configureRestUrls()`.
|
|
661
|
+
|
|
593
662
|
## Off-session SCA / 3DS confirmation (v1.0.x)
|
|
594
663
|
|
|
595
664
|
When an app charges a rider's **vaulted** card off-session (keel `payment.ChargeClient` / `StripeChargeClient`), the provider may return `requires_action` — the cardholder must complete a Strong Customer Authentication (3DS) challenge. keel surfaces the hooks on `ChargeResult` (`ClientSecret`, `ActionURL`); sail standardizes the **client-side** confirmation so it isn't rebuilt per app.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nauticana/sail",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.14",
|
|
4
4
|
"description": "Metadata-driven Angular component library for CRUD admin frontends backed by the keel Go backend. Tables, forms, navigation, OTP/2FA/social auth, billing, payout onboarding.",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"scripts": {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
@if (loading()) {
|
|
2
|
+
<mat-spinner diameter="36" aria-label="Loading invitation"></mat-spinner>
|
|
3
|
+
} @else if (complete()) {
|
|
4
|
+
<div class="agency-accept__complete" role="status">Agency management has been accepted.</div>
|
|
5
|
+
} @else if (invite(); as invitation) {
|
|
6
|
+
<section class="agency-accept__invitation">
|
|
7
|
+
<h1>Agency management invitation</h1>
|
|
8
|
+
<p>
|
|
9
|
+
<strong>{{ invitation.agencyName }}</strong> invited
|
|
10
|
+
<strong>{{ invitation.clientName }}</strong>. Accepting grants management access;
|
|
11
|
+
ownership remains with the client and the delegation can be revoked.
|
|
12
|
+
</p>
|
|
13
|
+
@if (invitation.expired) {
|
|
14
|
+
<p class="agency-accept__error" role="alert">This invitation has expired.</p>
|
|
15
|
+
}
|
|
16
|
+
@if (needsBusiness()) {
|
|
17
|
+
<p role="status">A business must be set up before management can be delegated.</p>
|
|
18
|
+
<button matButton="filled" type="button" (click)="requestBusinessSetup()">{{ businessSetupLabel() }}</button>
|
|
19
|
+
} @else if (authenticated()) {
|
|
20
|
+
<button matButton="filled" type="button" [disabled]="accepting() || invitation.expired" (click)="accept()">
|
|
21
|
+
Accept management
|
|
22
|
+
</button>
|
|
23
|
+
} @else {
|
|
24
|
+
<button matButton="filled" type="button" (click)="loginRequested.emit()">{{ loginLabel() }}</button>
|
|
25
|
+
<button matButton type="button" (click)="registrationRequested.emit()">{{ registerLabel() }}</button>
|
|
26
|
+
}
|
|
27
|
+
@if (errorMessage()) {
|
|
28
|
+
<p class="agency-accept__error" role="alert">{{ errorMessage() }}</p>
|
|
29
|
+
}
|
|
30
|
+
</section>
|
|
31
|
+
} @else {
|
|
32
|
+
<p class="agency-accept__error" role="alert">{{ errorMessage() }}</p>
|
|
33
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, OnInit, inject, input, output, signal } from '@angular/core';
|
|
2
|
+
import { MatButtonModule } from '@angular/material/button';
|
|
3
|
+
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
|
4
|
+
import { AgencyInvite } from '../../model/agency';
|
|
5
|
+
import { AgencyService } from '../../service/agency.service';
|
|
6
|
+
import { errorCode, errorDetail } from '../../util/errors';
|
|
7
|
+
|
|
8
|
+
const NO_PARTNER_CODE = 'agency_no_partner';
|
|
9
|
+
|
|
10
|
+
@Component({
|
|
11
|
+
selector: 'sail-agency-accept',
|
|
12
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
13
|
+
imports: [MatButtonModule, MatProgressSpinnerModule],
|
|
14
|
+
templateUrl: './agency_accept.html',
|
|
15
|
+
host: { class: 'agency-accept' },
|
|
16
|
+
})
|
|
17
|
+
export class AgencyAcceptComponent implements OnInit {
|
|
18
|
+
readonly token = input('');
|
|
19
|
+
readonly authenticated = input(false);
|
|
20
|
+
readonly tokenStorageKey = input('sail.agencyInvite');
|
|
21
|
+
readonly loginLabel = input('Log in to accept');
|
|
22
|
+
readonly registerLabel = input('Create an account');
|
|
23
|
+
readonly businessSetupLabel = input('Set up your business');
|
|
24
|
+
|
|
25
|
+
readonly loginRequested = output<void>();
|
|
26
|
+
readonly registrationRequested = output<void>();
|
|
27
|
+
readonly businessSetupRequested = output<string>();
|
|
28
|
+
readonly accepted = output<AgencyInvite>();
|
|
29
|
+
|
|
30
|
+
private readonly agency = inject(AgencyService);
|
|
31
|
+
private activeToken = '';
|
|
32
|
+
|
|
33
|
+
readonly loading = signal(true);
|
|
34
|
+
readonly accepting = signal(false);
|
|
35
|
+
readonly invite = signal<AgencyInvite | null>(null);
|
|
36
|
+
readonly errorMessage = signal('');
|
|
37
|
+
readonly complete = signal(false);
|
|
38
|
+
readonly needsBusiness = signal(false);
|
|
39
|
+
|
|
40
|
+
ngOnInit(): void {
|
|
41
|
+
this.activeToken = this.token() || localStorage.getItem(this.tokenStorageKey()) || '';
|
|
42
|
+
if (!this.activeToken) {
|
|
43
|
+
this.loading.set(false);
|
|
44
|
+
this.errorMessage.set('The invitation token is missing.');
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
this.agency.inviteInfo(this.activeToken).subscribe({
|
|
48
|
+
next: (invite) => {
|
|
49
|
+
this.invite.set(invite);
|
|
50
|
+
this.loading.set(false);
|
|
51
|
+
},
|
|
52
|
+
error: (error: unknown) => {
|
|
53
|
+
this.loading.set(false);
|
|
54
|
+
this.errorMessage.set(errorDetail(error, 'The invitation was not found.'));
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
accept(): void {
|
|
60
|
+
const invite = this.invite();
|
|
61
|
+
if (!invite || invite.expired || this.accepting()) return;
|
|
62
|
+
this.accepting.set(true);
|
|
63
|
+
this.needsBusiness.set(false);
|
|
64
|
+
this.errorMessage.set('');
|
|
65
|
+
this.agency.accept(this.activeToken).subscribe({
|
|
66
|
+
next: () => {
|
|
67
|
+
localStorage.removeItem(this.tokenStorageKey());
|
|
68
|
+
this.accepting.set(false);
|
|
69
|
+
this.complete.set(true);
|
|
70
|
+
this.accepted.emit(invite);
|
|
71
|
+
},
|
|
72
|
+
error: (error: unknown) => {
|
|
73
|
+
this.accepting.set(false);
|
|
74
|
+
if (this.statusOf(error) === 409 && errorCode(error) === NO_PARTNER_CODE) {
|
|
75
|
+
localStorage.setItem(this.tokenStorageKey(), this.activeToken);
|
|
76
|
+
this.needsBusiness.set(true);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
this.errorMessage.set(errorDetail(error, 'Could not accept the invitation.'));
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
requestBusinessSetup(): void {
|
|
85
|
+
localStorage.setItem(this.tokenStorageKey(), this.activeToken);
|
|
86
|
+
this.businessSetupRequested.emit(this.activeToken);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private statusOf(error: unknown): number | undefined {
|
|
90
|
+
return typeof error === 'object' && error !== null && 'status' in error
|
|
91
|
+
? Number((error as { status?: unknown }).status)
|
|
92
|
+
: undefined;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
@if (loading()) {
|
|
2
|
+
<mat-spinner diameter="36" aria-label="Loading agency clients"></mat-spinner>
|
|
3
|
+
} @else if (needsEnrollment()) {
|
|
4
|
+
<section class="agency-clients__enrollment">
|
|
5
|
+
<h2>{{ enrollmentTitle() }}</h2>
|
|
6
|
+
<p>{{ enrollmentDescription() }}</p>
|
|
7
|
+
<button matButton="filled" type="button" (click)="enroll()">{{ enrollmentActionLabel() }}</button>
|
|
8
|
+
</section>
|
|
9
|
+
} @else {
|
|
10
|
+
@if (errorMessage()) {
|
|
11
|
+
<p class="agency-clients__error" role="alert">{{ errorMessage() }}</p>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
<sail-agency-status-banner [profile]="profile()" />
|
|
15
|
+
|
|
16
|
+
<form class="agency-clients__add" [formGroup]="addForm" (ngSubmit)="addClient()">
|
|
17
|
+
<mat-form-field appearance="outline">
|
|
18
|
+
<mat-label>Client name</mat-label>
|
|
19
|
+
<input matInput formControlName="name" required />
|
|
20
|
+
</mat-form-field>
|
|
21
|
+
<mat-form-field appearance="outline">
|
|
22
|
+
<mat-label>Email</mat-label>
|
|
23
|
+
<input matInput formControlName="email" type="email" />
|
|
24
|
+
</mat-form-field>
|
|
25
|
+
<mat-form-field appearance="outline">
|
|
26
|
+
<mat-label>Website</mat-label>
|
|
27
|
+
<input matInput formControlName="website" />
|
|
28
|
+
</mat-form-field>
|
|
29
|
+
<button matButton="filled" type="submit" [disabled]="addForm.invalid || !canManage()">Add client</button>
|
|
30
|
+
</form>
|
|
31
|
+
|
|
32
|
+
<p class="agency-clients__count">Active clients: {{ activeClients() }}</p>
|
|
33
|
+
|
|
34
|
+
@if (clients().length === 0) {
|
|
35
|
+
<p class="agency-clients__empty">No clients have been staged.</p>
|
|
36
|
+
} @else {
|
|
37
|
+
<div class="agency-clients__table-wrap">
|
|
38
|
+
<table class="agency-clients__table">
|
|
39
|
+
<thead>
|
|
40
|
+
<tr>
|
|
41
|
+
<th>Name</th>
|
|
42
|
+
<th>Email</th>
|
|
43
|
+
<th>Website</th>
|
|
44
|
+
@for (column of extraColumns(); track column.id) {
|
|
45
|
+
<th>{{ column.caption }}</th>
|
|
46
|
+
}
|
|
47
|
+
<th>Status</th>
|
|
48
|
+
@if (showBillingModelControls()) { <th>Billing model</th> }
|
|
49
|
+
<th><span class="agency-clients__visually-hidden">Actions</span></th>
|
|
50
|
+
</tr>
|
|
51
|
+
</thead>
|
|
52
|
+
<tbody>
|
|
53
|
+
@for (client of clients(); track client.id) {
|
|
54
|
+
<tr>
|
|
55
|
+
<td>{{ client.name }}</td>
|
|
56
|
+
<td>{{ client.email }}</td>
|
|
57
|
+
<td>{{ client.website }}</td>
|
|
58
|
+
@for (column of extraColumns(); track column.id) {
|
|
59
|
+
<td>{{ column.value(client) }}</td>
|
|
60
|
+
}
|
|
61
|
+
<td>
|
|
62
|
+
<sail-status-chip domain="agency_client_status" [value]="client.status" />
|
|
63
|
+
@if (client.expired) { <span class="agency-clients__expired">Expired</span> }
|
|
64
|
+
</td>
|
|
65
|
+
@if (showBillingModelControls()) {
|
|
66
|
+
<td>
|
|
67
|
+
@if (client.clientPartnerId) {
|
|
68
|
+
<sail-status-chip domain="agency_billing_model" [value]="client.billingModel ?? ''" />
|
|
69
|
+
<button matButton type="button" [disabled]="!canManage() || client.billingModel === 'R'"
|
|
70
|
+
(click)="setBillingModel(client, 'R')">Referral</button>
|
|
71
|
+
@if (profile()?.wholesaleAllowed) {
|
|
72
|
+
<button matButton type="button" [disabled]="!canManage() || client.billingModel === 'W'"
|
|
73
|
+
(click)="setBillingModel(client, 'W')">Wholesale</button>
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
</td>
|
|
77
|
+
}
|
|
78
|
+
<td>
|
|
79
|
+
@if (client.status === 'S' || client.status === 'I') {
|
|
80
|
+
<button matButton type="button" [disabled]="!canManage()" (click)="invite(client)">
|
|
81
|
+
{{ client.status === 'S' ? 'Invite' : 'Re-invite' }}
|
|
82
|
+
</button>
|
|
83
|
+
<button matButton type="button" [disabled]="!canManage()" (click)="cancel(client)">Cancel</button>
|
|
84
|
+
}
|
|
85
|
+
@if (inviteLinks()[client.id]; as link) {
|
|
86
|
+
<button matIconButton type="button" aria-label="Copy invitation link" (click)="copy(link)">
|
|
87
|
+
<mat-icon>content_copy</mat-icon>
|
|
88
|
+
</button>
|
|
89
|
+
}
|
|
90
|
+
</td>
|
|
91
|
+
</tr>
|
|
92
|
+
}
|
|
93
|
+
</tbody>
|
|
94
|
+
</table>
|
|
95
|
+
</div>
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, OnInit, computed, inject, input, output, signal } from '@angular/core';
|
|
2
|
+
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
|
3
|
+
import { MatButtonModule } from '@angular/material/button';
|
|
4
|
+
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
5
|
+
import { MatIconModule } from '@angular/material/icon';
|
|
6
|
+
import { MatInputModule } from '@angular/material/input';
|
|
7
|
+
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
|
8
|
+
import { MatSnackBar } from '@angular/material/snack-bar';
|
|
9
|
+
import { AgencyClient, AgencyClientColumn, AgencyProfile } from '../../model/agency';
|
|
10
|
+
import { AgencyService } from '../../service/agency.service';
|
|
11
|
+
import { errorDetail } from '../../util/errors';
|
|
12
|
+
import { StatusChipComponent } from '../billing/status_chip';
|
|
13
|
+
import { AgencyStatusBannerComponent } from './agency_status_banner';
|
|
14
|
+
|
|
15
|
+
@Component({
|
|
16
|
+
selector: 'sail-agency-clients',
|
|
17
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
18
|
+
imports: [
|
|
19
|
+
ReactiveFormsModule,
|
|
20
|
+
MatButtonModule,
|
|
21
|
+
MatFormFieldModule,
|
|
22
|
+
MatIconModule,
|
|
23
|
+
MatInputModule,
|
|
24
|
+
MatProgressSpinnerModule,
|
|
25
|
+
StatusChipComponent,
|
|
26
|
+
AgencyStatusBannerComponent,
|
|
27
|
+
],
|
|
28
|
+
templateUrl: './agency_clients.html',
|
|
29
|
+
host: { class: 'agency-clients' },
|
|
30
|
+
})
|
|
31
|
+
export class AgencyClientsComponent implements OnInit {
|
|
32
|
+
readonly extraColumns = input<AgencyClientColumn[]>([]);
|
|
33
|
+
readonly enrollmentTitle = input('Agency enrollment');
|
|
34
|
+
readonly enrollmentDescription = input('Apply to manage clients and receive referral commission.');
|
|
35
|
+
readonly enrollmentActionLabel = input('Apply');
|
|
36
|
+
readonly showBillingModelControls = input(false);
|
|
37
|
+
|
|
38
|
+
readonly enrolled = output<AgencyProfile>();
|
|
39
|
+
readonly clientAdded = output<AgencyClient>();
|
|
40
|
+
readonly invited = output<{ client: AgencyClient; inviteLink: string }>();
|
|
41
|
+
readonly cancelled = output<AgencyClient>();
|
|
42
|
+
readonly billingModelChanged = output<{ client: AgencyClient; billingModel: 'R' | 'W' }>();
|
|
43
|
+
|
|
44
|
+
private readonly agency = inject(AgencyService);
|
|
45
|
+
private readonly snack = inject(MatSnackBar);
|
|
46
|
+
private readonly formBuilder = inject(FormBuilder);
|
|
47
|
+
|
|
48
|
+
readonly loading = signal(true);
|
|
49
|
+
readonly errorMessage = signal('');
|
|
50
|
+
readonly needsEnrollment = signal(false);
|
|
51
|
+
readonly profile = signal<AgencyProfile | null>(null);
|
|
52
|
+
readonly clients = signal<AgencyClient[]>([]);
|
|
53
|
+
readonly activeClients = signal(0);
|
|
54
|
+
readonly inviteLinks = signal<Record<number, string>>({});
|
|
55
|
+
readonly canManage = computed(() => {
|
|
56
|
+
const profile = this.profile();
|
|
57
|
+
return profile !== null && Boolean(profile.approvedAt) && !profile.suspended;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
readonly addForm = this.formBuilder.nonNullable.group({
|
|
61
|
+
name: ['', Validators.required],
|
|
62
|
+
email: ['', Validators.email],
|
|
63
|
+
website: [''],
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
ngOnInit(): void {
|
|
67
|
+
this.reload();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
reload(afterProfileLoad?: (profile: AgencyProfile) => void): void {
|
|
71
|
+
this.loading.set(true);
|
|
72
|
+
this.errorMessage.set('');
|
|
73
|
+
this.agency.profile().subscribe({
|
|
74
|
+
next: ({ profile }) => {
|
|
75
|
+
this.profile.set(profile);
|
|
76
|
+
this.needsEnrollment.set(profile === null);
|
|
77
|
+
if (profile === null) {
|
|
78
|
+
this.clients.set([]);
|
|
79
|
+
this.activeClients.set(0);
|
|
80
|
+
this.loading.set(false);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
afterProfileLoad?.(profile);
|
|
84
|
+
this.loadClients();
|
|
85
|
+
},
|
|
86
|
+
error: (error: unknown) => {
|
|
87
|
+
this.loading.set(false);
|
|
88
|
+
this.errorMessage.set(errorDetail(error, 'Could not load the agency profile.'));
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
enroll(): void {
|
|
94
|
+
this.loading.set(true);
|
|
95
|
+
this.errorMessage.set('');
|
|
96
|
+
this.agency.enroll().subscribe({
|
|
97
|
+
next: () => this.reload((profile) => this.enrolled.emit(profile)),
|
|
98
|
+
error: (error: unknown) => {
|
|
99
|
+
this.loading.set(false);
|
|
100
|
+
this.errorMessage.set(errorDetail(error, 'Agency enrollment failed.'));
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
addClient(): void {
|
|
106
|
+
if (this.addForm.invalid || !this.canManage()) return;
|
|
107
|
+
const value = this.addForm.getRawValue();
|
|
108
|
+
this.agency.addClients([value]).subscribe({
|
|
109
|
+
next: () => {
|
|
110
|
+
this.addForm.reset();
|
|
111
|
+
this.loadClients((client) => this.clientAdded.emit(client));
|
|
112
|
+
},
|
|
113
|
+
error: (error: unknown) => this.snack.open(errorDetail(error, 'Could not add the client.'), 'Dismiss'),
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
invite(client: AgencyClient): void {
|
|
118
|
+
if (!this.canManage()) return;
|
|
119
|
+
this.agency.invite(client.id).subscribe({
|
|
120
|
+
next: ({ inviteLink }) => {
|
|
121
|
+
this.inviteLinks.update((links) => ({ ...links, [client.id]: inviteLink }));
|
|
122
|
+
this.invited.emit({ client, inviteLink });
|
|
123
|
+
this.copy(inviteLink);
|
|
124
|
+
this.loadClients();
|
|
125
|
+
},
|
|
126
|
+
error: (error: unknown) => this.snack.open(errorDetail(error, 'Could not create the invitation.'), 'Dismiss'),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
cancel(client: AgencyClient): void {
|
|
131
|
+
if (!this.canManage()) return;
|
|
132
|
+
this.agency.cancel(client.id).subscribe({
|
|
133
|
+
next: () => {
|
|
134
|
+
this.cancelled.emit(client);
|
|
135
|
+
this.loadClients();
|
|
136
|
+
},
|
|
137
|
+
error: (error: unknown) => this.snack.open(errorDetail(error, 'Could not cancel the client.'), 'Dismiss'),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
setBillingModel(client: AgencyClient, billingModel: 'R' | 'W'): void {
|
|
142
|
+
if (!this.canManage() || !client.clientPartnerId) return;
|
|
143
|
+
this.agency.setBillingModel(client.clientPartnerId, billingModel).subscribe({
|
|
144
|
+
next: () => {
|
|
145
|
+
this.billingModelChanged.emit({ client, billingModel });
|
|
146
|
+
this.loadClients();
|
|
147
|
+
},
|
|
148
|
+
error: (error: unknown) => this.snack.open(errorDetail(error, 'Could not change the billing model.'), 'Dismiss'),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
copy(link: string): void {
|
|
153
|
+
if (!navigator.clipboard) {
|
|
154
|
+
this.snack.open(link, 'Dismiss');
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
navigator.clipboard.writeText(link).then(
|
|
158
|
+
() => this.snack.open('Invitation link copied.', undefined, { duration: 2500 }),
|
|
159
|
+
() => this.snack.open(link, 'Dismiss'),
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private loadClients(afterLoad?: (client: AgencyClient) => void): void {
|
|
164
|
+
this.agency.clients().subscribe({
|
|
165
|
+
next: ({ items, activeClients }) => {
|
|
166
|
+
const previousIDs = new Set(this.clients().map((client) => client.id));
|
|
167
|
+
const clients = items ?? [];
|
|
168
|
+
this.clients.set(clients);
|
|
169
|
+
this.activeClients.set(activeClients ?? 0);
|
|
170
|
+
this.loading.set(false);
|
|
171
|
+
if (afterLoad) {
|
|
172
|
+
const added = clients.find((client) => !previousIDs.has(client.id));
|
|
173
|
+
if (added) afterLoad(added);
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
error: (error: unknown) => {
|
|
177
|
+
this.loading.set(false);
|
|
178
|
+
this.errorMessage.set(errorDetail(error, 'Could not load agency clients.'));
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
@if (earnings(); as value) {
|
|
2
|
+
<div class="agency-earnings-summary__metric">
|
|
3
|
+
<span class="agency-earnings-summary__label">Active clients</span>
|
|
4
|
+
<span class="agency-earnings-summary__value">{{ activeClients() }}</span>
|
|
5
|
+
</div>
|
|
6
|
+
<div class="agency-earnings-summary__metric">
|
|
7
|
+
<span class="agency-earnings-summary__label">Paying clients</span>
|
|
8
|
+
<span class="agency-earnings-summary__value">{{ value.payingClients }}</span>
|
|
9
|
+
</div>
|
|
10
|
+
@for (balance of value.balances; track balance.currency) {
|
|
11
|
+
<div class="agency-earnings-summary__metric" [attr.data-currency]="balance.currency">
|
|
12
|
+
<span class="agency-earnings-summary__label">Held ({{ balance.currency }})</span>
|
|
13
|
+
<span class="agency-earnings-summary__value">{{ format(balance.heldMinor, balance.currency) }}</span>
|
|
14
|
+
</div>
|
|
15
|
+
<div class="agency-earnings-summary__metric" [attr.data-currency]="balance.currency">
|
|
16
|
+
<span class="agency-earnings-summary__label">Due ({{ balance.currency }})</span>
|
|
17
|
+
<span class="agency-earnings-summary__value">{{ format(balance.dueMinor, balance.currency) }}</span>
|
|
18
|
+
</div>
|
|
19
|
+
<div class="agency-earnings-summary__metric" [attr.data-currency]="balance.currency">
|
|
20
|
+
<span class="agency-earnings-summary__label">Paid ({{ balance.currency }})</span>
|
|
21
|
+
<span class="agency-earnings-summary__value">{{ format(balance.paidMinor, balance.currency) }}</span>
|
|
22
|
+
</div>
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
|
2
|
+
import { AgencyEarnings } from '../../model/agency';
|
|
3
|
+
import { formatMinorCurrency } from '../../util/money';
|
|
4
|
+
|
|
5
|
+
@Component({
|
|
6
|
+
selector: 'sail-agency-earnings-summary',
|
|
7
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
8
|
+
templateUrl: './agency_earnings_summary.html',
|
|
9
|
+
host: { class: 'agency-earnings-summary' },
|
|
10
|
+
})
|
|
11
|
+
export class AgencyEarningsSummaryComponent {
|
|
12
|
+
readonly earnings = input<AgencyEarnings | null>(null);
|
|
13
|
+
readonly activeClients = input(0);
|
|
14
|
+
readonly format = formatMinorCurrency;
|
|
15
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
@if (payouts().length > 0) {
|
|
2
|
+
<div class="agency-payout-history__table-wrap">
|
|
3
|
+
<table class="agency-payout-history__table">
|
|
4
|
+
<thead><tr><th>Cycle</th><th>Amount</th><th>Status</th><th>Paid</th></tr></thead>
|
|
5
|
+
<tbody>
|
|
6
|
+
@for (payout of payouts(); track payout.id) {
|
|
7
|
+
<tr>
|
|
8
|
+
<td>{{ payout.cutoffAt | date: 'yyyy-MM':'UTC' }}</td>
|
|
9
|
+
<td>{{ format(payout.amountMinor, payout.currency) }}</td>
|
|
10
|
+
<td><sail-status-chip domain="agency_payout_status" [value]="payout.status" /></td>
|
|
11
|
+
<td>{{ payout.paidAt ? (payout.paidAt | date: 'mediumDate':'UTC') : '' }}</td>
|
|
12
|
+
</tr>
|
|
13
|
+
}
|
|
14
|
+
</tbody>
|
|
15
|
+
</table>
|
|
16
|
+
</div>
|
|
17
|
+
} @else if (emptyMessage()) {
|
|
18
|
+
<p class="agency-payout-history__empty">{{ emptyMessage() }}</p>
|
|
19
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { DatePipe } from '@angular/common';
|
|
2
|
+
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
|
3
|
+
import { AgencyPayout } from '../../model/agency';
|
|
4
|
+
import { formatMinorCurrency } from '../../util/money';
|
|
5
|
+
import { StatusChipComponent } from '../billing/status_chip';
|
|
6
|
+
|
|
7
|
+
@Component({
|
|
8
|
+
selector: 'sail-agency-payout-history',
|
|
9
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
10
|
+
imports: [DatePipe, StatusChipComponent],
|
|
11
|
+
templateUrl: './agency_payout_history.html',
|
|
12
|
+
host: { class: 'agency-payout-history' },
|
|
13
|
+
})
|
|
14
|
+
export class AgencyPayoutHistoryComponent {
|
|
15
|
+
readonly payouts = input<AgencyPayout[]>([]);
|
|
16
|
+
readonly emptyMessage = input('No agency payouts yet.');
|
|
17
|
+
readonly format = formatMinorCurrency;
|
|
18
|
+
}
|
|
@@ -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
|
+
}
|
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
|
+
}
|
|
@@ -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
|
+
}
|
package/src/service/rest_url.ts
CHANGED
|
@@ -55,6 +55,20 @@ export const RestURL = {
|
|
|
55
55
|
payoutStatusURL: '/api/v1/payout/status',
|
|
56
56
|
payoutBankReplaceURL: '/api/v1/payout/bank/replace',
|
|
57
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',
|
|
58
72
|
// User payment methods — list/delete go through keel's generic REST
|
|
59
73
|
// CRUD against the UserSpecific basis table; set-default is a
|
|
60
74
|
// TableAction (basis table_action row), mounted at the conventional
|
package/src/util/errors.ts
CHANGED
|
@@ -1,20 +1,37 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
type ProblemShape = { title?: unknown; detail?: unknown; message?: unknown; code?: unknown };
|
|
2
|
+
|
|
3
|
+
// Structural extraction, not instanceof: bundling can produce two class
|
|
4
|
+
// identities for HttpErrorResponse (see api-response.interceptor.ts), so match
|
|
5
|
+
// by shape — SailApiError carries `problem`, an HTTP error a numeric `status`
|
|
6
|
+
// plus an `error` body.
|
|
7
|
+
function problemOf(err: unknown): ProblemShape | undefined {
|
|
8
|
+
if (!err || typeof err !== 'object') return undefined;
|
|
9
|
+
const e = err as { problem?: unknown; status?: unknown; error?: unknown };
|
|
10
|
+
const body = e.problem ?? (typeof e.status === 'number' && 'error' in e ? e.error : undefined);
|
|
11
|
+
return body && typeof body === 'object' ? (body as ProblemShape) : undefined;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function asString(value: unknown): string | undefined {
|
|
15
|
+
return typeof value === 'string' && value ? value : undefined;
|
|
16
|
+
}
|
|
3
17
|
|
|
4
18
|
/**
|
|
5
19
|
* Human-readable message from any sail error shape — SailApiError (BackendService),
|
|
6
20
|
* raw HttpErrorResponse (direct HttpClient calls), or plain Error.
|
|
7
21
|
*/
|
|
8
22
|
export function errorDetail(err: unknown, fallback: string): string {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const body = err.error as { detail?: string; message?: string } | null;
|
|
14
|
-
return body?.detail ?? body?.message ?? fallback;
|
|
23
|
+
const problem = problemOf(err);
|
|
24
|
+
if (problem) {
|
|
25
|
+
const message = asString(problem.detail) ?? asString(problem.title) ?? asString(problem.message);
|
|
26
|
+
if (message) return message;
|
|
15
27
|
}
|
|
16
28
|
if (err instanceof Error && err.message) {
|
|
17
29
|
return err.message;
|
|
18
30
|
}
|
|
19
31
|
return fallback;
|
|
20
32
|
}
|
|
33
|
+
|
|
34
|
+
/** Stable machine-readable discriminator from a keel RFC 7807 response. */
|
|
35
|
+
export function errorCode(err: unknown): string | undefined {
|
|
36
|
+
return asString(problemOf(err)?.code);
|
|
37
|
+
}
|
package/src/util/money.ts
CHANGED
|
@@ -13,6 +13,21 @@ export function formatCurrency(amount: number, currency: string): string {
|
|
|
13
13
|
}
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Format an integer minor-unit amount using the currency's exponent reported
|
|
18
|
+
* by Intl (JPY 0, USD 2, BHD 3, ...). No hardcoded /100 assumption.
|
|
19
|
+
*/
|
|
20
|
+
export function formatMinorCurrency(minor: number, currency: string): string {
|
|
21
|
+
const code = currency.trim().toUpperCase();
|
|
22
|
+
try {
|
|
23
|
+
const formatter = new Intl.NumberFormat(undefined, { style: 'currency', currency: code });
|
|
24
|
+
const exponent = formatter.resolvedOptions().maximumFractionDigits ?? 2;
|
|
25
|
+
return formatter.format(minor / 10 ** exponent);
|
|
26
|
+
} catch {
|
|
27
|
+
return `${minor} ${code}`;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
16
31
|
/** PERIOD_TYPE code vocabulary — the single source for cycle labels/durations. */
|
|
17
32
|
export const PERIOD_TYPE_INFO: Record<string, { label: string; noun: string; suffix: string; months: number }> = {
|
|
18
33
|
D: { label: 'Daily', noun: 'day', suffix: '/day', months: 1 / 30 },
|