@nauticana/sail 1.1.34 → 1.1.35

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.
Files changed (43) hide show
  1. package/README.md +22 -15
  2. package/package.json +1 -1
  3. package/src/agent/component/approval_inbox.html +49 -2
  4. package/src/agent/component/approval_inbox.ts +54 -16
  5. package/src/agent/component/assistant_activity.html +28 -0
  6. package/src/agent/component/assistant_activity.ts +40 -0
  7. package/src/agent/component/studio_page.html +35 -1
  8. package/src/agent/component/studio_page.ts +67 -10
  9. package/src/agent/component/turn_thread.html +27 -0
  10. package/src/agent/component/turn_thread.ts +83 -0
  11. package/src/agent/index.ts +2 -0
  12. package/src/agent/model/agent_studio.ts +63 -0
  13. package/src/agent/model/approval.ts +37 -1
  14. package/src/agent/model/conversation.ts +31 -0
  15. package/src/agent/model/decision.ts +12 -8
  16. package/src/agent/service/agent_studio.service.ts +29 -0
  17. package/src/agent/service/approval.service.ts +24 -4
  18. package/src/agent/service/conversation.service.ts +10 -0
  19. package/src/component/agency/agency_earnings_summary.html +3 -3
  20. package/src/component/agency/agency_earnings_summary.ts +2 -2
  21. package/src/component/agency/agency_payout_history.html +1 -1
  22. package/src/component/agency/agency_payout_history.ts +2 -3
  23. package/src/component/agency/commission_ledger.html +1 -1
  24. package/src/component/agency/commission_ledger.ts +2 -3
  25. package/src/component/billing/plan_selector.ts +1 -1
  26. package/src/component/billing/price_selector.ts +5 -5
  27. package/src/component/payout/bank_info_form.html +15 -6
  28. package/src/component/payout/bank_info_form.ts +28 -72
  29. package/src/component/security/api_keys.html +78 -0
  30. package/src/component/security/api_keys.ts +90 -0
  31. package/src/component/security/connected_apps.html +25 -0
  32. package/src/component/security/connected_apps.ts +40 -0
  33. package/src/index.ts +7 -0
  34. package/src/model/access.ts +28 -0
  35. package/src/model/appdata.ts +41 -67
  36. package/src/model/geo.ts +12 -0
  37. package/src/service/access.service.ts +57 -0
  38. package/src/service/caption_pipe.ts +8 -2
  39. package/src/service/currency.service.ts +51 -0
  40. package/src/service/money_pipe.ts +27 -0
  41. package/src/service/rest_service.ts +1 -1
  42. package/src/service/rest_url.ts +3 -0
  43. package/src/util/money.ts +18 -34
