@nauticana/sail 1.0.1 → 1.0.3

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 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).
318
+ sail ships a shared `BillingService` and ten 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
 
@@ -356,10 +356,24 @@ export class PricingPage {
356
356
  </sail-plan-selector>
357
357
  ```
358
358
 
359
- **Checkout button** — calls `createCheckout()` and redirects to the provider-hosted URL. `PublicPlan.priceId` lets you wire the picker directly to checkout without a local mapping table:
359
+ **Offer / price picker (v1.0.5)** — a plan no longer has a single price. keel v1.0.5 sells per-plan **offers** in the `subscription_plan_price` table (billing cycle × commitment term, each with its own `provider_price_id`), surfaced as `PublicPlan.prices: PlanPrice[]`. The customer picks an offer **at checkout**; its `priceId` is what flows to `<sail-checkout-button>`:
360
+
361
+ ```html
362
+ <sail-price-selector
363
+ [prices]="selectedPlan().prices ?? []"
364
+ [selected]="offer()?.priceId"
365
+ (selectionChange)="offer.set($event)">
366
+ </sail-price-selector>
367
+ ```
368
+
369
+ `<sail-price-selector>` is presentational (a `radiogroup` of offers), formats each amount with `Intl` from the offer's currency (so JPY/BHD render with the right number of decimals), and maps PERIOD_TYPE codes to copy via `[cycleLabels]`. It emits the full `PlanPrice` so you keep the chosen terms, not just the id.
370
+
371
+ > **Migration.** The flat `PublicPlan.priceId` is deprecated (keel v1.0.5 dropped `subscription_plan.provider_price_id`) — read the id from the selected offer (`offer().priceId`). **keel v1.0.7 also removed `PublicPlan.monthlyCost`/`annualCost`** from both `/public/plans` and the billing catalog, so any plan-card code reading them now gets `undefined`. Derive a display price from the offers with `fromPriceLabel(plan.prices)` (used by `<sail-plan-selector>`), or render the per-offer amounts via `<sail-price-selector>`. These fields are removed from sail's `PublicPlan` type so the compiler flags any remaining references.
372
+
373
+ **Checkout button** — calls `createCheckout()` and redirects to the provider-hosted URL. Feed it the chosen offer's `priceId`:
360
374
  ```html
361
375
  <sail-checkout-button
362
- [priceId]="selectedPlan().priceId!"
376
+ [priceId]="offer()!.priceId!"
363
377
  [mode]="'subscription'"
364
378
  [successUrl]="'https://app.example.com/billing/done'"
365
379
  [cancelUrl]="'https://app.example.com/billing'"
@@ -521,14 +535,79 @@ A complete screen is the pieces above assembled over `BillingService` — no met
521
535
  <sail-trial-banner [trialEnd]="sub()?.trialEnd"></sail-trial-banner>
522
536
  <sail-status-chip [value]="sub()?.status"></sail-status-chip>
523
537
  <sail-plan-selector [plans]="plans()" [selected]="sub()?.planId" (selectionChange)="onPlanPicked($event)"></sail-plan-selector>
538
+ <sail-price-selector [prices]="selectedPlan()?.prices ?? []" [selected]="offer()?.priceId" (selectionChange)="offer.set($event)"></sail-price-selector>
524
539
  <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>
540
+ <sail-checkout-button [priceId]="offer()!.priceId!" [quantity]="seats()" [successUrl]="okUrl" [cancelUrl]="backUrl"></sail-checkout-button>
526
541
  <sail-usage-meter [meters]="usage()"></sail-usage-meter>
527
542
  <sail-payment-methods></sail-payment-methods>
528
543
  <sail-portal-button></sail-portal-button>
529
544
  <!-- invoices: render listInvoices() rows directly -->
530
545
  ```
531
546
 
547
+ ## Off-session SCA / 3DS confirmation (v1.0.x)
548
+
549
+ 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.
550
+
551
+ 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:
552
+
553
+ - redirects to `ChargeResult.actionUrl` when present (provider-hosted challenge — no SDK needed); else
554
+ - delegates the inline `ChargeResult.clientSecret` to the app-registered `SCA_CONFIRMER`.
555
+
556
+ ### Backend contract (app-owned)
557
+
558
+ 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`:
559
+
560
+ ```jsonc
561
+ // POST /api/<app>/charge → 200
562
+ { "status": "requires_action", "clientSecret": "pi_..._secret_...", "actionUrl": "" }
563
+ // or { "status": "succeeded", "providerChargeId": "pi_..." }
564
+ // or { "status": "failed", "error": "card_declined" }
565
+ ```
566
+
567
+ > `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.
568
+
569
+ ### Frontend adoption (downstream projects)
570
+
571
+ **1. Implement the provider confirmer** (Stripe shown; lives in the app, behind an *optional* `@stripe/stripe-js` peer dep — non-Stripe apps skip it):
572
+
573
+ ```typescript
574
+ import { Injectable } from '@angular/core';
575
+ import { loadStripe } from '@stripe/stripe-js';
576
+ import { ScaConfirmer, ScaResult } from '@nauticana/sail';
577
+
578
+ @Injectable({ providedIn: 'root' })
579
+ export class StripeScaConfirmer implements ScaConfirmer {
580
+ async confirm(clientSecret: string): Promise<ScaResult> {
581
+ const stripe = await loadStripe(/* publishable key from backend cache */);
582
+ if (!stripe) return { outcome: 'failed', error: 'Stripe.js failed to load' };
583
+ const { error } = await stripe.handleNextAction({ clientSecret });
584
+ return error ? { outcome: 'failed', error: error.message } : { outcome: 'succeeded' };
585
+ }
586
+ }
587
+ ```
588
+
589
+ **2. Register it at composition time** (`app.config.ts` / `main.ts`):
590
+
591
+ ```typescript
592
+ import { SCA_CONFIRMER } from '@nauticana/sail';
593
+
594
+ providers: [
595
+ { provide: SCA_CONFIRMER, useExisting: StripeScaConfirmer },
596
+ ]
597
+ ```
598
+
599
+ **3. Drop the component into the post-charge flow** — bind the charge response and react to the outcome:
600
+
601
+ ```html
602
+ <sail-sca-confirm
603
+ [result]="charge()"
604
+ (confirmed)="onPaid()"
605
+ (failed)="onScaFailed($event)">
606
+ </sail-sca-confirm>
607
+ ```
608
+
609
+ `<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.
610
+
532
611
  ## Consent capture
533
612
 
534
613
  `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.1",
3
+ "version": "1.0.3",
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": [
@@ -2,10 +2,11 @@
2
2
  @for (plan of plans(); track plan.id) {
3
3
  <div class="plan-card" [class.plan-selected]="plan.id === selected()">
4
4
  <h3 class="plan-caption">{{ plan.caption }}</h3>
5
- <div class="plan-price">
6
- <span class="plan-amount">{{ plan.monthlyCost }}</span>
7
- <span class="plan-period">/ month</span>
8
- </div>
5
+ @if (priceLabels()[plan.id]; as priceLabel) {
6
+ <div class="plan-price">
7
+ <span class="plan-amount">{{ priceLabel }}</span>
8
+ </div>
9
+ }
9
10
  @if (featuresFor(plan.id).length > 0) {
10
11
  <ul class="plan-features">
11
12
  @for (feature of featuresFor(plan.id); track feature) {
@@ -1,6 +1,7 @@
1
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
+ import { fromPriceLabel } from '../../util/money';
4
5
 
5
6
  /**
6
7
  * Plan picker. Pure presentational component — stateless, no service calls.
@@ -42,6 +43,13 @@ export class PlanSelectorComponent {
42
43
  return out;
43
44
  });
44
45
 
46
+ /** planId → indicative "from" price derived from the plan's offers ('' = none). */
47
+ readonly priceLabels = computed<Record<string, string>>(() => {
48
+ const out: Record<string, string> = {};
49
+ for (const plan of this.plans()) out[plan.id] = fromPriceLabel(plan.prices);
50
+ return out;
51
+ });
52
+
45
53
  onSelect(planId: string): void {
46
54
  this.selectionChange.emit(planId);
47
55
  }
@@ -0,0 +1,17 @@
1
+ <div class="price-selector" role="radiogroup" aria-label="Billing options">
2
+ @for (offer of offers(); track offer.key) {
3
+ <button class="price-offer" type="button"
4
+ role="radio"
5
+ [class.price-selected]="offer.key === selected()"
6
+ [attr.aria-checked]="offer.key === selected()"
7
+ (click)="onSelect(offer)">
8
+ <span class="price-amount">{{ offer.amountText }}</span>
9
+ <span class="price-cycle">/ {{ offer.cycleText }}</span>
10
+ @if (offer.termText) {
11
+ <span class="price-term">{{ offer.termText }}</span>
12
+ }
13
+ </button>
14
+ } @empty {
15
+ <p class="empty-state">No billing options available.</p>
16
+ }
17
+ </div>
@@ -0,0 +1,66 @@
1
+ import { ChangeDetectionStrategy, Component, ViewEncapsulation, computed, input, output } from '@angular/core';
2
+ import { PlanPrice } from '../../model/appdata';
3
+ import { formatCurrency } from '../../util/money';
4
+
5
+ /** Per-offer view model for accessible, currency-formatted rendering. */
6
+ interface OfferRow {
7
+ key: string; // stable id for tracking / selection (priceId or composite)
8
+ price: PlanPrice;
9
+ amountText: string; // currency-formatted major amount
10
+ cycleText: string; // "Monthly" / "Annual" / …
11
+ termText: string; // commitment note, '' when the term is a single cycle
12
+ }
13
+
14
+ /**
15
+ * Offer picker for a plan's `subscription_plan_price` rows (keel v1.0.5). A plan
16
+ * sells several offers (billing cycle × commitment term); the customer picks one
17
+ * at checkout, then its `priceId` feeds `<sail-checkout-button [priceId]>`.
18
+ *
19
+ * Pure presentational — stateless. Amounts are formatted with `Intl` from each
20
+ * offer's currency, so non-2-decimal currencies (JPY 0, BHD 3) render correctly.
21
+ * Period codes map to copy via `[cycleLabels]` (override/extend the defaults).
22
+ */
23
+ @Component({
24
+ selector: 'sail-price-selector',
25
+ templateUrl: './price_selector.html',
26
+ changeDetection: ChangeDetectionStrategy.OnPush,
27
+ encapsulation: ViewEncapsulation.None,
28
+ })
29
+ export class PriceSelectorComponent {
30
+ private static readonly DEFAULT_CYCLE: Record<string, string> = {
31
+ D: 'Daily',
32
+ W: 'Weekly',
33
+ M: 'Monthly',
34
+ Q: 'Quarterly',
35
+ A: 'Annual',
36
+ };
37
+
38
+ readonly prices = input<PlanPrice[]>([]);
39
+ /** Selected offer key — a `priceId`, or the composite key when priceId is empty. */
40
+ readonly selected = input<string | undefined>(undefined);
41
+ /** PERIOD_TYPE code → label; overrides/extends the built-in defaults. */
42
+ readonly cycleLabels = input<{[periodCode: string]: string}>({});
43
+ readonly selectionChange = output<PlanPrice>();
44
+
45
+ readonly offers = computed<OfferRow[]>(() => {
46
+ const labels = this.cycleLabels();
47
+ const cycleLabel = (code: string) =>
48
+ labels[code] ?? PriceSelectorComponent.DEFAULT_CYCLE[code] ?? code;
49
+ return this.prices().map((p) => {
50
+ const singleCycle = p.termCount === 1 && p.termType === p.billingCycle;
51
+ return {
52
+ key: p.priceId || `${p.billingCycle}-${p.termType}-${p.termCount}`,
53
+ price: p,
54
+ amountText: formatCurrency(p.amount, p.currency),
55
+ cycleText: cycleLabel(p.billingCycle),
56
+ termText: singleCycle
57
+ ? ''
58
+ : `${p.termCount} ${cycleLabel(p.termType)}${p.termCount > 1 ? 's' : ''} commitment`,
59
+ };
60
+ });
61
+ });
62
+
63
+ onSelect(offer: OfferRow): void {
64
+ this.selectionChange.emit(offer.price);
65
+ }
66
+ }
@@ -46,7 +46,7 @@
46
46
  <mat-select formControlName="PlanID" required>
47
47
  @for (p of plans(); track p.id) {
48
48
  <mat-option [value]="p.id">
49
- {{ p.caption }}@if (p.monthlyCost > 0) { — \${{ p.monthlyCost }}/mo }
49
+ {{ p.caption }}@if (priceHint(p); as hint) { — {{ hint }} }
50
50
  </mat-option>
51
51
  }
52
52
  </mat-select>
@@ -13,6 +13,7 @@ import { firstValueFrom, map, merge } from 'rxjs';
13
13
  import { HttpClient } from '@angular/common/http';
14
14
  import { PartnerRegistration, PublicPlan } from '../../model/appdata';
15
15
  import { SAIL_GUI_CONFIG, SailGuiConfig, DEFAULT_CONFIG } from '../../config';
16
+ import { fromPriceLabel } from '../../util/money';
16
17
 
17
18
  @Component({
18
19
  selector: 'sail-register',
@@ -76,6 +77,11 @@ export class RegisterComponent implements OnInit {
76
77
  return this.guiConfig.googleMapsApiKey ?? '';
77
78
  }
78
79
 
80
+ /** Indicative "from" price for a plan option, derived from its offers ('' = free/none). */
81
+ protected priceHint(plan: PublicPlan): string {
82
+ return fromPriceLabel(plan.prices);
83
+ }
84
+
79
85
  ngOnInit() {
80
86
  this.billing.listPlans().subscribe({
81
87
  next: (list) => this.plans.set(list ?? []),
@@ -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
@@ -30,6 +30,7 @@ export * from './component/security/twofactor_setup';
30
30
  export * from './component/security/twofactor_verify';
31
31
  export * from './component/security/trusted_devices';
32
32
  export * from './component/billing/plan_selector';
33
+ export * from './component/billing/price_selector';
33
34
  export * from './component/billing/checkout_button';
34
35
  export * from './component/billing/payment_methods';
35
36
  export * from './component/billing/portal_button';
@@ -41,7 +42,9 @@ export * from './component/billing/dunning_banner';
41
42
  export * from './component/payout/provider_onboarding';
42
43
  export * from './component/payout/bank_info_form';
43
44
  export * from './component/payment/user_payment_methods';
45
+ export * from './component/payment/sca_confirm';
44
46
  export * from './service/auth.service';
47
+ export * from './service/sca_confirmer';
45
48
  export * from './service/billing.service';
46
49
  export * from './service/payout.service';
47
50
  export * from './service/user_payment_method.service';
@@ -53,3 +56,4 @@ export * from './service/rest_url';
53
56
  export * from './service/label.service';
54
57
  export * from './service/base_rest.service';
55
58
  export * from './util/text';
59
+ export * from './util/money';
@@ -201,17 +201,39 @@ export interface PartnerRegistration {
201
201
  PlanID: string;
202
202
  }
203
203
 
204
+ // One purchasable offer for a plan — a (billing cycle × commitment term) row of
205
+ // keel's `subscription_plan_price` table (v1.0.5). A plan sells several; the
206
+ // customer picks one at checkout via `<sail-price-selector>`. Mirrors keel
207
+ // `billing.PlanPrice`.
208
+ export interface PlanPrice {
209
+ billingCycle: string; // PERIOD_TYPE code (D/W/M/A) — how often charged
210
+ termCount: number; // commitment length, in termType units
211
+ termType: string; // PERIOD_TYPE code — commitment/pricing unit
212
+ amountMinor: number; // price per termType unit, in minor units (cents)
213
+ amount: number; // same amount in major units (currency-exponent applied)
214
+ currency: string; // ISO 4217
215
+ // Provider price id (Stripe Price). Empty for free/unpriced offers; pass to
216
+ // `<sail-checkout-button [priceId]>` / `createCheckout({ priceId })`.
217
+ priceId?: string;
218
+ }
219
+
204
220
  // PublicPlan — shape returned by GET /public/plans. Used on the registration
205
221
  // page to render the plan picker and to drive `<sail-checkout-button>` flows.
206
222
  export interface PublicPlan {
207
223
  id: string;
208
224
  caption: string;
209
- monthlyCost: number;
210
- annualCost: number;
211
- // Stripe price ID (or provider-equivalent). Optional because keel only
212
- // populates it when a plan is wired to a billing provider; FREE plans and
213
- // not-yet-priced tiers leave it empty. Pass straight to
214
- // `<sail-checkout-button [priceId]>` / `BillingService.createCheckout({ priceId })`.
225
+ // NOTE: keel v1.0.7 REMOVED the flat `monthlyCost`/`annualCost` from the plan
226
+ // JSON (both `/public/plans` and the billing catalog). Price lives per-offer
227
+ // in `prices[]`; derive a display price with `fromPriceLabel(plan.prices)`.
228
+ // Purchasable offers (keel v1.0.5 `subscription_plan_price`, nested under the
229
+ // plan). The source of truth for checkout — feed a chosen offer's `priceId`
230
+ // to `<sail-checkout-button>`. Returned by `/public/plans` as of keel v1.0.6
231
+ // (so `<sail-price-selector>` works on the registration page too); absent on
232
+ // pre-v1.0.6 backends.
233
+ prices?: PlanPrice[];
234
+ // @deprecated keel v1.0.5 moved per-offer prices into `prices[]`
235
+ // (`subscription_plan.provider_price_id` was dropped). Use
236
+ // `prices?.[…].priceId`. Kept optional for pre-v1.0.5 backends.
215
237
  priceId?: string;
216
238
  // keel `subscription_plan.activation_mode` (SUBSCRIPTION_ACTIVATION_MODE
217
239
  // dictionary, single char — e.g. 'P' paid, 'F' free, 'T' trial). Drives the
@@ -284,6 +306,25 @@ export interface UsageMeter {
284
306
  limit: number;
285
307
  }
286
308
 
309
+ // Normalized outcome of an off-session charge — the camelCase JSON shape an app
310
+ // returns from its own charge endpoint, mirroring keel `payment.ChargeResult`.
311
+ // 'requires_action' means the rider must complete an SCA / 3DS confirmation
312
+ // (drive it with `<sail-sca-confirm>`).
313
+ export type ChargeStatus = 'succeeded' | 'requires_action' | 'failed';
314
+
315
+ export interface ChargeResult {
316
+ status: ChargeStatus;
317
+ providerChargeId?: string; // PaymentIntent / charge id (pi_xxx)
318
+ // Provider client secret — present when status === 'requires_action' and the
319
+ // SCA challenge must be confirmed inline (provider SDK). Mirrors keel
320
+ // ChargeResult.ClientSecret.
321
+ clientSecret?: string;
322
+ // Provider-hosted redirect URL when one exists (often empty for off-session
323
+ // 3DS). When present it's the simplest, provider-agnostic confirmation path.
324
+ actionUrl?: string;
325
+ error?: string; // decline / error message when status === 'failed'
326
+ }
327
+
287
328
  // REST list endpoints return paginated wrappers. `BackendService.list<T>()`
288
329
  // unwraps to the array; use `listPaginated<T>()` to get the metadata.
289
330
  export interface PaginatedList<T> {
@@ -301,6 +342,9 @@ export interface Subscription {
301
342
  monthlyCost: number;
302
343
  currency: string;
303
344
  autoRenew: boolean;
345
+ // PERIOD_TYPE code (D/W/M/A) the sub is charged on, copied from the chosen
346
+ // offer (keel `billing_cycle`); absent on pre-v1.0.5 backends.
347
+ billingCycle?: string;
304
348
  // ISO timestamp the trial ends (keel `trial_end`); empty/absent when not
305
349
  // trialing. Drives `<sail-trial-banner>`.
306
350
  trialEnd?: string;
@@ -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');
@@ -0,0 +1,29 @@
1
+ import { PlanPrice } from '../model/appdata';
2
+
3
+ /**
4
+ * Format a major-unit amount in its currency, honouring the currency exponent
5
+ * (JPY 0 decimals, BHD 3, …) via `Intl`. Falls back to "<amount> <currency>"
6
+ * for an unknown currency code rather than throwing.
7
+ */
8
+ export function formatCurrency(amount: number, currency: string): string {
9
+ try {
10
+ return new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(amount);
11
+ } catch {
12
+ return `${amount} ${currency}`;
13
+ }
14
+ }
15
+
16
+ /**
17
+ * "From" price teaser for a plan card / dropdown — the lowest priced offer,
18
+ * currency-formatted, prefixed "from " when the plan sells more than one priced
19
+ * offer. Returns '' when the plan has no priced offers (free/trial), so callers
20
+ * can omit the price line entirely. The exact offer is chosen later via
21
+ * `<sail-price-selector>`.
22
+ */
23
+ export function fromPriceLabel(prices: PlanPrice[] | undefined): string {
24
+ const priced = (prices ?? []).filter((p) => p.amount > 0);
25
+ if (priced.length === 0) return '';
26
+ const lowest = priced.reduce((a, b) => (b.amount < a.amount ? b : a));
27
+ const formatted = formatCurrency(lowest.amount, lowest.currency);
28
+ return priced.length > 1 ? `from ${formatted}` : formatted;
29
+ }