@nauticana/sail 1.1.34 → 1.1.36
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 +23 -16
- package/package.json +1 -1
- package/src/agent/component/approval_inbox.html +93 -22
- package/src/agent/component/approval_inbox.ts +199 -17
- package/src/agent/component/assistant_activity.html +28 -0
- package/src/agent/component/assistant_activity.ts +40 -0
- package/src/agent/component/studio_page.html +35 -1
- package/src/agent/component/studio_page.ts +67 -10
- package/src/agent/component/turn_thread.html +27 -0
- package/src/agent/component/turn_thread.ts +94 -0
- package/src/agent/index.ts +2 -0
- package/src/agent/model/agent_studio.ts +67 -1
- package/src/agent/model/approval.ts +45 -1
- package/src/agent/model/conversation.ts +35 -1
- package/src/agent/model/decision.ts +20 -8
- package/src/agent/service/agent_studio.service.ts +29 -0
- package/src/agent/service/approval.service.ts +24 -4
- package/src/agent/service/conversation.service.ts +10 -0
- package/src/component/agency/agency_accept.ts +2 -8
- package/src/component/agency/agency_earnings_summary.html +3 -3
- package/src/component/agency/agency_earnings_summary.ts +2 -2
- package/src/component/agency/agency_payout_history.html +1 -1
- package/src/component/agency/agency_payout_history.ts +2 -3
- package/src/component/agency/commission_ledger.html +1 -1
- package/src/component/agency/commission_ledger.ts +2 -3
- package/src/component/billing/plan_selector.ts +1 -1
- package/src/component/billing/price_selector.ts +5 -5
- package/src/component/oauth_connection/oauth_connection_panel.ts +2 -1
- package/src/component/payout/bank_info_form.html +15 -6
- package/src/component/payout/bank_info_form.ts +28 -72
- package/src/component/security/api_keys.html +85 -0
- package/src/component/security/api_keys.ts +96 -0
- package/src/component/security/connected_apps.html +25 -0
- package/src/component/security/connected_apps.ts +40 -0
- package/src/index.ts +7 -0
- package/src/model/access.ts +37 -0
- package/src/model/appdata.ts +41 -67
- package/src/model/geo.ts +12 -0
- package/src/service/access.service.ts +56 -0
- package/src/service/auth.service.ts +2 -1
- package/src/service/caption_pipe.ts +8 -2
- package/src/service/currency.service.ts +51 -0
- package/src/service/money_pipe.ts +27 -0
- package/src/service/rest_service.ts +1 -1
- package/src/service/rest_url.ts +3 -0
- package/src/util/errors.ts +5 -4
- package/src/util/money.ts +18 -34
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { DatePipe } from '@angular/common';
|
|
2
|
+
import { ChangeDetectionStrategy, Component, OnInit, ViewEncapsulation, inject, signal } from '@angular/core';
|
|
3
|
+
import { MatButtonModule } from '@angular/material/button';
|
|
4
|
+
import { OAuthGrant } from '../../model/access';
|
|
5
|
+
import { AccessService } from '../../service/access.service';
|
|
6
|
+
import { BaseAsync } from '../abstract/base_async';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* OAuth clients holding a live grant from the signed-in user, with disconnect; a disconnected
|
|
10
|
+
* client must authorize again. Selector: <sail-connected-apps>.
|
|
11
|
+
*/
|
|
12
|
+
@Component({
|
|
13
|
+
selector: 'sail-connected-apps',
|
|
14
|
+
templateUrl: './connected_apps.html',
|
|
15
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
16
|
+
encapsulation: ViewEncapsulation.None,
|
|
17
|
+
imports: [DatePipe, MatButtonModule],
|
|
18
|
+
})
|
|
19
|
+
export class ConnectedAppsComponent extends BaseAsync implements OnInit {
|
|
20
|
+
private readonly access = inject(AccessService);
|
|
21
|
+
|
|
22
|
+
readonly grants = signal<OAuthGrant[]>([]);
|
|
23
|
+
|
|
24
|
+
ngOnInit(): void {
|
|
25
|
+
this.load();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
load(): void {
|
|
29
|
+
this.run(this.access.grants(), (rows) => this.grants.set(rows), 'Failed to load connected apps.');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
revoke(grant: OAuthGrant): void {
|
|
33
|
+
const name = grant.clientName || grant.clientId;
|
|
34
|
+
if (!confirm(`Disconnect "${name}"? It will have to connect again.`)) return;
|
|
35
|
+
this.run(this.access.revokeGrant(grant.clientId), () => {
|
|
36
|
+
this.successMessage.set(`Disconnected ${name}.`);
|
|
37
|
+
this.load();
|
|
38
|
+
}, 'Failed to disconnect the app.');
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,8 @@ export * from './model/oauth_connection';
|
|
|
7
7
|
export * from './model/notification';
|
|
8
8
|
export * from './model/document';
|
|
9
9
|
export * from './model/agency';
|
|
10
|
+
export * from './model/geo';
|
|
11
|
+
export * from './model/access';
|
|
10
12
|
export * from './model/decorator';
|
|
11
13
|
export * from './component/abstract/base_table';
|
|
12
14
|
export * from './component/dashboard/dashboard_shell';
|
|
@@ -53,6 +55,8 @@ export * from './component/security/twofactor_setup';
|
|
|
53
55
|
export * from './component/security/twofactor_verify';
|
|
54
56
|
export * from './component/security/trusted_devices';
|
|
55
57
|
export * from './component/security/sessions';
|
|
58
|
+
export * from './component/security/api_keys';
|
|
59
|
+
export * from './component/security/connected_apps';
|
|
56
60
|
export * from './component/billing/plan_selector';
|
|
57
61
|
export * from './component/billing/price_selector';
|
|
58
62
|
export * from './component/billing/checkout_button';
|
|
@@ -95,6 +99,9 @@ export * from './service/rest_service';
|
|
|
95
99
|
export * from './service/rest_url';
|
|
96
100
|
export * from './service/label.service';
|
|
97
101
|
export * from './service/caption_pipe';
|
|
102
|
+
export * from './service/currency.service';
|
|
103
|
+
export * from './service/access.service';
|
|
104
|
+
export * from './service/money_pipe';
|
|
98
105
|
export * from './service/base_rest.service';
|
|
99
106
|
export * from './util/text';
|
|
100
107
|
export * from './util/money';
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** A row of keel's `api_key` table as generic REST returns it; the hash never leaves keel. */
|
|
2
|
+
export interface ApiKey {
|
|
3
|
+
Id: number;
|
|
4
|
+
KeyName: string;
|
|
5
|
+
KeyPrefix: string;
|
|
6
|
+
Scopes: string; // CSV
|
|
7
|
+
IsActive: boolean;
|
|
8
|
+
CreatedAt: string;
|
|
9
|
+
ExpiresAt?: string;
|
|
10
|
+
LastUsedAt?: string;
|
|
11
|
+
UserId?: number;
|
|
12
|
+
AllowedCidrs?: string; // CSV of networks; absent allows any address
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** keel `api_key/generate` request; blank scopes are the product `ScopePolicy`'s to supply. */
|
|
16
|
+
export type NewApiKey = {
|
|
17
|
+
key_name: string;
|
|
18
|
+
scopes: string; // CSV
|
|
19
|
+
allowed_cidrs: string;
|
|
20
|
+
/** RFC 3339; absent never expires. */
|
|
21
|
+
expires_at?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** keel `api_key/generate` reply: the plaintext is shown once and never stored. */
|
|
25
|
+
export interface MintedApiKey {
|
|
26
|
+
api_key: string;
|
|
27
|
+
key_prefix: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** An OAuth client the signed-in user authorized on keel's authorization server (`authserver.Grant`). */
|
|
31
|
+
export interface OAuthGrant {
|
|
32
|
+
clientId: string;
|
|
33
|
+
clientName: string;
|
|
34
|
+
scopes: string[];
|
|
35
|
+
authorizedAt: string;
|
|
36
|
+
lastIssuedAt: string;
|
|
37
|
+
}
|
package/src/model/appdata.ts
CHANGED
|
@@ -252,47 +252,32 @@ export interface PartnerCreated {
|
|
|
252
252
|
paymentUrl?: string;
|
|
253
253
|
}
|
|
254
254
|
|
|
255
|
-
// One purchasable offer for a plan
|
|
256
|
-
// keel's `subscription_plan_price` table
|
|
257
|
-
// customer picks one at checkout via `<sail-price-selector>`. Mirrors keel
|
|
258
|
-
// `billing.PlanPrice`.
|
|
255
|
+
// One purchasable offer for a plan: a (billing cycle × commitment term) row of
|
|
256
|
+
// keel's `subscription_plan_price` table. Mirrors keel `billing.PlanPrice`.
|
|
259
257
|
export interface PlanPrice {
|
|
260
258
|
billingCycle: string; // PERIOD_TYPE code (D/W/M/A) — how often charged
|
|
261
259
|
termCount: number; // commitment length, in termType units
|
|
262
260
|
termType: string; // PERIOD_TYPE code — commitment/pricing unit
|
|
263
|
-
amountMinor: number; // price per termType unit, in minor units
|
|
264
|
-
amount: number; // same amount in major units (currency-exponent applied)
|
|
261
|
+
amountMinor: number; // price per termType unit, in minor units
|
|
265
262
|
currency: string; // ISO 4217
|
|
263
|
+
exponent: number; // minor-unit decimals from keel's currency table
|
|
266
264
|
// Provider price id (Stripe Price). Empty for free/unpriced offers; pass to
|
|
267
265
|
// `<sail-checkout-button [priceId]>` / `createCheckout({ priceId })`.
|
|
268
266
|
priceId?: string;
|
|
269
267
|
}
|
|
270
268
|
|
|
271
|
-
//
|
|
272
|
-
// page to render the plan picker and to drive `<sail-checkout-button>` flows.
|
|
269
|
+
// Mirrors keel `billing.Plan` from GET /public/plans.
|
|
273
270
|
export interface PublicPlan {
|
|
274
271
|
id: string;
|
|
275
272
|
caption: string;
|
|
276
|
-
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
|
|
280
|
-
//
|
|
281
|
-
|
|
282
|
-
// (so `<sail-price-selector>` works on the registration page too); absent on
|
|
283
|
-
// pre-v1.0.6 backends.
|
|
284
|
-
prices?: PlanPrice[];
|
|
285
|
-
// @deprecated keel v1.0.5 moved per-offer prices into `prices[]`
|
|
286
|
-
// (`subscription_plan.provider_price_id` was dropped). Use
|
|
287
|
-
// `prices?.[…].priceId`. Kept optional for pre-v1.0.5 backends.
|
|
288
|
-
priceId?: string;
|
|
289
|
-
// keel `subscription_plan.activation_mode` (SUBSCRIPTION_ACTIVATION_MODE
|
|
290
|
-
// dictionary, single char — e.g. 'P' paid, 'F' free, 'T' trial). Drives the
|
|
291
|
-
// picker CTA copy ("Start trial" / "Subscribe" / …). Optional — pre-v1.0.3
|
|
292
|
-
// backends omit it.
|
|
293
|
-
activationMode?: string;
|
|
273
|
+
currency: string;
|
|
274
|
+
// Purchasable offers; derive a display price with `fromPriceLabel(plan.prices)`
|
|
275
|
+
// and feed the chosen offer's `priceId` to `<sail-checkout-button>`.
|
|
276
|
+
prices: PlanPrice[] | null;
|
|
277
|
+
// keel `subscription_plan.activation_mode` ('P' paid, 'F' free, 'T' trial); drives the picker CTA.
|
|
278
|
+
activationMode: string;
|
|
294
279
|
// Length of the free trial in days when `activationMode === 'T'`.
|
|
295
|
-
trialDays
|
|
280
|
+
trialDays: number;
|
|
296
281
|
}
|
|
297
282
|
|
|
298
283
|
// ConfirmRegisterResponse — shape returned by POST /public/register/confirm:
|
|
@@ -323,7 +308,7 @@ export type CheckoutMode = 'subscription' | 'payment' | 'setup';
|
|
|
323
308
|
export interface CheckoutRequest {
|
|
324
309
|
mode?: CheckoutMode; // default 'subscription'
|
|
325
310
|
// Required for 'subscription' / 'payment'; must be empty for 'setup'.
|
|
326
|
-
// Pass `
|
|
311
|
+
// Pass the chosen `PlanPrice.priceId` straight through.
|
|
327
312
|
priceId?: string;
|
|
328
313
|
quantity?: number;
|
|
329
314
|
email?: string;
|
|
@@ -401,33 +386,35 @@ export interface PageRequest {
|
|
|
401
386
|
orderBy?: string;
|
|
402
387
|
}
|
|
403
388
|
|
|
389
|
+
// Mirrors keel `billing.Subscription`; amounts are minor units at `exponent`.
|
|
404
390
|
export interface Subscription {
|
|
405
|
-
planId:
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
// trialing. Drives `<sail-trial-banner>`.
|
|
417
|
-
trialEnd?:
|
|
418
|
-
// Seat quantity on the active/trial sub
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
391
|
+
planId: string;
|
|
392
|
+
caption?: string; // plan display name
|
|
393
|
+
status: string; // 'A' active, 'P' pending-payment, 'C' cancelled, 'T' trialing, 'X' past-due — caption via SUBSCRIPTION_STATUS dict
|
|
394
|
+
begda: string;
|
|
395
|
+
endda?: string;
|
|
396
|
+
installmentMinor: number;
|
|
397
|
+
currency: string;
|
|
398
|
+
exponent: number;
|
|
399
|
+
autoRenew: boolean;
|
|
400
|
+
// PERIOD_TYPE code (D/W/M/A) the sub is charged on, copied from the chosen offer.
|
|
401
|
+
billingCycle?: string;
|
|
402
|
+
// ISO timestamp the trial ends; empty/absent when not trialing. Drives `<sail-trial-banner>`.
|
|
403
|
+
trialEnd?: string;
|
|
404
|
+
// Seat quantity on the active/trial sub; absent for non-seat plans. Drives `<sail-seat-selector>`.
|
|
405
|
+
seats?: number;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Mirrors keel `billing.Invoice`; `status` is keel's one-character invoice status code.
|
|
423
409
|
export interface Invoice {
|
|
424
|
-
id:
|
|
425
|
-
number:
|
|
426
|
-
status:
|
|
427
|
-
|
|
428
|
-
currency:
|
|
429
|
-
|
|
430
|
-
|
|
410
|
+
id: string;
|
|
411
|
+
number: string;
|
|
412
|
+
status: string;
|
|
413
|
+
totalMinor: number;
|
|
414
|
+
currency: string;
|
|
415
|
+
exponent: number;
|
|
416
|
+
issuedAt: string;
|
|
417
|
+
paidAt?: string;
|
|
431
418
|
}
|
|
432
419
|
|
|
433
420
|
// ── Payout types ──
|
|
@@ -469,25 +456,12 @@ export interface BankInfoFormValue {
|
|
|
469
456
|
currency: string;
|
|
470
457
|
accountHolderName: string;
|
|
471
458
|
billingAddress: string;
|
|
472
|
-
taxIdType: string; //
|
|
459
|
+
taxIdType: string; // keel `tax_id_type` code
|
|
473
460
|
taxId: string;
|
|
474
461
|
provider: string;
|
|
475
462
|
providerAgreement: boolean;
|
|
476
463
|
}
|
|
477
464
|
|
|
478
|
-
// CountryProfile drives the country dropdown in the bank-info form.
|
|
479
|
-
// Each profile bundles ISO codes + tax-id labelling so the form's
|
|
480
|
-
// hint/placeholder text follows the country selection.
|
|
481
|
-
export interface CountryProfile {
|
|
482
|
-
code: string; // ISO 3166-1 alpha-2
|
|
483
|
-
label: string;
|
|
484
|
-
currency: string; // ISO 4217
|
|
485
|
-
taxIdType: string; // matches basis constant_header 'tax_id_type'
|
|
486
|
-
taxIdLabel: string;
|
|
487
|
-
taxIdPlaceholder: string;
|
|
488
|
-
taxIdHint: string;
|
|
489
|
-
}
|
|
490
|
-
|
|
491
465
|
// ── User payment methods ──
|
|
492
466
|
// Consumer-side saved cards/wallets. Mirrors basis user_payment_method
|
|
493
467
|
// one-to-one. Distinct from PaymentMethod (partner-scoped SaaS billing
|
package/src/model/geo.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Rows of keel's `currency` and `country` tables as generic REST returns them. */
|
|
2
|
+
export interface Currency {
|
|
3
|
+
Code: string; // ISO 4217
|
|
4
|
+
Caption: string;
|
|
5
|
+
Exponent: number; // minor-unit decimals, 0–6
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface Country {
|
|
9
|
+
Id: string; // ISO 3166-1 alpha-2
|
|
10
|
+
Caption: string;
|
|
11
|
+
Currency: string; // default ISO 4217 code
|
|
12
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Injectable } from '@angular/core';
|
|
2
|
+
import { Observable, catchError, throwError } from 'rxjs';
|
|
3
|
+
import { ApiKey, MintedApiKey, NewApiKey, OAuthGrant } from '../model/access';
|
|
4
|
+
import { TableAction } from '../model/appdata';
|
|
5
|
+
import { BackendService } from './rest_service';
|
|
6
|
+
import { RestURL } from './rest_url';
|
|
7
|
+
|
|
8
|
+
/** The signed-in user's API keys (keel `api_key` table and actions) and OAuth grants. */
|
|
9
|
+
@Injectable({ providedIn: 'root' })
|
|
10
|
+
export class AccessService extends BackendService {
|
|
11
|
+
apiKeys(): Observable<ApiKey[]> {
|
|
12
|
+
return this.list<ApiKey>('api_key', undefined, { orderBy: 'created_at desc' });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
mintApiKey(key: NewApiKey): Observable<MintedApiKey> {
|
|
16
|
+
return this.apiKeyAction<MintedApiKey>('generate', key);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
restrictApiKey(id: number, allowedCidrs: string): Observable<void> {
|
|
20
|
+
return this.apiKeyAction<void>('restrict', { id, allowed_cidrs: allowedCidrs });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
revokeApiKey(id: number): Observable<{ message: string }> {
|
|
24
|
+
return this.delete('api_key', { Id: String(id) });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
grants(): Observable<OAuthGrant[]> {
|
|
28
|
+
return this.http.get<OAuthGrant[]>(this.url(RestURL.oauthGrantsURL))
|
|
29
|
+
.pipe(catchError((err) => this.handleError(err)));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
revokeGrant(clientId: string): Observable<void> {
|
|
33
|
+
return this.http.post<void>(this.url(RestURL.oauthGrantRevokeURL), { clientId })
|
|
34
|
+
.pipe(catchError((err) => this.handleError(err)));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
canApiKeyAction(name: string): boolean {
|
|
38
|
+
const action = this.findApiKeyAction(name);
|
|
39
|
+
return !!action && this.auth.canExecute(action.authorityObject, action.authorityCheck, 'api_key');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
canRevokeApiKey(): boolean {
|
|
43
|
+
return this.auth.canDelete('api_key');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private apiKeyAction<T>(name: string, body: Record<string, unknown>): Observable<T> {
|
|
47
|
+
const action = this.findApiKeyAction(name);
|
|
48
|
+
return action
|
|
49
|
+
? this.executeAction<T>(action.method, body)
|
|
50
|
+
: throwError(() => new Error(`sail: api_key/${name} is not available in backend metadata.`));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private findApiKeyAction(name: string): TableAction | undefined {
|
|
54
|
+
return (this.auth.getTableDefinition('api_key')?.Actions ?? []).find((action) => action.action === name);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -2,6 +2,7 @@ import { Injectable, WritableSignal, inject, signal } from '@angular/core';
|
|
|
2
2
|
import { HttpErrorResponse, HttpParams } from '@angular/common/http';
|
|
3
3
|
import { filter, finalize, map, mergeMap, shareReplay, take, tap } from 'rxjs/operators';
|
|
4
4
|
import { PasswordRules } from '../util/password_policy';
|
|
5
|
+
import { httpStatus } from '../util/errors';
|
|
5
6
|
import { ActiveSession, ApplicationData, ConfirmRegisterResponse, DictionaryPath, LoginResponse2FA, PartnerCreated, PartnerRegistration, PartnerSetup, RestReport, TableDefinition, TokenPair, TrustedDevice, TwoFactorSetupResponse, TwoFactorVerifyRequest, TwoFactorVerifyResponse } from '../model/appdata';
|
|
6
7
|
import {
|
|
7
8
|
LoginResponseSocial,
|
|
@@ -289,7 +290,7 @@ export abstract class BaseAuthService extends BaseRestService {
|
|
|
289
290
|
},
|
|
290
291
|
error: (err: unknown) => {
|
|
291
292
|
console.error('Session handoff failed:', err);
|
|
292
|
-
this.sessionHandoffError.set(handoffErrorMessage((err
|
|
293
|
+
this.sessionHandoffError.set(handoffErrorMessage(httpStatus(err)));
|
|
293
294
|
},
|
|
294
295
|
});
|
|
295
296
|
}
|
|
@@ -18,8 +18,14 @@ export class CaptionPipe implements PipeTransform {
|
|
|
18
18
|
this.loaded();
|
|
19
19
|
if (!this.requested.has(constantId)) {
|
|
20
20
|
this.requested.add(constantId);
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
// A cached domain emits synchronously: its caption is already current, and a signal
|
|
22
|
+
// write during render would throw NG0600.
|
|
23
|
+
let rendering = true;
|
|
24
|
+
this.labels.loadLabels(constantId).pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
|
|
25
|
+
next: () => { if (!rendering) this.loaded.update((n) => n + 1); },
|
|
26
|
+
error: () => this.requested.delete(constantId),
|
|
27
|
+
});
|
|
28
|
+
rendering = false;
|
|
23
29
|
}
|
|
24
30
|
return this.labels.getLabel(constantId, code);
|
|
25
31
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Injectable, inject, signal } from '@angular/core';
|
|
2
|
+
import { Observable, map, of, shareReplay, tap } from 'rxjs';
|
|
3
|
+
import { Currency } from '../model/geo';
|
|
4
|
+
import { formatMinor } from '../util/money';
|
|
5
|
+
import { BackendService } from './rest_service';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* keel's `currency` catalog, the only source of minor-unit exponents. Loaded once through
|
|
9
|
+
* generic REST, so the caller's role needs `TABLE SELECT currency`.
|
|
10
|
+
*/
|
|
11
|
+
@Injectable({ providedIn: 'root' })
|
|
12
|
+
export class CurrencyService {
|
|
13
|
+
private readonly backend = inject(BackendService);
|
|
14
|
+
private readonly byCode = new Map<string, Currency>();
|
|
15
|
+
private pending?: Observable<void>;
|
|
16
|
+
readonly ready = signal(false);
|
|
17
|
+
readonly failed = signal(false);
|
|
18
|
+
|
|
19
|
+
/** Load the catalog; completed and concurrent calls share one request, a failed one is retried. */
|
|
20
|
+
load(): Observable<void> {
|
|
21
|
+
if (this.ready()) return of(undefined);
|
|
22
|
+
this.failed.set(false);
|
|
23
|
+
this.pending ??= this.backend.list<Currency>('currency', undefined, { limit: 1000 }).pipe(
|
|
24
|
+
tap({
|
|
25
|
+
next: (rows) => {
|
|
26
|
+
for (const row of rows) this.byCode.set(row.Code, row);
|
|
27
|
+
this.ready.set(true);
|
|
28
|
+
},
|
|
29
|
+
error: () => {
|
|
30
|
+
this.pending = undefined;
|
|
31
|
+
this.failed.set(true);
|
|
32
|
+
},
|
|
33
|
+
}),
|
|
34
|
+
map(() => undefined),
|
|
35
|
+
shareReplay({ bufferSize: 1, refCount: false }),
|
|
36
|
+
);
|
|
37
|
+
return this.pending;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Minor-unit exponent of a code; an unknown code is an error, never a guess. */
|
|
41
|
+
exponent(currency: string): number {
|
|
42
|
+
if (!this.ready()) throw new Error('sail: the currency catalog is not loaded.');
|
|
43
|
+
const row = this.byCode.get(currency);
|
|
44
|
+
if (!row) throw new Error(`sail: unknown currency "${currency}".`);
|
|
45
|
+
return row.Exponent;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
format(minor: number, currency: string): string {
|
|
49
|
+
return formatMinor(minor, currency, this.exponent(currency));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { DestroyRef, Pipe, PipeTransform, inject } from '@angular/core';
|
|
2
|
+
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|
3
|
+
import { formatMinor } from '../util/money';
|
|
4
|
+
import { CurrencyService } from './currency.service';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `{{ minor | sailMoney:currency }}` formats integer minor units at the catalog exponent, '' until
|
|
8
|
+
* the catalog loads; `{{ minor | sailMoney:currency:exponent }}` uses the exponent keel sent with
|
|
9
|
+
* the amount and needs no catalog. Impure so it re-renders once the catalog is ready.
|
|
10
|
+
*/
|
|
11
|
+
@Pipe({ name: 'sailMoney', pure: false })
|
|
12
|
+
export class MoneyPipe implements PipeTransform {
|
|
13
|
+
private readonly currencies = inject(CurrencyService);
|
|
14
|
+
private readonly destroyRef = inject(DestroyRef);
|
|
15
|
+
private requested = false;
|
|
16
|
+
|
|
17
|
+
transform(minor: number, currency: string, exponent?: number): string {
|
|
18
|
+
if (exponent !== undefined) return formatMinor(minor, currency, exponent);
|
|
19
|
+
if (this.currencies.ready()) return this.currencies.format(minor, currency);
|
|
20
|
+
if (this.currencies.failed()) throw new Error('sail: failed to load the currency catalog.');
|
|
21
|
+
if (!this.requested) {
|
|
22
|
+
this.requested = true;
|
|
23
|
+
this.currencies.load().pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ error: () => undefined });
|
|
24
|
+
}
|
|
25
|
+
return '';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -39,7 +39,7 @@ export class SailApiError extends Error {
|
|
|
39
39
|
|
|
40
40
|
@Injectable({providedIn: 'root'})
|
|
41
41
|
export class BackendService extends BaseRestService {
|
|
42
|
-
|
|
42
|
+
protected readonly auth = inject(BaseAuthService);
|
|
43
43
|
|
|
44
44
|
/** Resolved per call so configureRestUrls() may run any time before the first request. */
|
|
45
45
|
protected get prefix(): string {
|
package/src/service/rest_url.ts
CHANGED
|
@@ -25,6 +25,9 @@ export const RestURL = {
|
|
|
25
25
|
trustedDeviceRevokeURL: '/api/user/trusted-device/revoke',
|
|
26
26
|
sessionListURL: '/api/user/sessions',
|
|
27
27
|
sessionRevokeURL: '/api/user/sessions/revoke',
|
|
28
|
+
// keel GrantHandler (v1.2.112): the signed-in user's OAuth grants.
|
|
29
|
+
oauthGrantsURL: '/api/user/oauth-grants',
|
|
30
|
+
oauthGrantRevokeURL: '/api/user/oauth-grants/revoke',
|
|
28
31
|
checkoutURL: '/api/billing/checkout',
|
|
29
32
|
subscriptionURL: '/api/billing/subscription',
|
|
30
33
|
cancelSubURL: '/api/billing/subscription/cancel',
|
package/src/util/errors.ts
CHANGED
|
@@ -21,7 +21,8 @@ function asString(value: unknown): string | undefined {
|
|
|
21
21
|
return typeof value === 'string' && value ? value : undefined;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
/** The HTTP status of any sail error shape; undefined when it carries none. */
|
|
25
|
+
export function httpStatus(err: unknown): number | undefined {
|
|
25
26
|
if (!err || typeof err !== 'object') return undefined;
|
|
26
27
|
const s = (err as { status?: unknown }).status;
|
|
27
28
|
return typeof s === 'number' ? s : undefined;
|
|
@@ -34,12 +35,12 @@ function statusOf(err: unknown): number | undefined {
|
|
|
34
35
|
* available. Use it to offer a retry rather than reporting a backend rejection.
|
|
35
36
|
*/
|
|
36
37
|
export function isTransportFailure(err: unknown): boolean {
|
|
37
|
-
return
|
|
38
|
+
return httpStatus(err) === 0;
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
/** True for keel's 403: the session is valid but lacks the grant for this action. */
|
|
41
42
|
export function isPermissionDenied(err: unknown): boolean {
|
|
42
|
-
return
|
|
43
|
+
return httpStatus(err) === 403;
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
/**
|
|
@@ -55,7 +56,7 @@ export function isPermissionDenied(err: unknown): boolean {
|
|
|
55
56
|
* exposing their response text.
|
|
56
57
|
*/
|
|
57
58
|
export function errorDetail(err: unknown, fallback: string, statuses?: readonly number[]): string {
|
|
58
|
-
const status =
|
|
59
|
+
const status = httpStatus(err);
|
|
59
60
|
if (statuses && status !== undefined && !statuses.includes(status)) {
|
|
60
61
|
return fallback;
|
|
61
62
|
}
|
package/src/util/money.ts
CHANGED
|
@@ -1,31 +1,19 @@
|
|
|
1
1
|
import { PlanPrice } from '../model/appdata';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Format
|
|
5
|
-
*
|
|
6
|
-
* for an unknown currency code rather than throwing.
|
|
4
|
+
* Format integer minor units at the currency's exponent from keel's `currency` table.
|
|
5
|
+
* `Intl` supplies only the symbol and grouping; it never decides the decimals.
|
|
7
6
|
*/
|
|
8
|
-
export function
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
return `${amount} ${currency}`;
|
|
7
|
+
export function formatMinor(minor: number, currency: string, exponent: number): string {
|
|
8
|
+
if (!Number.isSafeInteger(minor)) throw new Error(`sail: money must be a safe integer (${minor}).`);
|
|
9
|
+
if (!Number.isInteger(exponent) || exponent < 0 || exponent > 6) {
|
|
10
|
+
throw new Error(`sail: currency "${currency}" has no valid exponent (${exponent}).`);
|
|
13
11
|
}
|
|
14
|
-
}
|
|
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
12
|
const code = currency.trim().toUpperCase();
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
} catch {
|
|
27
|
-
return `${minor} ${code}`;
|
|
28
|
-
}
|
|
13
|
+
if (!code) throw new Error('sail: currency is required.');
|
|
14
|
+
return new Intl.NumberFormat(undefined, {
|
|
15
|
+
style: 'currency', currency: code, minimumFractionDigits: exponent, maximumFractionDigits: exponent,
|
|
16
|
+
}).format(minor / 10 ** exponent);
|
|
29
17
|
}
|
|
30
18
|
|
|
31
19
|
/** PERIOD_TYPE code vocabulary — the single source for cycle labels/durations. */
|
|
@@ -38,19 +26,15 @@ export const PERIOD_TYPE_INFO: Record<string, { label: string; noun: string; suf
|
|
|
38
26
|
};
|
|
39
27
|
|
|
40
28
|
/**
|
|
41
|
-
* "From" price teaser for a plan card
|
|
42
|
-
*
|
|
43
|
-
* compared within one currency, rendered with its cycle ("from $30.00/mo").
|
|
44
|
-
* Returns '' when the plan has no priced offers (free/trial). The exact offer
|
|
45
|
-
* is chosen later via `<sail-price-selector>`.
|
|
29
|
+
* "From" price teaser for a plan card: the cheapest offer per month within the first
|
|
30
|
+
* offer's currency, rendered with its cycle ("from $30.00/mo"); '' when nothing is priced.
|
|
46
31
|
*/
|
|
47
|
-
export function fromPriceLabel(prices: PlanPrice[] | undefined): string {
|
|
48
|
-
const priced = (prices ?? []).filter((p) => p.
|
|
32
|
+
export function fromPriceLabel(prices: readonly PlanPrice[] | null | undefined): string {
|
|
33
|
+
const priced = (prices ?? []).filter((p) => p.amountMinor > 0);
|
|
49
34
|
if (priced.length === 0) return '';
|
|
50
|
-
const
|
|
51
|
-
const
|
|
52
|
-
const perMonth = (p: PlanPrice) => p.amount / (PERIOD_TYPE_INFO[p.billingCycle]?.months ?? 1);
|
|
35
|
+
const comparable = priced.filter((p) => p.currency === priced[0].currency);
|
|
36
|
+
const perMonth = (p: PlanPrice) => p.amountMinor / (PERIOD_TYPE_INFO[p.billingCycle]?.months ?? 1);
|
|
53
37
|
const lowest = comparable.reduce((a, b) => (perMonth(b) < perMonth(a) ? b : a));
|
|
54
|
-
const formatted =
|
|
55
|
-
return
|
|
38
|
+
const formatted = formatMinor(lowest.amountMinor, lowest.currency, lowest.exponent) + (PERIOD_TYPE_INFO[lowest.billingCycle]?.suffix ?? '');
|
|
39
|
+
return comparable.length > 1 ? `from ${formatted}` : formatted;
|
|
56
40
|
}
|