@nauticana/sail 1.0.0 → 1.0.2

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 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 five 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).
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,19 +465,134 @@ 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-plan-selector [plans]="plans()" [selected]="sub()?.planId" (selectionChange)="selected.set($event)"></sail-plan-selector>
472
- <sail-checkout-button [priceId]="priceFor(selected())" [successUrl]="okUrl" [cancelUrl]="backUrl"></sail-checkout-button>
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>
476
529
  <!-- invoices: render listInvoices() rows directly -->
477
530
  ```
478
531
 
532
+ ## Off-session SCA / 3DS confirmation (v1.0.x)
533
+
534
+ When an app charges a rider's **vaulted** card off-session (keel `payment.ChargeClient` / `StripeChargeClient`), the provider may return `requires_action` — the cardholder must complete a Strong Customer Authentication (3DS) challenge. keel surfaces the hooks on `ChargeResult` (`ClientSecret`, `ActionURL`); sail standardizes the **client-side** confirmation so it isn't rebuilt per app.
535
+
536
+ sail stays provider-agnostic: it ships **no payment-provider SDK** and **no default confirmer**. It provides a port (`ScaConfirmer`), the `SCA_CONFIRMER` injection token, and `<sail-sca-confirm>`, which:
537
+
538
+ - redirects to `ChargeResult.actionUrl` when present (provider-hosted challenge — no SDK needed); else
539
+ - delegates the inline `ChargeResult.clientSecret` to the app-registered `SCA_CONFIRMER`.
540
+
541
+ ### Backend contract (app-owned)
542
+
543
+ Off-session charging is a worker/handler concern, so the **app** owns the charge endpoint. Have it call keel's `ChargeClient.Charge` and return the normalized result as camelCase JSON matching sail's `ChargeResult`:
544
+
545
+ ```jsonc
546
+ // POST /api/<app>/charge → 200
547
+ { "status": "requires_action", "clientSecret": "pi_..._secret_...", "actionUrl": "" }
548
+ // or { "status": "succeeded", "providerChargeId": "pi_..." }
549
+ // or { "status": "failed", "error": "card_declined" }
550
+ ```
551
+
552
+ > `ChargeRequest.Metadata` (keel v1.0.4) carries `ride_id` / `user_id` / `tip_amount` through to the settled charge's `PaymentEvent.Metadata`, so the webhook can correlate and split the fare — set it on the backend, not the client.
553
+
554
+ ### Frontend adoption (downstream projects)
555
+
556
+ **1. Implement the provider confirmer** (Stripe shown; lives in the app, behind an *optional* `@stripe/stripe-js` peer dep — non-Stripe apps skip it):
557
+
558
+ ```typescript
559
+ import { Injectable } from '@angular/core';
560
+ import { loadStripe } from '@stripe/stripe-js';
561
+ import { ScaConfirmer, ScaResult } from '@nauticana/sail';
562
+
563
+ @Injectable({ providedIn: 'root' })
564
+ export class StripeScaConfirmer implements ScaConfirmer {
565
+ async confirm(clientSecret: string): Promise<ScaResult> {
566
+ const stripe = await loadStripe(/* publishable key from backend cache */);
567
+ if (!stripe) return { outcome: 'failed', error: 'Stripe.js failed to load' };
568
+ const { error } = await stripe.handleNextAction({ clientSecret });
569
+ return error ? { outcome: 'failed', error: error.message } : { outcome: 'succeeded' };
570
+ }
571
+ }
572
+ ```
573
+
574
+ **2. Register it at composition time** (`app.config.ts` / `main.ts`):
575
+
576
+ ```typescript
577
+ import { SCA_CONFIRMER } from '@nauticana/sail';
578
+
579
+ providers: [
580
+ { provide: SCA_CONFIRMER, useExisting: StripeScaConfirmer },
581
+ ]
582
+ ```
583
+
584
+ **3. Drop the component into the post-charge flow** — bind the charge response and react to the outcome:
585
+
586
+ ```html
587
+ <sail-sca-confirm
588
+ [result]="charge()"
589
+ (confirmed)="onPaid()"
590
+ (failed)="onScaFailed($event)">
591
+ </sail-sca-confirm>
592
+ ```
593
+
594
+ `<sail-sca-confirm>` renders nothing for `succeeded`, the decline message for `failed`, and a confirm button for `requires_action`. The publishable key is backend-authoritative — read it from the auth/app-data cache, never hardcode a fallback.
595
+
479
596
  ## Consent capture
480
597
 
481
598
  `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": "1.0.0",