@@ -0,0 +1,27 @@
1
+ <div class="turn-thread">
2
+ @for (turn of turns(); track turn.request_id) {
3
+ <article [class]="'turn-thread__turn turn-thread__turn--' + turn.status">
4
+ <header class="turn-thread__head">
5
+ <span class="turn-thread__title">{{ label(turn.task_kind) }} · {{ turn.input_summary }}</span>
6
+ <span class="turn-thread__status">{{ turn.status | sailCaption:'turn_status' }}</span>
7
+ </header>
8
+ <p class="turn-thread__meta">
9
+ {{ turn.created_at | date: 'medium' }}
10
+ @if (turn.cost_minor !== undefined && turn.currency && turn.exponent !== undefined) { · {{ turn.cost_minor | sailMoney:turn.currency:turn.exponent }} }
11
+ @if (turn.release_version) { · {{ turn.agent_name }} v{{ turn.release_version }} }
12
+ </p>
13
+ @if (turn.status === 'F' && turn.error_text) {
14
+ <p class="turn-thread__error" role="alert">{{ turn.error_text }}</p>
15
+ }
16
+ @if (resultTemplate(turn.task_kind); as template) {
17
+ <ng-container [ngTemplateOutlet]="template" [ngTemplateOutletContext]="{ $implicit: turn }" />
18
+ }
19
+ </article>
20
+ } @empty {
21
+ @if (!loading()) { <p class="turn-thread__empty">{{ emptyMessage() }}</p> }
22
+ }
23
+ @if (nextBefore()) {
24
+ <button matButton type="button" [disabled]="loading()" (click)="more()">Load more</button>
25
+ }
26
+ @if (errorMessage()) { <p class="turn-thread__error" role="alert">{{ errorMessage() }}</p> }
27
+ </div>
@@ -0,0 +1,83 @@
1
+ import { DatePipe, NgTemplateOutlet } from '@angular/common';
2
+ import {
3
+ ChangeDetectionStrategy, Component, DestroyRef, Directive, OnInit, TemplateRef, contentChildren, inject, input, signal,
4
+ } from '@angular/core';
5
+ import { MatButtonModule } from '@angular/material/button';
6
+ import { BaseAsync } from '../../component/abstract/base_async';
7
+ import { CaptionPipe } from '../../service/caption_pipe';
8
+ import { MoneyPipe } from '../../service/money_pipe';
9
+ import { ConversationTurn, TERMINAL_TURN_STATUSES } from '../model/conversation';
10
+ import { ConversationService } from '../service/conversation.service';
11
+
12
+ /**
13
+ * The result card of one task kind: `<ng-template sailTurnResult="kind" let-turn>…</ng-template>`.
14
+ * The template's context is the turn.
15
+ */
16
+ @Directive({ selector: 'ng-template[sailTurnResult]' })
17
+ export class TurnResultDirective {
18
+ readonly kind = input.required<string>({ alias: 'sailTurnResult' });
19
+ readonly template = inject<TemplateRef<{ $implicit: ConversationTurn }>>(TemplateRef);
20
+ }
21
+
22
+ /**
23
+ * The signed-in person's turns from `GET {base}turns`, newest first, with status captions, cost
24
+ * and release version, paged by `next_before` and reloaded every `pollMs` while a turn is
25
+ * nonterminal. Selector: <sail-turn-thread>.
26
+ */
27
+ @Component({
28
+ selector: 'sail-turn-thread',
29
+ changeDetection: ChangeDetectionStrategy.OnPush,
30
+ imports: [DatePipe, NgTemplateOutlet, MatButtonModule, CaptionPipe, MoneyPipe],
31
+ templateUrl: './turn_thread.html',
32
+ })
33
+ export class TurnThreadComponent extends BaseAsync implements OnInit {
34
+ private readonly conversation = inject(ConversationService);
35
+
36
+ /** Only turns that served this work item; all of the person's turns when empty. */
37
+ readonly workRef = input('');
38
+ /** `task_kind` → label; the kind itself otherwise. */
39
+ readonly kindLabels = input<Record<string, string>>({});
40
+ readonly pollMs = input(10000);
41
+ readonly emptyMessage = input('Nothing yet.');
42
+
43
+ readonly turns = signal<ConversationTurn[]>([]);
44
+ readonly nextBefore = signal('');
45
+ private readonly results = contentChildren(TurnResultDirective);
46
+ private timer: ReturnType<typeof setTimeout> | undefined;
47
+
48
+ constructor() {
49
+ super();
50
+ inject(DestroyRef).onDestroy(() => clearTimeout(this.timer));
51
+ }
52
+
53
+ ngOnInit(): void {
54
+ this.load();
55
+ }
56
+
57
+ /** Reloads the first page; the pages below it are dropped. */
58
+ load(): void {
59
+ clearTimeout(this.timer);
60
+ this.run(this.conversation.turns(this.workRef()), (page) => {
61
+ this.turns.set(page.turns);
62
+ this.nextBefore.set(page.next_before ?? '');
63
+ if (page.turns.some((t) => !TERMINAL_TURN_STATUSES.has(t.status))) {
64
+ this.timer = setTimeout(() => this.load(), Math.max(1000, this.pollMs()));
65
+ }
66
+ }, 'Failed to load the turns.');
67
+ }
68
+
69
+ more(): void {
70
+ this.run(this.conversation.turns(this.workRef(), this.nextBefore()), (page) => {
71
+ this.turns.update((rows) => [...rows, ...page.turns]);
72
+ this.nextBefore.set(page.next_before ?? '');
73
+ }, 'Failed to load more turns.');
74
+ }
75
+
76
+ label(kind: string): string {
77
+ return this.kindLabels()[kind] ?? kind;
78
+ }
79
+
80
+ resultTemplate(kind: string): TemplateRef<{ $implicit: ConversationTurn }> | undefined {
81
+ return this.results().find((r) => r.kind() === kind)?.template;
82
+ }
83
+ }
@@ -21,3 +21,5 @@ export * from './component/decision_timeline';
21
21
  export * from './component/studio_tab';
