@nauticana/sail 1.0.0 → 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 +56 -3
- package/package.json +1 -1
- 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/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/index.ts +4 -0
- package/src/model/appdata.ts +14 -1
- package/src/service/billing.service.ts +14 -0
- package/src/service/rest_url.ts +2 -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,6 +337,8 @@ 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[]>` |
|
|
342
344
|
| `createPortalSession()` | `POST /api/billing/portal` | `Observable<PortalResponse>` |
|
|
@@ -463,13 +465,64 @@ A `TableAction` with `kind: 'redirect'` makes `BaseTable.executeAction` POST and
|
|
|
463
465
|
|
|
464
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.
|
|
465
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
|
+
|
|
466
515
|
### Compose a full Billing screen
|
|
467
516
|
|
|
468
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):
|
|
469
518
|
|
|
470
519
|
```html
|
|
471
|
-
<sail-
|
|
472
|
-
<sail-
|
|
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>
|
|
473
526
|
<sail-usage-meter [meters]="usage()"></sail-usage-meter>
|
|
474
527
|
<sail-payment-methods></sail-payment-methods>
|
|
475
528
|
<sail-portal-button></sail-portal-button>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nauticana/sail",
|
|
3
|
-
"version": "1.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": [
|
|
@@ -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,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
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -34,6 +34,10 @@ export * from './component/billing/checkout_button';
|
|
|
34
34
|
export * from './component/billing/payment_methods';
|
|
35
35
|
export * from './component/billing/portal_button';
|
|
36
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';
|
|
37
41
|
export * from './component/payout/provider_onboarding';
|
|
38
42
|
export * from './component/payout/bank_info_form';
|
|
39
43
|
export * from './component/payment/user_payment_methods';
|
package/src/model/appdata.ts
CHANGED
|
@@ -213,6 +213,13 @@ export interface PublicPlan {
|
|
|
213
213
|
// not-yet-priced tiers leave it empty. Pass straight to
|
|
214
214
|
// `<sail-checkout-button [priceId]>` / `BillingService.createCheckout({ priceId })`.
|
|
215
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;
|
|
216
223
|
}
|
|
217
224
|
|
|
218
225
|
// ConfirmRegisterResponse — shape returned by POST /public/confirm/register.
|
|
@@ -288,12 +295,18 @@ export interface PaginatedList<T> {
|
|
|
288
295
|
|
|
289
296
|
export interface Subscription {
|
|
290
297
|
planId: string;
|
|
291
|
-
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
|
|
292
299
|
begda: string;
|
|
293
300
|
endda?: string;
|
|
294
301
|
monthlyCost: number;
|
|
295
302
|
currency: string;
|
|
296
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;
|
|
297
310
|
}
|
|
298
311
|
|
|
299
312
|
export interface Invoice {
|
|
@@ -49,6 +49,20 @@ export class BillingService extends BaseRestService {
|
|
|
49
49
|
return this.http.post<void>(this.url(RestURL.cancelSubURL), {});
|
|
50
50
|
}
|
|
51
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
|
+
|
|
52
66
|
/** Partner's invoice history. */
|
|
53
67
|
listInvoices(): Observable<Invoice[]> {
|
|
54
68
|
return this.http.get<Invoice[]>(this.url(RestURL.invoicesURL));
|
package/src/service/rest_url.ts
CHANGED
|
@@ -20,6 +20,8 @@ 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',
|
|
25
27
|
portalURL: '/api/billing/portal',
|