3
+ "version": "1.0.2",
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,6 @@
1
+ @if (isPastDue()) {
2
+ <div class="dunning-banner" role="alert">
3
+ <span class="dunning-message">{{ message() }}</span>
4
+ <sail-portal-button label="Update your card"></sail-portal-button>
5
+ </div>
6
+ }
@@ -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 { Choose }
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,3 @@
1
+ @if (value()) {
2
+ <span class="status-chip" [attr.data-status]="value()">{{ caption() }}</span>
3
+ }
@@ -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,3 @@
1
+ @if (daysLeft() > 0) {
2
+ <div class="trial-banner" role="status">{{ message() }}</div>
3
+ }
@@ -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,14 @@
1
+ @if (needsAction()) {
2
+ <div class="sca-confirm">
3
+ <button matButton="elevated" class="primary" type="button"
4
+ [disabled]="loading()"
5
+ (click)="confirm()">
6
+ @if (loading()) { Confirming… } @else { {{ label() }} }
7
+ </button>
8
+ </div>
9
+ } @else if (declined()) {
10
+ <div class="auth-error" role="alert">{{ result().error ?? 'Your payment was declined.' }}</div>
11
+ }
12
+ @if (errorMessage()) {
13
+ <div class="auth-error" role="alert">{{ errorMessage() }}</div>
14
+ }
@@ -0,0 +1,77 @@
1
+ import { ChangeDetectionStrategy, Component, ViewEncapsulation, computed, inject, input, output } from '@angular/core';
2
+ import { MatButtonModule } from '@angular/material/button';
3
+ import { BaseAsync } from '../abstract/base_async';
4
+ import { ChargeResult } from '../../model/appdata';
5
+ import { SCA_CONFIRMER } from '../../service/sca_confirmer';
6
+
7
+ /**
8
+ * Drives the SCA / 3DS confirmation for an off-session `ChargeResult`.
9
+ *
10
+ * Two confirmation paths, mirroring keel's `ChargeResult`:
11
+ * - `actionUrl` present → redirect to the provider-hosted challenge
12
+ * (provider-agnostic; the page navigates away).
13
+ * - otherwise `clientSecret` → delegate to the injected `SCA_CONFIRMER`
14
+ * (the app's provider SDK wrapper). No confirmer is bundled in sail.
15
+ *
16
+ * Renders nothing for a `succeeded` result; shows the decline message for
17
+ * `failed`; shows a confirm button for `requires_action`. Emits `(confirmed)`
18
+ * on success and `(failed)` with a message on any error — never silently.
19
+ */
20
+ @Component({
21
+ selector: 'sail-sca-confirm',
22
+ templateUrl: './sca_confirm.html',
23
+ changeDetection: ChangeDetectionStrategy.OnPush,
24
+ encapsulation: ViewEncapsulation.None,
25
+ imports: [MatButtonModule],
26
+ })
27
+ export class ScaConfirmComponent extends BaseAsync {
28
+ private readonly confirmer = inject(SCA_CONFIRMER, { optional: true });
29
+
30
+ readonly result = input.required<ChargeResult>();
31
+ readonly label = input('Confirm your payment');
32
+ readonly confirmed = output<void>();
33
+ readonly failed = output<string>();
34
+
35
+ readonly needsAction = computed(() => this.result().status === 'requires_action');
36
+ readonly declined = computed(() => this.result().status === 'failed');
37
+
38
+ async confirm(): Promise<void> {
39
+ const res = this.result();
40
+
41
+ // Provider-hosted redirect branch — provider-agnostic; navigates away.
42
+ if (res.actionUrl) {
43
+ window.location.href = res.actionUrl;
44
+ return;
45
+ }
46
+
47
+ // Inline confirmation branch — delegates to the app's provider impl.
48
+ if (!res.clientSecret) {
49
+ this.fail('Missing client secret for SCA confirmation.');
50
+ return;
51
+ }
52
+ if (!this.confirmer) {
53
+ this.fail('No SCA confirmer is configured. Provide SCA_CONFIRMER (e.g. a Stripe.js wrapper).');
54
+ return;
55
+ }
56
+
57
+ this.clearMessages();
58
+ this.loading.set(true);
59
+ try {
60
+ const out = await this.confirmer.confirm(res.clientSecret);
61
+ this.loading.set(false);
62
+ if (out.outcome === 'succeeded') {
63
+ this.confirmed.emit();
64
+ } else {
65
+ this.fail(out.error ?? 'Payment confirmation was not completed.');
66
+ }
67
+ } catch (e: unknown) {
68
+ this.loading.set(false);
69
+ this.fail(e instanceof Error ? e.message : 'Payment confirmation failed.');
70
+ }
71
+ }
72
+
73
+ private fail(message: string): void {
74
+ this.errorMessage.set(message);
75
+ this.failed.emit(message);
76
+ }
77
+ }
package/src/index.ts CHANGED
@@ -34,10 +34,16 @@ 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';
44
+ export * from './component/payment/sca_confirm';
40
45
  export * from './service/auth.service';