22
22
  export * from './component/studio_page';
23
23
  export * from './component/approval_inbox';
24
+ export * from './component/assistant_activity';
25
+ export * from './component/turn_thread';
@@ -309,3 +309,66 @@ export interface AgentSettingChange {
309
309
  expected_agent_revision: number;
310
310
  expected_type_defaults_revision: number;
311
311
  }
312
+
313
+ /** Captions for Scout's fixed `AgentReadiness` values, which no constant domain carries. */
314
+ export const AGENT_READINESS_CAPTIONS: Record<string, string> = {
315
+ ready: 'Ready',
316
+ disabled: 'Disabled',
317
+ missing_model: 'No text model',
318
+ unpublished: 'Unpublished',
319
+ error: 'Invalid definition',
320
+ not_entitled: 'Not entitled',
321
+ needs_integration: 'Needs integration',
322
+ forbidden: 'Forbidden',
323
+ };
324
+
325
+ /** One enforced guardrail rule; `params` is the kind's JSON parameters. */
326
+ export interface GuardrailRule {
327
+ id: string;
328
+ kind: string;
329
+ stages?: string[];
330
+ action: string;
331
+ severity: string;
332
+ params?: unknown;
333
+ }
334
+
335
+ /** Rules an agent inherits from one layer: B baseline, P platform, T tenant, Y type. */
336
+ export interface InheritedGuardrails {
337
+ layer: string;
338
+ digest?: string;
339
+ rules: GuardrailRule[];
340
+ }
341
+
342
+ /** The layers an agent inherits, widest first, and its own rules, which only add or tighten. */
343
+ export interface AgentGuardrails {
344
+ agent_name: string;
345
+ inherited: InheritedGuardrails[];
346
+ rules: GuardrailRule[];
347
+ /** Why the agent's rules no longer fit the inherited ones. */
348
+ problem?: string;
349
+ expected_agent_revision: number;
350
+ }
351
+
352
+ /** Replaces the agent's own rules as a whole; an empty list removes them. */
353
+ export interface AgentGuardrailChange {
354
+ agent_name: string;
355
+ rules: GuardrailRule[];
356
+ expected_agent_revision: number;
357
+ }
358
+
359
+ /** One stored guardrail hit: rule ids and attribution, never the inspected content. */
360
+ export interface SafetyEvent {
361
+ id: number;
362
+ version?: string;
363
+ policy_version?: string;
364
+ stage: string;
365
+ layer: string;
366
+ action: string;
367
+ severity: string;
368
+ rule_ids: string[];
369
+ actor_kind?: string;
370
+ actor_id?: string;
371
+ request_id?: string;
372
+ duration_ms: number;
373
+ occurred_at: string;
374
+ }
@@ -1,4 +1,6 @@
1
- /** Scout approval desk wire types (v1.2.7). Codes render through backend captions. */
1
+ import { PrincipalRef } from './decision';
2
+
3
+ /** Scout approval desk and MCP confirmation inbox wire types (v1.2.13). Codes render through backend captions. */
2
4
 
3
5
  /** A approved, X rejected, E edited (tool-call proposals only, with revised arguments). */
4
6
  export type ApprovalVerdict = 'A' | 'X' | 'E';
@@ -40,3 +42,37 @@ export interface ApprovalResolveRequest {
40
42
  /** The JSON object an `E` verdict runs the tool call with instead. */
41
43
  revised_arguments?: Record<string, unknown>;
42
44
  }
45
+
46
+ /** `mcp_confirmation_status`: P pending, A approved, R running, D executed, F failed, U unknown, X declined, W withdrawn, T expired. */
47
+ export type ConfirmationStatus = 'P' | 'A' | 'R' | 'D' | 'F' | 'U' | 'X' | 'W' | 'T';
48
+
49
+ export interface ConfirmationKey {
50
+ tenant_id: number;
51
+ id: number;
52
+ }
53
+
54
+ /** One MCP tool call awaiting or past a person's confirmation; never its payload. */
55
+ export interface Confirmation extends ConfirmationKey {
56
+ maker: PrincipalRef;
57
+ client_ref?: string;
58
+ tool: string;
59
+ payload_digest: string;
60
+ /** The product's preview of the complete action. */
61
+ preview?: unknown;
62
+ channel: string;
63
+ status: ConfirmationStatus;
64
+ approval_required: boolean;
65
+ expired: boolean;
66
+ expires_at: string;
67
+ created_at: string;
68
+ decided_at?: string;
69
+ decider?: PrincipalRef;
70
+ decision_note?: string;
71
+ error?: string;
72
+ completed_at?: string;
73
+ reconciler?: PrincipalRef;
74
+ reconcile_note?: string;
75
+ can_decide: boolean;
76
+ can_withdraw: boolean;
77
+ can_reconcile: boolean;
78
+ }
@@ -36,6 +36,37 @@ export class ConversationStreamFailure extends Error {
36
36
  }
