@nauticana/sail 1.1.13 → 1.1.16

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/LICENSE CHANGED
@@ -186,7 +186,7 @@
186
186
  same "printed page" as the copyright notice for easier
187
187
  identification within third-party archives.
188
188
 
189
- Copyright [yyyy] [name of copyright owner]
189
+ Copyright 2026 Mustafa Karli
190
190
 
191
191
  Licensed under the Apache License, Version 2.0 (the "License");
192
192
  you may not use this file except in compliance with the License.
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** (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`.
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.13",
3
+ "version": "1.1.16",
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
+ }
@@ -11,6 +11,7 @@ export interface ProblemDetail {
11
11
  title?: string;
12
12
  detail?: string;
13
13
  status?: number;
14
+ code?: string;
14
15
  [key: string]: unknown;
15
16
  }
16
17
 
@@ -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
@@ -1,20 +1,63 @@
1
- import { HttpErrorResponse } from '@angular/common/http';
2
- import { SailApiError } from '../service/rest_service';
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
+ }
17
+
18
+ function statusOf(err: unknown): number | undefined {
19
+ if (!err || typeof err !== 'object') return undefined;
20
+ const s = (err as { status?: unknown }).status;
21
+ return typeof s === 'number' ? s : undefined;
22
+ }
23
+
24
+ /**
25
+ * True when the browser never received a readable response — the network was
26
+ * down, a CORS rule blocked it, or the request was aborted. Angular collapses
27
+ * all three to status 0 with a ProgressEvent body, so this is the only signal
28
+ * available. Use it to offer a retry rather than reporting a backend rejection.
29
+ */
30
+ export function isTransportFailure(err: unknown): boolean {
31
+ return statusOf(err) === 0;
32
+ }
3
33
 
4
34
  /**
5
35
  * Human-readable message from any sail error shape — SailApiError (BackendService),
6
36
  * raw HttpErrorResponse (direct HttpClient calls), or plain Error.
37
+ *
38
+ * keel answers every rejection with an RFC 7807 body, so an error carrying no
39
+ * `detail` did not come from the API: either nothing reached it (status 0) or
40
+ * something in front of it replied with a body we cannot read. Both used to
41
+ * collapse into the caller's fallback, which reads as a backend rejection and
42
+ * hides the real fault; they are now reported distinctly.
7
43
  */
8
44
  export function errorDetail(err: unknown, fallback: string): string {
9
- if (err instanceof SailApiError) {
10
- return err.problem?.detail ?? err.problem?.title ?? err.message ?? fallback;
45
+ if (isTransportFailure(err)) {
46
+ return 'Cannot reach the server — the network or a CORS rule blocked the response.';
11
47
  }
12
- if (err instanceof HttpErrorResponse) {
13
- const body = err.error as { detail?: string; message?: string } | null;
14
- return body?.detail ?? body?.message ?? fallback;
48
+ const problem = problemOf(err);
49
+ if (problem) {
50
+ const message = asString(problem.detail) ?? asString(problem.title) ?? asString(problem.message);
51
+ if (message) return message;
15
52
  }
16
53
  if (err instanceof Error && err.message) {
17
54
  return err.message;
18
55
  }
19
- return fallback;
56
+ const status = statusOf(err);
57
+ return status ? `${fallback} (HTTP ${status})` : fallback;
58
+ }
59
+
60
+ /** Stable machine-readable discriminator from a keel RFC 7807 response. */
61
+ export function errorCode(err: unknown): string | undefined {
62
+ return asString(problemOf(err)?.code);
20
63
  }
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 },