@nauticana/sail 0.9.6 → 1.0.1
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 +94 -1
- package/package.json +1 -1
- package/src/component/abstract/base_table.ts +11 -2
- package/src/component/billing/dunning_banner.html +6 -0
- package/src/component/billing/dunning_banner.ts +23 -0
- package/src/component/billing/plan_selector.html +1 -1
- package/src/component/billing/plan_selector.ts +24 -1
- package/src/component/billing/portal_button.html +10 -0
- package/src/component/billing/portal_button.ts +31 -0
- package/src/component/billing/seat_selector.html +15 -0
- package/src/component/billing/seat_selector.ts +54 -0
- package/src/component/billing/status_chip.html +3 -0
- package/src/component/billing/status_chip.ts +39 -0
- package/src/component/billing/trial_banner.html +3 -0
- package/src/component/billing/trial_banner.ts +30 -0
- package/src/component/billing/usage_meter.html +20 -0
- package/src/component/billing/usage_meter.ts +44 -0
- package/src/index.ts +6 -0
- package/src/model/appdata.ts +27 -1
- package/src/service/billing.service.ts +30 -0
- package/src/service/rest_url.ts +4 -0
package/README.md
CHANGED
|
@@ -315,7 +315,7 @@ If "trust this device" appears to do nothing (user is 2FA-prompted on every logi
|
|
|
315
315
|
|
|
316
316
|
## Billing
|
|
317
317
|
|
|
318
|
-
sail ships a shared `BillingService` and
|
|
318
|
+
sail ships a shared `BillingService` and nine billing components backed by keel's payment endpoints (see [keel/SHARED_PAYMENT.md](https://github.com/nauticana/keel/blob/main/SHARED_PAYMENT.md) for the provider contract — Stripe by default, pluggable).
|
|
319
319
|
|
|
320
320
|
### Service
|
|
321
321
|
|
|
@@ -337,8 +337,12 @@ export class PricingPage {
|
|
|
337
337
|
| `createCheckout(req)` | `POST /api/billing/checkout` | `Observable<CheckoutResponse>` |
|
|
338
338
|
| `getSubscription()` | `GET /api/billing/subscription` | `Observable<Subscription>` |
|
|
339
339
|
| `cancelSubscription()` | `POST /api/billing/subscription/cancel` | `Observable<void>` |
|
|
340
|
+
| `changePlan(planId)` | `POST /api/billing/subscription/change` | `Observable<void>` |
|
|
341
|
+
| `setSeats(seats)` | `POST /api/billing/subscription/seats` | `Observable<void>` |
|
|
340
342
|
| `listInvoices()` | `GET /api/billing/invoices` | `Observable<Invoice[]>` |
|
|
341
343
|
| `listPaymentMethods()` | `GET /api/billing/payment-methods` | `Observable<PaymentMethod[]>` |
|
|
344
|
+
| `createPortalSession()` | `POST /api/billing/portal` | `Observable<PortalResponse>` |
|
|
345
|
+
| `listUsage()` | `GET /api/billing/usage` | `Observable<UsageMeter[]>` |
|
|
342
346
|
|
|
343
347
|
### Components
|
|
344
348
|
|
|
@@ -436,6 +440,95 @@ Add to `src/styles.css` to match the rest of the library:
|
|
|
436
440
|
.checkout-button { display: flex; flex-direction: column; gap: .5rem; }
|
|
437
441
|
```
|
|
438
442
|
|
|
443
|
+
### Customer portal (v1.0.0)
|
|
444
|
+
|
|
445
|
+
`createPortalSession()` + `<sail-portal-button>` mirror checkout — POST `/api/billing/portal`, then redirect to the provider-hosted customer portal where the partner manages their payment method / subscription:
|
|
446
|
+
|
|
447
|
+
```html
|
|
448
|
+
<sail-portal-button [label]="'Manage billing'"></sail-portal-button>
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
### Usage meters (v1.0.0)
|
|
452
|
+
|
|
453
|
+
`listUsage()` returns `UsageMeter[]` (`{ resource, used, limit }`, matching keel's `UsageItem`); `<sail-usage-meter>` renders one progress bar per resource (a `limit < 0` shows "Unlimited"):
|
|
454
|
+
|
|
455
|
+
```typescript
|
|
456
|
+
readonly meters = toSignal(this.billing.listUsage(), { initialValue: [] });
|
|
457
|
+
```
|
|
458
|
+
```html
|
|
459
|
+
<sail-usage-meter [meters]="meters()"></sail-usage-meter>
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
### Redirect-action (v1.0.0)
|
|
463
|
+
|
|
464
|
+
A `TableAction` with `kind: 'redirect'` makes `BaseTable.executeAction` POST and then follow the returned `{ url }` instead of refreshing — so checkout / portal / any provider-hosted handoff can be declared as a basis `table_action` row with **no bespoke component**. The backend action handler returns `{ "url": "https://…" }`.
|
|
465
|
+
|
|
466
|
+
> **Response-shape contract.** The generic redirect-action path reads a bare `url` because it's polymorphic — one dispatch for checkout / portal / export / etc., where the action's identity lives in the metadata, not the payload. keel's `WrapTableAction` enforces no shape; your inner handler must return `{ url }`. The *typed* billing endpoints keep specific names instead (`createCheckout()` → `checkoutUrl`, `createPortalSession()` → `portalUrl`). So the same portal concept has two response shapes depending on the path: wire it as `<sail-portal-button>` (typed → `portalUrl`) **or** as a `table_action` redirect (generic → `url`), not both.
|
|
467
|
+
|
|
468
|
+
### Status chip (v1.0.1)
|
|
469
|
+
|
|
470
|
+
`<sail-status-chip>` resolves a code to its caption from the client-cache `constant_value` dictionary — no per-app `@switch`. It defaults to the `SUBSCRIPTION_STATUS` domain (so `T` trialing / `X` past-due render with no app code) and works for any constant domain via `[domain]`. The code is exposed as `[attr.data-status]` so the app can colour the chip:
|
|
471
|
+
|
|
472
|
+
```html
|
|
473
|
+
<sail-status-chip [value]="sub()?.status"></sail-status-chip>
|
|
474
|
+
<sail-status-chip [value]="invoice.status" domain="INVOICE_STATUS"></sail-status-chip>
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
> Replaces a downstream app's hardcoded status `@switch` (e.g. seo's): captions are backend-authoritative — when a dictionary row is absent the raw code shows, never a fabricated label.
|
|
478
|
+
|
|
479
|
+
### Trial banner (v1.0.1)
|
|
480
|
+
|
|
481
|
+
`<sail-trial-banner>` shows "Trial ends in N days" from `Subscription.trialEnd`; it renders nothing when the value is empty, unparseable, or already past, so it can sit unconditionally in the template:
|
|
482
|
+
|
|
483
|
+
```html
|
|
484
|
+
<sail-trial-banner [trialEnd]="sub()?.trialEnd"></sail-trial-banner>
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
### Seat selector (v1.0.1)
|
|
488
|
+
|
|
489
|
+
`<sail-seat-selector>` is a presentational quantity stepper seeded from `Subscription.seats`. Feed the result into checkout (`[quantity]` / metadata) for new subs, or `BillingService.setSeats()` for an existing one:
|
|
490
|
+
|
|
491
|
+
```html
|
|
492
|
+
<sail-seat-selector [seats]="sub()?.seats ?? 1" [min]="1" (seatsChange)="seats.set($event)"></sail-seat-selector>
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
### Plan change vs first-time checkout (v1.0.1)
|
|
496
|
+
|
|
497
|
+
First-time checkout uses `<sail-checkout-button>` (provider-hosted). For an **existing** subscription, upgrade/downgrade is a distinct path — `BillingService.changePlan(planId)` routes to keel's `SubscriptionLifecycle.ChangePlan` via an endpoint the app exposes. Wire the picker's selection to whichever path applies:
|
|
498
|
+
|
|
499
|
+
```typescript
|
|
500
|
+
onPlanPicked(planId: string) {
|
|
501
|
+
this.sub() ? this.billing.changePlan(planId).subscribe() : this.startCheckout(planId);
|
|
502
|
+
}
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
`<sail-plan-selector>` varies its CTA copy by `PublicPlan.activationMode` ("Start trial" for `T`, "Subscribe" for `P`, "Activate" for `F`); override or extend via `[ctaLabels]="{ S: 'Add seats' }"`.
|
|
506
|
+
|
|
507
|
+
### Dunning banner (v1.0.1)
|
|
508
|
+
|
|
509
|
+
`<sail-dunning-banner>` shows only when `Subscription.status === 'X'` (past-due / keel `SetDunningState`), prompting the partner to update their card and deep-linking to the provider portal via an embedded `<sail-portal-button>`:
|
|
510
|
+
|
|
511
|
+
```html
|
|
512
|
+
<sail-dunning-banner [status]="sub()?.status"></sail-dunning-banner>
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
### Compose a full Billing screen
|
|
516
|
+
|
|
517
|
+
A complete screen is the pieces above assembled over `BillingService` — no metadata CRUD (billing data is partner-scoped, read-mostly, money-formatted, with an external-redirect action, which `TableList`/`TableEdit` don't model):
|
|
518
|
+
|
|
519
|
+
```html
|
|
520
|
+
<sail-dunning-banner [status]="sub()?.status"></sail-dunning-banner>
|
|
521
|
+
<sail-trial-banner [trialEnd]="sub()?.trialEnd"></sail-trial-banner>
|
|
522
|
+
<sail-status-chip [value]="sub()?.status"></sail-status-chip>
|
|
523
|
+
<sail-plan-selector [plans]="plans()" [selected]="sub()?.planId" (selectionChange)="onPlanPicked($event)"></sail-plan-selector>
|
|
524
|
+
<sail-seat-selector [seats]="sub()?.seats ?? 1" (seatsChange)="seats.set($event)"></sail-seat-selector>
|
|
525
|
+
<sail-checkout-button [priceId]="priceFor(selected())" [quantity]="seats()" [successUrl]="okUrl" [cancelUrl]="backUrl"></sail-checkout-button>
|
|
526
|
+
<sail-usage-meter [meters]="usage()"></sail-usage-meter>
|
|
527
|
+
<sail-payment-methods></sail-payment-methods>
|
|
528
|
+
<sail-portal-button></sail-portal-button>
|
|
529
|
+
<!-- invoices: render listInvoices() rows directly -->
|
|
530
|
+
```
|
|
531
|
+
|
|
439
532
|
## Consent capture
|
|
440
533
|
|
|
441
534
|
`ConsentGateComponent` is a reusable signup-consent primitive that mirrors keel's `user.SignupConsent` structure. It always renders two required checkboxes (`privacy_policy`, `cross_border`) and lets you declare any number of optional consents.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nauticana/sail",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.1",
|
|
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
|
"files": [
|
|
@@ -114,8 +114,17 @@ export abstract class BaseTable {
|
|
|
114
114
|
if (!this.beforeExecuteAction(action, record)) return;
|
|
115
115
|
if (action.confirmMessage && !confirm(action.confirmMessage)) return;
|
|
116
116
|
const body = action.recordSpecific && record ? this.primaryKeyValues(record) : {};
|
|
117
|
-
this.backendService.executeAction(action.method, body).subscribe({
|
|
118
|
-
next: () =>
|
|
117
|
+
this.backendService.executeAction<{ url?: string }>(action.method, body).subscribe({
|
|
118
|
+
next: (resp) => {
|
|
119
|
+
// A 'redirect' action POSTs then follows the returned {url}
|
|
120
|
+
// (provider-hosted checkout / portal / any external handoff
|
|
121
|
+
// declared as basis table_action metadata — no component).
|
|
122
|
+
if (action.kind === 'redirect' && resp?.url) {
|
|
123
|
+
window.location.href = resp.url;
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
this.onActionSuccess(action, record);
|
|
127
|
+
},
|
|
119
128
|
error: (err) => {
|
|
120
129
|
console.error(`Action ${action.action} failed`, err);
|
|
121
130
|
alert(err?.error?.detail ?? `Failed to ${action.caption}`);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, ViewEncapsulation, computed, input } from '@angular/core';
|
|
2
|
+
import { PortalButtonComponent } from './portal_button';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Past-due (dunning) banner. Shows only when the subscription status is the
|
|
6
|
+
* past-due code 'X' (keel `SetDunningState`), prompting the partner to update
|
|
7
|
+
* their card and deep-linking to the provider portal via `<sail-portal-button>`.
|
|
8
|
+
* Renders nothing otherwise, so it can sit unconditionally in the template.
|
|
9
|
+
*/
|
|
10
|
+
@Component({
|
|
11
|
+
selector: 'sail-dunning-banner',
|
|
12
|
+
templateUrl: './dunning_banner.html',
|
|
13
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
14
|
+
encapsulation: ViewEncapsulation.None,
|
|
15
|
+
imports: [PortalButtonComponent],
|
|
16
|
+
})
|
|
17
|
+
export class DunningBannerComponent {
|
|
18
|
+
/** Subscription.status — the banner shows when this is 'X' (past-due). */
|
|
19
|
+
readonly status = input<string | undefined>(undefined);
|
|
20
|
+
readonly message = input('Your payment is past due — please update your card to keep your subscription active.');
|
|
21
|
+
|
|
22
|
+
readonly isPastDue = computed(() => this.status() === 'X');
|
|
23
|
+
}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
</ul>
|
|
15
15
|
}
|
|
16
16
|
<button matButton="elevated" class="primary" type="button" (click)="onSelect(plan.id)">
|
|
17
|
-
@if (plan.id === selected()) { Selected } @else {
|
|
17
|
+
@if (plan.id === selected()) { Selected } @else { {{ ctas()[plan.id] }} }
|
|
18
18
|
</button>
|
|
19
19
|
</div>
|
|
20
20
|
}
|
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
import { ChangeDetectionStrategy, Component, ViewEncapsulation, input, output } from '@angular/core';
|
|
1
|
+
import { ChangeDetectionStrategy, Component, ViewEncapsulation, computed, input, output } from '@angular/core';
|
|
2
2
|
import { MatButtonModule } from '@angular/material/button';
|
|
3
3
|
import { PublicPlan } from '../../model/appdata';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Plan picker. Pure presentational component — stateless, no service calls.
|
|
7
7
|
* The caller provides the plans and handles the selection event.
|
|
8
|
+
*
|
|
9
|
+
* The per-plan CTA copy varies by `PublicPlan.activationMode` (e.g. 'T' →
|
|
10
|
+
* "Start trial", 'P' → "Subscribe"); override or extend the mapping via
|
|
11
|
+
* `[ctaLabels]` (e.g. `{ S: 'Add seats' }`).
|
|
8
12
|
*/
|
|
9
13
|
@Component({
|
|
10
14
|
selector: 'sail-plan-selector',
|
|
@@ -14,11 +18,30 @@ import { PublicPlan } from '../../model/appdata';
|
|
|
14
18
|
imports: [MatButtonModule],
|
|
15
19
|
})
|
|
16
20
|
export class PlanSelectorComponent {
|
|
21
|
+
private static readonly DEFAULT_CTA: Record<string, string> = {
|
|
22
|
+
T: 'Start trial',
|
|
23
|
+
F: 'Activate',
|
|
24
|
+
P: 'Subscribe',
|
|
25
|
+
};
|
|
26
|
+
|
|
17
27
|
readonly plans = input<PublicPlan[]>([]);
|
|
18
28
|
readonly selected = input<string | undefined>(undefined);
|
|
19
29
|
readonly features = input<{[planId: string]: string[]}>({});
|
|
30
|
+
/** activationMode → CTA copy; overrides/extends the built-in defaults. */
|
|
31
|
+
readonly ctaLabels = input<{[activationMode: string]: string}>({});
|
|
20
32
|
readonly selectionChange = output<string>();
|
|
21
33
|
|
|
34
|
+
/** planId → CTA label, resolved from activationMode (override wins). */
|
|
35
|
+
readonly ctas = computed<Record<string, string>>(() => {
|
|
36
|
+
const overrides = this.ctaLabels();
|
|
37
|
+
const out: Record<string, string> = {};
|
|
38
|
+
for (const plan of this.plans()) {
|
|
39
|
+
const mode = plan.activationMode ?? '';
|
|
40
|
+
out[plan.id] = overrides[mode] ?? PlanSelectorComponent.DEFAULT_CTA[mode] ?? 'Choose';
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
});
|
|
44
|
+
|
|
22
45
|
onSelect(planId: string): void {
|
|
23
46
|
this.selectionChange.emit(planId);
|
|
24
47
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<div class="portal-button">
|
|
2
|
+
<button matButton="elevated" class="primary" type="button"
|
|
3
|
+
[disabled]="loading()"
|
|
4
|
+
(click)="openPortal()">
|
|
5
|
+
@if (loading()) { Opening… } @else { {{ label() }} }
|
|
6
|
+
</button>
|
|
7
|
+
@if (errorMessage()) {
|
|
8
|
+
<div class="auth-error" role="alert">{{ errorMessage() }}</div>
|
|
9
|
+
}
|
|
10
|
+
</div>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, ViewEncapsulation, inject, input } from '@angular/core';
|
|
2
|
+
import { MatButtonModule } from '@angular/material/button';
|
|
3
|
+
import { BaseAsync } from '../abstract/base_async';
|
|
4
|
+
import { BillingService } from '../../service/billing.service';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Button that opens a provider customer-portal session and redirects the
|
|
8
|
+
* browser to the returned URL — the partner manages their payment method /
|
|
9
|
+
* subscription on the provider's hosted page. Mirrors CheckoutButtonComponent;
|
|
10
|
+
* uses BaseAsync for loading/error state.
|
|
11
|
+
*/
|
|
12
|
+
@Component({
|
|
13
|
+
selector: 'sail-portal-button',
|
|
14
|
+
templateUrl: './portal_button.html',
|
|
15
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
16
|
+
encapsulation: ViewEncapsulation.None,
|
|
17
|
+
imports: [MatButtonModule],
|
|
18
|
+
})
|
|
19
|
+
export class PortalButtonComponent extends BaseAsync {
|
|
20
|
+
private readonly billing = inject(BillingService);
|
|
21
|
+
|
|
22
|
+
readonly label = input('Manage billing');
|
|
23
|
+
|
|
24
|
+
openPortal(): void {
|
|
25
|
+
this.run(
|
|
26
|
+
this.billing.createPortalSession(),
|
|
27
|
+
(resp) => { window.location.href = resp.portalUrl; },
|
|
28
|
+
'Could not open the billing portal. Please try again.',
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<div class="seat-selector" role="group" aria-label="Seats">
|
|
2
|
+
<button matIconButton type="button"
|
|
3
|
+
aria-label="Decrease seats"
|
|
4
|
+
[disabled]="!canDecrease()"
|
|
5
|
+
(click)="decrease()">
|
|
6
|
+
<mat-icon>remove</mat-icon>
|
|
7
|
+
</button>
|
|
8
|
+
<span class="seat-count" aria-live="polite">{{ seats() }}</span>
|
|
9
|
+
<button matIconButton type="button"
|
|
10
|
+
aria-label="Increase seats"
|
|
11
|
+
[disabled]="!canIncrease()"
|
|
12
|
+
(click)="increase()">
|
|
13
|
+
<mat-icon>add</mat-icon>
|
|
14
|
+
</button>
|
|
15
|
+
</div>
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, ViewEncapsulation, computed, effect, input, output, signal } from '@angular/core';
|
|
2
|
+
import { MatButtonModule } from '@angular/material/button';
|
|
3
|
+
import { MatIconModule } from '@angular/material/icon';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Seat quantity stepper. Presentational — emits the chosen count; the caller
|
|
7
|
+
* feeds it into `<sail-checkout-button [quantity]>` / checkout metadata or a
|
|
8
|
+
* `BillingService.setSeats()` call. Seed `[seats]` from `Subscription.seats`.
|
|
9
|
+
*/
|
|
10
|
+
@Component({
|
|
11
|
+
selector: 'sail-seat-selector',
|
|
12
|
+
templateUrl: './seat_selector.html',
|
|
13
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
14
|
+
encapsulation: ViewEncapsulation.None,
|
|
15
|
+
imports: [MatButtonModule, MatIconModule],
|
|
16
|
+
})
|
|
17
|
+
export class SeatSelectorComponent {
|
|
18
|
+
/** Initial / parent-bound seat count. */
|
|
19
|
+
readonly seatsInput = input(1, { alias: 'seats' });
|
|
20
|
+
/** Minimum selectable seats. */
|
|
21
|
+
readonly min = input(1);
|
|
22
|
+
/** Maximum selectable seats; unbounded when undefined. */
|
|
23
|
+
readonly max = input<number | undefined>(undefined);
|
|
24
|
+
readonly seatsChange = output<number>();
|
|
25
|
+
|
|
26
|
+
/** Locally-mutated value, synced from the parent-bound input. */
|
|
27
|
+
readonly seats = signal(1);
|
|
28
|
+
|
|
29
|
+
readonly canDecrease = computed(() => this.seats() > this.min());
|
|
30
|
+
readonly canIncrease = computed(() => {
|
|
31
|
+
const max = this.max();
|
|
32
|
+
return max === undefined || this.seats() < max;
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
constructor() {
|
|
36
|
+
effect(() => this.seats.set(this.seatsInput()));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
decrease(): void {
|
|
40
|
+
this.commit(Math.max(this.min(), this.seats() - 1));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
increase(): void {
|
|
44
|
+
const max = this.max();
|
|
45
|
+
const next = this.seats() + 1;
|
|
46
|
+
this.commit(max === undefined ? next : Math.min(max, next));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private commit(value: number): void {
|
|
50
|
+
if (value === this.seats()) return;
|
|
51
|
+
this.seats.set(value);
|
|
52
|
+
this.seatsChange.emit(value);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, ViewEncapsulation, computed, inject, input } from '@angular/core';
|
|
2
|
+
import { toSignal } from '@angular/core/rxjs-interop';
|
|
3
|
+
import { BaseAuthService } from '../../service/auth.service';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Status chip/label that resolves a code to its caption from the client cache
|
|
7
|
+
* `constant_value` dictionary — no per-app `@switch`. Defaults to the
|
|
8
|
+
* `SUBSCRIPTION_STATUS` domain (A active / T trialing / X past-due / …) but
|
|
9
|
+
* works for any constant domain via `[domain]`.
|
|
10
|
+
*
|
|
11
|
+
* The caption is backend-authoritative (read from the dictionary); when the
|
|
12
|
+
* domain row is absent the raw code is shown rather than a fabricated label.
|
|
13
|
+
* `[attr.data-status]` carries the code so the consuming app can colour the
|
|
14
|
+
* chip per status (the library ships no CSS).
|
|
15
|
+
*/
|
|
16
|
+
@Component({
|
|
17
|
+
selector: 'sail-status-chip',
|
|
18
|
+
templateUrl: './status_chip.html',
|
|
19
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
20
|
+
encapsulation: ViewEncapsulation.None,
|
|
21
|
+
})
|
|
22
|
+
export class StatusChipComponent {
|
|
23
|
+
private readonly auth = inject(BaseAuthService);
|
|
24
|
+
|
|
25
|
+
/** The code to resolve (e.g. a Subscription.status value). */
|
|
26
|
+
readonly value = input<string>('');
|
|
27
|
+
/** Constant-value domain to look the caption up in. */
|
|
28
|
+
readonly domain = input<string>('SUBSCRIPTION_STATUS');
|
|
29
|
+
|
|
30
|
+
// Recompute when the cache (app data) loads or the inputs change.
|
|
31
|
+
private readonly appData = toSignal(this.auth.getAppData());
|
|
32
|
+
|
|
33
|
+
readonly caption = computed<string>(() => {
|
|
34
|
+
this.appData(); // dependency: re-read the dictionary once the cache is hydrated
|
|
35
|
+
const code = this.value();
|
|
36
|
+
const match = this.auth.getDomainValues(this.domain())?.find((v) => v.Value === code);
|
|
37
|
+
return match?.Caption ?? code;
|
|
38
|
+
});
|
|
39
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, ViewEncapsulation, computed, input } from '@angular/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* "Trial ends in N days" banner, bound to `Subscription.trialEnd`. Renders
|
|
5
|
+
* nothing when `trialEnd` is empty, unparseable, or already past, so it can be
|
|
6
|
+
* left in the template unconditionally.
|
|
7
|
+
*/
|
|
8
|
+
@Component({
|
|
9
|
+
selector: 'sail-trial-banner',
|
|
10
|
+
templateUrl: './trial_banner.html',
|
|
11
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
12
|
+
encapsulation: ViewEncapsulation.None,
|
|
13
|
+
})
|
|
14
|
+
export class TrialBannerComponent {
|
|
15
|
+
/** ISO timestamp the trial ends (Subscription.trialEnd). */
|
|
16
|
+
readonly trialEnd = input<string | undefined>(undefined);
|
|
17
|
+
|
|
18
|
+
readonly daysLeft = computed<number>(() => {
|
|
19
|
+
const raw = this.trialEnd();
|
|
20
|
+
if (!raw) return 0;
|
|
21
|
+
const end = Date.parse(raw);
|
|
22
|
+
if (Number.isNaN(end)) return 0;
|
|
23
|
+
return Math.ceil((end - Date.now()) / 86_400_000);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
readonly message = computed<string>(() => {
|
|
27
|
+
const d = this.daysLeft();
|
|
28
|
+
return d === 1 ? 'Trial ends in 1 day' : `Trial ends in ${d} days`;
|
|
29
|
+
});
|
|
30
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<div class="usage-meter">
|
|
2
|
+
@for (r of rows(); track r.resource) {
|
|
3
|
+
<div class="usage-row">
|
|
4
|
+
<div class="usage-label">{{ r.resource }}</div>
|
|
5
|
+
@if (r.hasBar) {
|
|
6
|
+
<div class="usage-bar" role="progressbar"
|
|
7
|
+
[attr.aria-label]="r.resource"
|
|
8
|
+
[attr.aria-valuemin]="0"
|
|
9
|
+
[attr.aria-valuemax]="r.ariaMax"
|
|
10
|
+
[attr.aria-valuenow]="r.ariaNow"
|
|
11
|
+
[attr.aria-valuetext]="r.valueText">
|
|
12
|
+
<div class="usage-fill" [style.width.%]="r.percent"></div>
|
|
13
|
+
</div>
|
|
14
|
+
}
|
|
15
|
+
<div class="usage-value">{{ r.valueText }}</div>
|
|
16
|
+
</div>
|
|
17
|
+
} @empty {
|
|
18
|
+
<div class="usage-empty">No usage data.</div>
|
|
19
|
+
}
|
|
20
|
+
</div>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, ViewEncapsulation, computed, input } from '@angular/core';
|
|
2
|
+
import { UsageMeter } from '../../model/appdata';
|
|
3
|
+
|
|
4
|
+
/** Per-row view model derived from a UsageMeter for accessible rendering. */
|
|
5
|
+
interface UsageRow {
|
|
6
|
+
resource: string;
|
|
7
|
+
hasBar: boolean; // limit > 0 → determinate progress bar; else text-only
|
|
8
|
+
percent: number; // fill width 0..100 (clamped)
|
|
9
|
+
ariaMax: number; // limit (only meaningful when hasBar)
|
|
10
|
+
ariaNow: number; // used, clamped to [0, limit] for a valid progressbar
|
|
11
|
+
valueText: string; // "used / limit", "used / Unlimited" (<0), or "used / 0"
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Presentational usage meters — one progress bar per resource (used / limit).
|
|
16
|
+
* Stateless; the caller (or BillingService.listUsage) supplies the data.
|
|
17
|
+
*
|
|
18
|
+
* Limit semantics mirror keel's UsageItem: `limit < 0` = unlimited (keel's
|
|
19
|
+
* documented sentinel), `limit === 0` = a real zero / unprovisioned cap. Only
|
|
20
|
+
* `limit > 0` renders a determinate progress bar; the other two are text-only.
|
|
21
|
+
*/
|
|
22
|
+
@Component({
|
|
23
|
+
selector: 'sail-usage-meter',
|
|
24
|
+
templateUrl: './usage_meter.html',
|
|
25
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
26
|
+
encapsulation: ViewEncapsulation.None,
|
|
27
|
+
})
|
|
28
|
+
export class UsageMeterComponent {
|
|
29
|
+
readonly meters = input<UsageMeter[]>([]);
|
|
30
|
+
|
|
31
|
+
readonly rows = computed<UsageRow[]>(() =>
|
|
32
|
+
this.meters().map((m) => {
|
|
33
|
+
const hasBar = m.limit > 0;
|
|
34
|
+
return {
|
|
35
|
+
resource: m.resource,
|
|
36
|
+
hasBar,
|
|
37
|
+
percent: hasBar ? Math.min(100, Math.round((m.used / m.limit) * 100)) : 0,
|
|
38
|
+
ariaMax: m.limit,
|
|
39
|
+
ariaNow: hasBar ? Math.min(m.used, m.limit) : m.used,
|
|
40
|
+
valueText: `${m.used} / ${m.limit < 0 ? 'Unlimited' : m.limit}`,
|
|
41
|
+
};
|
|
42
|
+
}),
|
|
43
|
+
);
|
|
44
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -32,6 +32,12 @@ export * from './component/security/trusted_devices';
|
|
|
32
32
|
export * from './component/billing/plan_selector';
|
|
33
33
|
export * from './component/billing/checkout_button';
|
|
34
34
|
export * from './component/billing/payment_methods';
|
|
35
|
+
export * from './component/billing/portal_button';
|
|
36
|
+
export * from './component/billing/usage_meter';
|
|
37
|
+
export * from './component/billing/status_chip';
|
|
38
|
+
export * from './component/billing/trial_banner';
|
|
39
|
+
export * from './component/billing/seat_selector';
|
|
40
|
+
export * from './component/billing/dunning_banner';
|
|
35
41
|
export * from './component/payout/provider_onboarding';
|
|
36
42
|
export * from './component/payout/bank_info_form';
|
|
37
43
|
export * from './component/payment/user_payment_methods';
|
package/src/model/appdata.ts
CHANGED
|
@@ -83,6 +83,7 @@ export interface TableAction {
|
|
|
83
83
|
confirmMessage?: string;
|
|
84
84
|
authorityObject: string; // uppercased table_name — for canExecute()
|
|
85
85
|
authorityCheck: string; // uppercased action_name — for canExecute()
|
|
86
|
+
kind?: 'post' | 'redirect'; // 'redirect' = POST then follow the returned {url}; default 'post'
|
|
86
87
|
}
|
|
87
88
|
|
|
88
89
|
export interface ForeignKey {
|
|
@@ -212,6 +213,13 @@ export interface PublicPlan {
|
|
|
212
213
|
// not-yet-priced tiers leave it empty. Pass straight to
|
|
213
214
|
// `<sail-checkout-button [priceId]>` / `BillingService.createCheckout({ priceId })`.
|
|
214
215
|
priceId?: string;
|
|
216
|
+
// keel `subscription_plan.activation_mode` (SUBSCRIPTION_ACTIVATION_MODE
|
|
217
|
+
// dictionary, single char — e.g. 'P' paid, 'F' free, 'T' trial). Drives the
|
|
218
|
+
// picker CTA copy ("Start trial" / "Subscribe" / …). Optional — pre-v1.0.3
|
|
219
|
+
// backends omit it.
|
|
220
|
+
activationMode?: string;
|
|
221
|
+
// Length of the free trial in days when `activationMode === 'T'`.
|
|
222
|
+
trialDays?: number;
|
|
215
223
|
}
|
|
216
224
|
|
|
217
225
|
// ConfirmRegisterResponse — shape returned by POST /public/confirm/register.
|
|
@@ -264,6 +272,18 @@ export interface CheckoutResponse {
|
|
|
264
272
|
checkoutUrl: string;
|
|
265
273
|
}
|
|
266
274
|
|
|
275
|
+
/** Response of BillingService.createPortalSession — the provider customer-portal URL. */
|
|
276
|
+
export interface PortalResponse {
|
|
277
|
+
portalUrl: string;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** One resource's current-period usage vs its plan limit (matches keel UsageItem). */
|
|
281
|
+
export interface UsageMeter {
|
|
282
|
+
resource: string;
|
|
283
|
+
used: number;
|
|
284
|
+
limit: number;
|
|
285
|
+
}
|
|
286
|
+
|
|
267
287
|
// REST list endpoints return paginated wrappers. `BackendService.list<T>()`
|
|
268
288
|
// unwraps to the array; use `listPaginated<T>()` to get the metadata.
|
|
269
289
|
export interface PaginatedList<T> {
|
|
@@ -275,12 +295,18 @@ export interface PaginatedList<T> {
|
|
|
275
295
|
|
|
276
296
|
export interface Subscription {
|
|
277
297
|
planId: string;
|
|
278
|
-
status: string; // 'A' active, 'P' pending-payment, 'C' cancelled,
|
|
298
|
+
status: string; // 'A' active, 'P' pending-payment, 'C' cancelled, 'T' trialing, 'X' past-due — caption via SUBSCRIPTION_STATUS dict
|
|
279
299
|
begda: string;
|
|
280
300
|
endda?: string;
|
|
281
301
|
monthlyCost: number;
|
|
282
302
|
currency: string;
|
|
283
303
|
autoRenew: boolean;
|
|
304
|
+
// ISO timestamp the trial ends (keel `trial_end`); empty/absent when not
|
|
305
|
+
// trialing. Drives `<sail-trial-banner>`.
|
|
306
|
+
trialEnd?: string;
|
|
307
|
+
// Seat quantity on the active/trial sub (keel `seats`); absent for
|
|
308
|
+
// non-seat plans. Drives `<sail-seat-selector>`.
|
|
309
|
+
seats?: number;
|
|
284
310
|
}
|
|
285
311
|
|
|
286
312
|
export interface Invoice {
|
|
@@ -7,8 +7,10 @@ import {
|
|
|
7
7
|
CheckoutResponse,
|
|
8
8
|
Invoice,
|
|
9
9
|
PaymentMethod,
|
|
10
|
+
PortalResponse,
|
|
10
11
|
PublicPlan,
|
|
11
12
|
Subscription,
|
|
13
|
+
UsageMeter,
|
|
12
14
|
} from '../model/appdata';
|
|
13
15
|
|
|
14
16
|
/**
|
|
@@ -47,6 +49,20 @@ export class BillingService extends BaseRestService {
|
|
|
47
49
|
return this.http.post<void>(this.url(RestURL.cancelSubURL), {});
|
|
48
50
|
}
|
|
49
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Upgrade / downgrade the current subscription to `planId` — distinct from
|
|
54
|
+
* first-time checkout. Routes to keel's `SubscriptionLifecycle.ChangePlan`
|
|
55
|
+
* (atomic close+open) via an endpoint the app exposes.
|
|
56
|
+
*/
|
|
57
|
+
changePlan(planId: string): Observable<void> {
|
|
58
|
+
return this.http.post<void>(this.url(RestURL.changePlanURL), { planId });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Set the seat quantity on the current active/trial subscription. */
|
|
62
|
+
setSeats(seats: number): Observable<void> {
|
|
63
|
+
return this.http.post<void>(this.url(RestURL.seatsURL), { seats });
|
|
64
|
+
}
|
|
65
|
+
|
|
50
66
|
/** Partner's invoice history. */
|
|
51
67
|
listInvoices(): Observable<Invoice[]> {
|
|
52
68
|
return this.http.get<Invoice[]>(this.url(RestURL.invoicesURL));
|
|
@@ -56,4 +72,18 @@ export class BillingService extends BaseRestService {
|
|
|
56
72
|
listPaymentMethods(): Observable<PaymentMethod[]> {
|
|
57
73
|
return this.http.get<PaymentMethod[]>(this.url(RestURL.paymentMethodsURL));
|
|
58
74
|
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Create a provider customer-portal session; caller redirects to the URL so
|
|
78
|
+
* the partner can manage their payment method / subscription on the
|
|
79
|
+
* provider's hosted page.
|
|
80
|
+
*/
|
|
81
|
+
createPortalSession(): Observable<PortalResponse> {
|
|
82
|
+
return this.http.post<PortalResponse>(this.url(RestURL.portalURL), {});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Current-period usage per resource (used vs plan limit). */
|
|
86
|
+
listUsage(): Observable<UsageMeter[]> {
|
|
87
|
+
return this.http.get<UsageMeter[]>(this.url(RestURL.usageURL));
|
|
88
|
+
}
|
|
59
89
|
}
|
package/src/service/rest_url.ts
CHANGED
|
@@ -20,8 +20,12 @@ export const RestURL = {
|
|
|
20
20
|
checkoutURL: '/api/billing/checkout',
|
|
21
21
|
subscriptionURL: '/api/billing/subscription',
|
|
22
22
|
cancelSubURL: '/api/billing/subscription/cancel',
|
|
23
|
+
changePlanURL: '/api/billing/subscription/change',
|
|
24
|
+
seatsURL: '/api/billing/subscription/seats',
|
|
23
25
|
invoicesURL: '/api/billing/invoices',
|
|
24
26
|
paymentMethodsURL: '/api/billing/payment-methods',
|
|
27
|
+
portalURL: '/api/billing/portal',
|
|
28
|
+
usageURL: '/api/billing/usage',
|
|
25
29
|
// OTP / social / push / account-lifecycle endpoints
|
|
26
30
|
otpSendURL: '/public/otp/send',
|
|
27
31
|
otpVerifyURL: '/public/otp/verify',
|