37
37
  }
38
38
 
39
+ /** A `turn_status` code; C, F and X are terminal, as Scout's catalog marks them. */
40
+ export type TurnStatus = 'Q' | 'R' | 'S' | 'U' | 'H' | 'O' | 'C' | 'F' | 'X';
41
+
42
+ export const TERMINAL_TURN_STATUSES: ReadonlySet<string> = new Set<TurnStatus>(['C', 'F', 'X']);
43
+
44
+ /** One turn of the signed-in person's history; cost fields are absent while nothing is settled. */
45
+ export interface ConversationTurn {
46
+ request_id: string;
47
+ conversation_id: string;
48
+ agent_name: string;
49
+ release_version: string;
50
+ status: TurnStatus;
51
+ work_ref?: string;
52
+ task_kind: string;
53
+ input_summary: string;
54
+ error_text?: string;
55
+ /** The product's result card, handed to the kind's `sailTurnResult` template. */
56
+ result?: unknown;
57
+ created_at: string;
58
+ completed_at?: string;
59
+ cost_minor?: number;
60
+ currency?: string;
61
+ exponent?: number;
62
+ }
63
+
64
+ /** Newest first; `next_before` is absent on the last page. */
65
+ export interface ConversationTurnPage {
66
+ turns: ConversationTurn[];
67
+ next_before?: string;
68
+ }
69
+
39
70
  /** Where the app mounts Scout's ConversationHandler, e.g. '/api/wingmate/'. */