46
+ export * from './service/sca_confirmer';
41
47
  export * from './service/billing.service';
42
48
  export * from './service/payout.service';
43
49
  export * from './service/user_payment_method.service';
@@ -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.
@@ -277,6 +284,25 @@ export interface UsageMeter {
277
284
  limit: number;
278
285
  }
279
286
 
287
+ // Normalized outcome of an off-session charge — the camelCase JSON shape an app
288
+ // returns from its own charge endpoint, mirroring keel `payment.ChargeResult`.
289
+ // 'requires_action' means the rider must complete an SCA / 3DS confirmation
290
+ // (drive it with `<sail-sca-confirm>`).
291
+ export type ChargeStatus = 'succeeded' | 'requires_action' | 'failed';
292
+
293
+ export interface ChargeResult {
294
+ status: ChargeStatus;
295
+ providerChargeId?: string; // PaymentIntent / charge id (pi_xxx)
296
+ // Provider client secret — present when status === 'requires_action' and the
297
+ // SCA challenge must be confirmed inline (provider SDK). Mirrors keel
298
+ // ChargeResult.ClientSecret.
299
+ clientSecret?: string;
300
+ // Provider-hosted redirect URL when one exists (often empty for off-session
301
+ // 3DS). When present it's the simplest, provider-agnostic confirmation path.
302
+ actionUrl?: string;
303
+ error?: string; // decline / error message when status === 'failed'
304
+ }
305
+
280
306
  // REST list endpoints return paginated wrappers. `BackendService.list<T>()`
281
307
  // unwraps to the array; use `listPaginated<T>()` to get the metadata.
282
308
  export interface PaginatedList<T> {
@@ -288,12 +314,18 @@ export interface PaginatedList<T> {
288
314
 
289
315
  export interface Subscription {
290
316
  planId: string;
291
- status: string; // 'A' active, 'P' pending-payment, 'C' cancelled, ...
317
+ status: string; // 'A' active, 'P' pending-payment, 'C' cancelled, 'T' trialing, 'X' past-due — caption via SUBSCRIPTION_STATUS dict
292
318
  begda: string;
293
319
  endda?: string;
294
320
  monthlyCost: number;
295
321
  currency: string;
296
322
  autoRenew: boolean;
323
+ // ISO timestamp the trial ends (keel `trial_end`); empty/absent when not
324
+ // trialing. Drives `<sail-trial-banner>`.
325
+ trialEnd?: string;
326
+ // Seat quantity on the active/trial sub (keel `seats`); absent for
327
+ // non-seat plans. Drives `<sail-seat-selector>`.
328
+ seats?: number;
297
329
  }
298
330
 
299
331
  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));
@@ -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',
@@ -0,0 +1,29 @@
1
+ import { InjectionToken } from '@angular/core';
2
+
3
+ /** Outcome of a client-side SCA / 3DS confirmation attempt. */
4
+ export type ScaOutcome = 'succeeded' | 'failed' | 'cancelled';
5
+
6
+ export interface ScaResult {
7
+ outcome: ScaOutcome;
8
+ error?: string;
9
+ }
10
+
11
+ /**
12
+ * Provider-agnostic port for confirming a Strong Customer Authentication (3DS)
13
+ * challenge on a `client_secret` returned by an off-session charge.
14
+ *
15
+ * sail ships NO provider SDK and NO default implementation — each app provides
16
+ * one for its payment provider (e.g. a thin Stripe.js wrapper calling
17
+ * `stripe.handleNextAction({ clientSecret })`) and registers it at composition
18
+ * time, keeping the provider choice out of the shared library:
19
+ *
20
+ * { provide: SCA_CONFIRMER, useClass: StripeScaConfirmer }
21
+ *
22
+ * The `actionUrl` (provider-hosted redirect) branch needs no confirmer — only
23
+ * the inline `clientSecret` branch delegates here.
24
+ */
25
+ export interface ScaConfirmer {
26
+ confirm(clientSecret: string): Promise<ScaResult>;
27
+ }
28
+
29
+ export const SCA_CONFIRMER = new InjectionToken<ScaConfirmer>('SailScaConfirmer');