@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
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
|
|
|
@@ -273,7 +274,8 @@ interface SailGuiConfig {
|
|
|
273
274
|
googleClientId?: string; // Google Identity Services client ID
|
|
274
275
|
appleServiceId?: string; // Apple Services ID
|
|
275
276
|
appleRedirectUri?: string; // Apple Sign-In redirect URI
|
|
276
|
-
privacyPolicyUrl?: string; // Linked from ConsentGateComponent
|
|
277
|
+
privacyPolicyUrl?: string; // Linked from ConsentGateComponent and the login footer
|
|
278
|
+
termsUrl?: string; // Linked from the SMS-consent disclosure and the login footer
|
|
277
279
|
defaultPolicyVersion?: string; // Content hash of the deployed policy
|
|
278
280
|
defaultPolicyLanguage?: string; // ISO 639-1 fallback language
|
|
279
281
|
accountDeletedRoute?: string; // Route after account deletion (default '/login/local')
|
|
@@ -589,6 +591,74 @@ A complete screen is the pieces above assembled over `BillingService` — no met
|
|
|
589
591
|
<!-- invoices: render listInvoices() rows directly -->
|
|
590
592
|
```
|
|
591
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
|
+
|
|
592
662
|
## Off-session SCA / 3DS confirmation (v1.0.x)
|
|
593
663
|
|
|
594
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.
|
|
@@ -1138,8 +1208,8 @@ The v0.6 / v0.7 line introduced three additive feature groups against keel v0.7.
|
|
|
1138
1208
|
|
|
1139
1209
|
| From | Export | Purpose |
|
|
1140
1210
|
|---|---|---|
|
|
1141
|
-
| `@nauticana/sail` | `PayoutService` | keel/payout API client —
|
|
1142
|
-
| `@nauticana/sail` | `PayoutProviderOnboardingComponent` (`<sail-payout-provider-onboarding>`) | Drop-in onboarding step with reuse picker
|
|
1211
|
+
| `@nauticana/sail` | `PayoutService` | keel/payout API client — onboarding, reuse, status, bank-version replacement, beneficiary registration |
|
|
1212
|
+
| `@nauticana/sail` | `PayoutProviderOnboardingComponent` (`<sail-payout-provider-onboarding>`) | Drop-in onboarding step with reuse picker, hosted-KYC handoff, and asynchronous-confirmation output |
|
|
1143
1213
|
| `@nauticana/sail` | `PayoutBankInfoFormComponent` (`<sail-payout-bank-info-form>`) | Tax + payout details form (country / currency / tax ID / billing address / agreement) |
|
|
1144
1214
|
| `@nauticana/sail` | `UserPaymentMethodService` | Saved-card list / delete / set-default API client |
|
|
1145
1215
|
| `@nauticana/sail` | `UserPaymentMethodsComponent` (`<sail-user-payment-methods>`) | List of saved cards/wallets with set-default + delete |
|
|
@@ -1151,10 +1221,12 @@ Make sure your keel deployment exposes these — they're all under `/api/v1/`:
|
|
|
1151
1221
|
|
|
1152
1222
|
| Endpoint | Method | Purpose |
|
|
1153
1223
|
|---|---|---|
|
|
1154
|
-
| `/api/v1/payout/onboard/start` | POST |
|
|
1224
|
+
| `/api/v1/payout/onboard/start` | POST | Start provider onboarding; returns `{ url, externalAccountId, expiresAt }` (`url` is empty when confirmation continues asynchronously) |
|
|
1155
1225
|
| `/api/v1/payout/reusable` | POST | List provider accounts the user has on other partners |
|
|
1156
1226
|
| `/api/v1/payout/reusable/link` | POST | Copy a `providerAccountId` onto the active partner's `user_bank_info` row |
|
|
1157
1227
|
| `/api/v1/payout/status` | POST | `{ complete: true }` once the active partner's row has a `providerAccountId` |
|
|
1228
|
+
| `/api/v1/payout/bank/replace` | POST | Supersede the active bank-info version and insert its replacement atomically; the app must wire the handler's `SealTaxID` hook |
|
|
1229
|
+
| `/api/v1/payout/beneficiary/register` | POST | Create and link a provider beneficiary from provider-collected details |
|
|
1158
1230
|
| `/api/v1/payment-methods/set-default` | POST | Atomic multi-row UPDATE — sets one row as default, clears the rest |
|
|
1159
1231
|
| `/api/v1/{table}/{action_name}` | POST | Per-table custom actions resolved from `basis.table_action` |
|
|
1160
1232
|
|
|
@@ -1176,6 +1248,7 @@ Drop the two components into a wizard step. Routing between them stays in the co
|
|
|
1176
1248
|
<sail-payout-provider-onboarding
|
|
1177
1249
|
(linked)="step.set('done')"
|
|
1178
1250
|
(started)="step.set('waiting')"
|
|
1251
|
+
(pending)="step.set('awaiting-confirmation')"
|
|
1179
1252
|
(skipped)="step.set('done')"
|
|
1180
1253
|
(back)="step.set('bank')">
|
|
1181
1254
|
</sail-payout-provider-onboarding>
|
|
@@ -1183,7 +1256,9 @@ Drop the two components into a wizard step. Routing between them stays in the co
|
|
|
1183
1256
|
}
|
|
1184
1257
|
```
|
|
1185
1258
|
|
|
1186
|
-
`BankInfoFormValue`
|
|
1259
|
+
`BankInfoFormValue` is application-layer input, not a generic-CRUD row. In particular, its plaintext `taxId` must be sealed into the `tax_id_encrypted` column and must never be sent through the generic `/api/v1/user_bank_info` CRUD surface. First-time setup goes through the consuming app's registration service. Changes to an existing destination use `PayoutService.replaceBankInfo()`, which calls keel's atomic version-replacement endpoint; wire `PayoutHandler.SealTaxID` so that endpoint seals the plaintext before persistence.
|
|
1260
|
+
|
|
1261
|
+
`PayoutService.startOnboarding()` returns a `PayoutOnboardingSession`. When `url` is non-empty, the component emits `started` and navigates to the provider's hosted flow. When `url` is empty, as with Wise email recipients, it emits `pending` instead; consumers must show an awaiting-confirmation state while the recipient completes the provider's email flow. Airwallex apps can pass details collected by the embedded beneficiary component to `PayoutService.registerBeneficiary()`.
|
|
1187
1262
|
|
|
1188
1263
|
For apps that operate on a single keel partner, the reuse picker stays empty and only the "Start onboarding" CTA renders. Multi-partner apps get the picker for free.
|
|
1189
1264
|
|
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
|
+
}
|