40
71
  export const CONVERSATION_BASE_PATH = new InjectionToken<string>('SailConversationBasePath', {
41
72
  providedIn: 'root',
@@ -1,5 +1,3 @@
1
- import { ObjectRef } from './turn_event';
2
-
3
1
  /** A allowed, D denied, P pending (`audit_decision_outcome`). */
4
2
  export type DecisionOutcome = 'A' | 'D' | 'P';
5
3
 
@@ -14,12 +12,9 @@ export interface AuthorityRef {
14
12
  grantor?: PrincipalRef;
15
13
  }
16
14
 
17
- /**
18
- * One audit record as sail renders it. The audit endpoint is the app's, so the app maps its
19
- * response onto this shape; `id` is whatever key the endpoint gives a record.
20
- */
15
+ /** One governed decision of the tenant (Scout `api.DecisionRecord`); evidence is a digest only. */
21
16
  export interface DecisionRecord {
22
- id: string | number;
17
+ id: number;
23
18
  category: string;
24
19
  action: string;
25
20
  resource: string;
@@ -27,17 +22,26 @@ export interface DecisionRecord {
27
22
  reason: string;
28
23
  principal: PrincipalRef;
29
24
  authority?: AuthorityRef;
25
+ /** The client application the principal acted through. */
26
+ client_ref?: string;
27
+ work_ref?: string;
30
28
  scope_id?: string;
31
29
  release_version?: string;
32
30
  policy_id?: string;
33
31
  policy_version?: string;
34
32
  obligations?: string[];
35
- evidence?: ObjectRef;
33
+ evidence_digest?: string;
36
34
  request_id: string;
37
35
  conversation_id?: string;
38
36
  occurred_at: string;
39
37
  }
40
38
 
39
+ /** Newest first; `next_before` is absent on the last page. */
40
+ export interface DecisionPage {
41
+ records: DecisionRecord[];
42
+ next_before?: number;
43
+ }
44
+
41
45
  export interface DecisionCategoryGroup {
42
46
  category: string;
43
47
  records: DecisionRecord[];
@@ -3,6 +3,8 @@ import { Observable } from 'rxjs';
3
3
  import { BaseRestService } from '../../service/base_rest.service';
4
4
  import {
5
5
  AgentArchiveRequest,
6
+ AgentGuardrailChange,
7
+ AgentGuardrails,
6
8
  AgentAuditEvent,
7
9
  AgentCreateRequest,
8
10
  AgentDraft,
@@ -20,9 +22,11 @@ import {
20
22
  AgentSummary,
21
23
  AgentTestRequest,
22
24
  AgentTestResult,
25
+ SafetyEvent,
23
26
  StudioFeature,
24
27
  StudioModel,
25
28
  } from '../model/agent_studio';
29
+ import { DecisionOutcome, DecisionPage } from '../model/decision';
26
30
 
27
31
  const studioBasePath = '/api/agent-studio/';
28
32
 
@@ -114,6 +118,31 @@ export class AgentStudioService extends BaseRestService {
114
118
  return this.http.get<StudioModel[]>(this.studioUrl('models'));
115
119
  }
116
120
 
121
+ /** Scout's optional `decisions` feature: the tenant's decision records, newest first. */
122
+ decisions(before = 0, limit = 0, category = '', outcome?: DecisionOutcome): Observable<DecisionPage> {
123
+ const params: Record<string, string> = {};
124
+ if (before) params['before'] = String(before);
125
+ if (limit) params['limit'] = String(limit);
126
+ if (category) params['category'] = category;
127
+ if (outcome) params['outcome'] = outcome;
128
+ return this.http.get<DecisionPage>(this.studioUrl('decisions'), { params });
129
+ }
130
+
131
+ /** Scout's optional `guardrails` feature. */
132
+ guardrails(agentName: string): Observable<AgentGuardrails> {
133
+ return this.http.get<AgentGuardrails>(this.studioUrl('guardrails'), { params: { agent_name: agentName } });
134
+ }
135
+
136
+ saveGuardrails(change: AgentGuardrailChange): Observable<AgentGuardrails> {
137
+ return this.http.post<AgentGuardrails>(this.studioUrl('guardrails'), change);
138
+ }
139
+
140
+ /** Newest first; pass the last id as `before` for the next page. */
141
+ safetyEvents(agentName: string, before = 0, limit = 50): Observable<SafetyEvent[]> {
142
+ return this.http.get<SafetyEvent[]>(this.studioUrl('safety-events'),
143
+ { params: { agent_name: agentName, before: String(before), limit: String(limit) } });
144
+ }
145
+
117
146
  private studioUrl(route: string): string {
118
147
  return this.url(studioBasePath + route);
119
148
  }
@@ -1,18 +1,38 @@
1
1
  import { Injectable } from '@angular/core';
2
2
  import { Observable } from 'rxjs';
3
3
  import { BaseRestService } from '../../service/base_rest.service';
4
- import { ApprovalRequest, ApprovalResolveRequest } from '../model/approval';
4
+ import { ApprovalRequest, ApprovalResolveRequest, Confirmation, ConfirmationKey } from '../model/approval';
5
5
 
6
- /** Client for Scout's approval desk under the fixed `/api/approval/` path. */
6
+ const base = '/api/approval/';
7
+
8
+ /** Client for Scout's approval desk and MCP confirmation inbox under the fixed `/api/approval/` path. */
7
9
  @Injectable({ providedIn: 'root' })
8
10
  export class ApprovalService extends BaseRestService {
9
11
  /** The requests the signed-in person may decide now, soonest deadline first. */
10
12
  pending(): Observable<ApprovalRequest[]> {
11
- return this.http.get<ApprovalRequest[]>(this.url('/api/approval/pending'));
13
+ return this.http.get<ApprovalRequest[]>(this.url(base + 'pending'));
12
14
  }
13
15
 
14
16
  /** 409 when the request was reassigned or decided differently since it was read. */
15
17
  resolve(request: ApprovalResolveRequest): Observable<ApprovalRequest> {
16
- return this.http.post<ApprovalRequest>(this.url('/api/approval/resolve'), request);
18
+ return this.http.post<ApprovalRequest>(this.url(base + 'resolve'), request);
19
+ }
20
+
21
+ /** Open confirmations and those created since `since` (RFC 3339), across the person's tenants. */
22
+ confirmations(since = ''): Observable<Confirmation[]> {
23
+ return this.http.get<Confirmation[]>(this.url(base + 'confirmations'), { params: since ? { since } : {} });
24
+ }
25
+
26
+ decideConfirmation(key: ConfirmationKey, approve: boolean, note = ''): Observable<Confirmation> {
27
+ return this.http.post<Confirmation>(this.url(base + 'confirmation/decide'), { ...key, approve, note });
28
+ }
29
+
30
+ withdrawConfirmation(key: ConfirmationKey): Observable<ConfirmationKey> {
31
+ return this.http.post<ConfirmationKey>(this.url(base + 'confirmation/withdraw'), key);
32
+ }
33
+
34
+ /** Records the verified outcome of an unknown confirmation: D executed or F failed. */
35
+ reconcileConfirmation(key: ConfirmationKey, outcome: 'D' | 'F', note = ''): Observable<Confirmation> {
36
+ return this.http.post<Confirmation>(this.url(base + 'confirmation/reconcile'), { ...key, outcome, note });
17
37
  }
18
38
  }
@@ -7,6 +7,7 @@ import {
7
7
  ConversationStreamError,
8
8
  ConversationStreamFailure,
9
9
  ConversationTurnAccepted,
10
+ ConversationTurnPage,
10
11
  ConversationTurnRequest,
11
12
  } from '../model/conversation';
12
13
  import { TurnReplyFrame } from '../model/reply_frame';
@@ -34,6 +35,15 @@ export class ConversationService extends BaseRestService {
34
35
  return this.http.post<ConversationTurnAccepted>(this.conversationUrl('turn'), request);
35
36
  }
36
37
 
38
+ /** The person's turns, newest first; `before` continues from a page's `next_before`. */
39
+ turns(workRef = '', before = '', limit = 0): Observable<ConversationTurnPage> {
40
+ const params: Record<string, string> = {};
41
+ if (workRef) params['work_ref'] = workRef;
42
+ if (before) params['before'] = before;
43
+ if (limit) params['limit'] = String(limit);
44
+ return this.http.get<ConversationTurnPage>(this.conversationUrl('turns'), { params });
45
+ }
46
+
37
47
  /** Cancel is a request: the turn stays open until its final frame arrives. */
38
48
  cancel(requestId: string, reason?: string): Observable<ConversationTurnAccepted> {
39
49
  return this.http.post<ConversationTurnAccepted>(this.conversationUrl('turn/cancel'), {
@@ -10,15 +10,15 @@
10
10
  @for (balance of value.balances; track balance.currency) {
11
11
  <div class="agency-earnings-summary__metric" [attr.data-currency]="balance.currency">
12
12
  <span class="agency-earnings-summary__label">Held ({{ balance.currency }})</span>
13
- <span class="agency-earnings-summary__value">{{ format(balance.heldMinor, balance.currency) }}</span>
13
+ <span class="agency-earnings-summary__value">{{ balance.heldMinor | sailMoney:balance.currency }}</span>
14
14
  </div>
15
15
  <div class="agency-earnings-summary__metric" [attr.data-currency]="balance.currency">
16
16
  <span class="agency-earnings-summary__label">Due ({{ balance.currency }})</span>
17
- <span class="agency-earnings-summary__value">{{ format(balance.dueMinor, balance.currency) }}</span>
17
+ <span class="agency-earnings-summary__value">{{ balance.dueMinor | sailMoney:balance.currency }}</span>
18
18
  </div>
19
19
  <div class="agency-earnings-summary__metric" [attr.data-currency]="balance.currency">
20
20
  <span class="agency-earnings-summary__label">Paid ({{ balance.currency }})</span>
21
- <span class="agency-earnings-summary__value">{{ format(balance.paidMinor, balance.currency) }}</span>
21
+ <span class="agency-earnings-summary__value">{{ balance.paidMinor | sailMoney:balance.currency }}</span>
22
22
  </div>
23
23
  }
24
24
  }
@@ -1,15 +1,15 @@
1
1
  import { ChangeDetectionStrategy, Component, input } from '@angular/core';
2
2
  import { AgencyEarnings } from '../../model/agency';
3
- import { formatMinorCurrency } from '../../util/money';
3
+ import { MoneyPipe } from '../../service/money_pipe';
4
4
 
5
5
  @Component({
6
6
  selector: 'sail-agency-earnings-summary',
7
7
  changeDetection: ChangeDetectionStrategy.OnPush,
8
+ imports: [MoneyPipe],
8
9
  templateUrl: './agency_earnings_summary.html',
9
10
  host: { class: 'agency-earnings-summary' },
10
11
  })
11
12
  export class AgencyEarningsSummaryComponent {
12
13
  readonly earnings = input<AgencyEarnings | null>(null);
13
14
  readonly activeClients = input(0);
14
- readonly format = formatMinorCurrency;
15
15
  }
@@ -6,7 +6,7 @@
6
6
  @for (payout of payouts(); track payout.id) {
7
7
  <tr>
8
8
  <td>{{ payout.cutoffAt | date: 'yyyy-MM':'UTC' }}</td>
9
- <td>{{ format(payout.amountMinor, payout.currency) }}</td>
9
+ <td>{{ payout.amountMinor | sailMoney:payout.currency }}</td>
10
10
  <td><sail-status-chip domain="agency_payout_status" [value]="payout.status" /></td>
11
11
  <td>{{ payout.paidAt ? (payout.paidAt | date: 'mediumDate':'UTC') : '' }}</td>
12
12
  </tr>
@@ -1,18 +1,17 @@
1
1
  import { DatePipe } from '@angular/common';
2
2
  import { ChangeDetectionStrategy, Component, input } from '@angular/core';
3
3
  import { AgencyPayout } from '../../model/agency';
4
- import { formatMinorCurrency } from '../../util/money';
4
+ import { MoneyPipe } from '../../service/money_pipe';
5
5
  import { StatusChipComponent } from '../billing/status_chip';
6
6
 
7
7
  @Component({
8
8
  selector: 'sail-agency-payout-history',
9
9
  changeDetection: ChangeDetectionStrategy.OnPush,
10
- imports: [DatePipe, StatusChipComponent],
10
+ imports: [DatePipe, StatusChipComponent, MoneyPipe],
11
11
  templateUrl: './agency_payout_history.html',
12
12
  host: { class: 'agency-payout-history' },
13
13
  })
14
14
  export class AgencyPayoutHistoryComponent {
15
15
  readonly payouts = input<AgencyPayout[]>([]);
16
16
  readonly emptyMessage = input('No agency payouts yet.');
17
- readonly format = formatMinorCurrency;
18
17
  }
@@ -7,7 +7,7 @@
7
7
  <tr [class.commission-ledger__reversal]="entry.entryType === 'R'">
8
8
  <td>{{ entry.clientName }}</td>
9
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>
10
+ <td>{{ entry.amountMinor | sailMoney:entry.currency }}</td>
11
11
  <td><sail-status-chip domain="agency_commission_status" [value]="entry.status" /></td>
12
12
  </tr>
13
13
  }
@@ -1,18 +1,17 @@
1
1
  import { DatePipe } from '@angular/common';
2
2
  import { ChangeDetectionStrategy, Component, input } from '@angular/core';
3
3
  import { AgencyEarningEntry } from '../../model/agency';
4
- import { formatMinorCurrency } from '../../util/money';
4
+ import { MoneyPipe } from '../../service/money_pipe';
5
5
  import { StatusChipComponent } from '../billing/status_chip';
6
6
 
7
7
  @Component({
8
8
  selector: 'sail-commission-ledger',
9
9
  changeDetection: ChangeDetectionStrategy.OnPush,
10
- imports: [DatePipe, StatusChipComponent],
10
+ imports: [DatePipe, StatusChipComponent, MoneyPipe],
11
11
  templateUrl: './commission_ledger.html',
12
12
  host: { class: 'commission-ledger' },
13
13
  })
14
14
  export class CommissionLedgerComponent {
15
15
  readonly entries = input<AgencyEarningEntry[]>([]);
16
16
  readonly emptyMessage = input('No commission activity yet.');
17
- readonly format = formatMinorCurrency;
18
17
  }
@@ -37,7 +37,7 @@ export class PlanSelectorComponent {
37
37
  const overrides = this.ctaLabels();
38
38
  const out: Record<string, string> = {};
39
39
  for (const plan of this.plans()) {
40
- const mode = plan.activationMode ?? '';
40
+ const mode = plan.activationMode;
41
41
  out[plan.id] = overrides[mode] ?? PlanSelectorComponent.DEFAULT_CTA[mode] ?? 'Choose';
42
42
  }
43
43
  return out;
@@ -1,12 +1,12 @@
1
1
  import { ChangeDetectionStrategy, Component, ViewEncapsulation, computed, input, output } from '@angular/core';
2
2
  import { PlanPrice } from '../../model/appdata';
3
- import { formatCurrency, PERIOD_TYPE_INFO } from '../../util/money';
3
+ import { formatMinor, PERIOD_TYPE_INFO } from '../../util/money';
4
4
 
5
5
  /** Per-offer view model for accessible, currency-formatted rendering. */
6
6
  interface OfferRow {
7
7
  key: string; // stable id for tracking / selection (priceId or composite)
8
8
  price: PlanPrice;
9
- amountText: string; // currency-formatted major amount
9
+ amountText: string; // amount formatted at the offer's exponent
10
10
  cycleText: string; // "Monthly" / "Annual" / …
11
11
  termText: string; // commitment note, '' when the term is a single cycle
12
12
  }
@@ -16,8 +16,8 @@ interface OfferRow {
16
16
  * sells several offers (billing cycle × commitment term); the customer picks one
17
17
  * at checkout, then its `priceId` feeds `<sail-checkout-button [priceId]>`.
18
18
  *
19
- * Pure presentational — stateless. Amounts are formatted with `Intl` from each
20
- * offer's currency, so non-2-decimal currencies (JPY 0, BHD 3) render correctly.
19
+ * Pure presentational — stateless. Amounts are minor units formatted at the
20
+ * exponent keel sends with each offer.
21
21
  * Period codes map to copy via `[cycleLabels]` (override/extend the defaults).
22
22
  */
23
23
  @Component({
@@ -45,7 +45,7 @@ export class PriceSelectorComponent {
45
45
  return {
46
46
  key: p.priceId || `${p.billingCycle}-${p.termType}-${p.termCount}`,
47
47
  price: p,
48
- amountText: formatCurrency(p.amount, p.currency),
48
+ amountText: formatMinor(p.amountMinor, p.currency, p.exponent),
49
49
  cycleText: cycleLabel(p.billingCycle),
50
50
  termText: singleCycle ? '' : `${p.termCount}-${termNoun(p.termType)} commitment`,
51
51
  };
@@ -9,12 +9,12 @@
9
9
  <mat-form-field appearance="outline" class="full-width">
10
10
  <mat-label>Country</mat-label>
11
11
  <mat-select formControlName="countryCode">
12
- @for (c of countryProfiles(); track c.code) {
13
- <mat-option [value]="c.code">{{ c.label }} ({{ c.currency }})</mat-option>
12
+ @for (c of options()?.countries ?? []; track c.Id) {
13
+ <mat-option [value]="c.Id">{{ c.Caption }} ({{ c.Currency }})</mat-option>
14
14
  }
15
15
  </mat-select>
16
16
  </mat-form-field>
17
- <p class="hint">Sets your tax ID format + payout currency</p>
17
+ <p class="hint">Sets your payout currency</p>
18
18
 
19
19
  <mat-form-field appearance="outline" class="full-width">
20
20
  <mat-label>Account holder name</mat-label>
@@ -23,10 +23,19 @@
23
23
  <p class="hint">Legal name on the bank account (must match government ID)</p>
24
24
 
25
25
  <mat-form-field appearance="outline" class="full-width">
26
- <mat-label>{{ taxIdLabel() }}</mat-label>
27
- <input matInput formControlName="taxId" [placeholder]="taxIdPlaceholder()">
26
+ <mat-label>Tax ID type</mat-label>
27
+ <mat-select formControlName="taxIdType">
28
+ @for (t of options()?.taxIdTypes ?? []; track t.code) {
29
+ <mat-option [value]="t.code">{{ t.caption }}</mat-option>
30
+ }
31
+ </mat-select>
32
+ </mat-form-field>
33
+
34
+ <mat-form-field appearance="outline" class="full-width">
35
+ <mat-label>Tax ID</mat-label>
36
+ <input matInput formControlName="taxId">
28
37
  </mat-form-field>
29
- <p class="hint">{{ taxIdHint() }}</p>
38
+ <p class="hint">Shared with the tax authority for annual reporting</p>
30
39
 
31
40
  <mat-form-field appearance="outline" class="full-width">
32
41
  <mat-label>Billing address</mat-label>