@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 +83 -4
- package/package.json +1 -1
- package/src/component/billing/plan_selector.html +5 -4
- package/src/component/billing/plan_selector.ts +8 -0
- package/src/component/billing/price_selector.html +17 -0
- package/src/component/billing/price_selector.ts +66 -0
- package/src/component/login/register_component.html +1 -1
- package/src/component/login/register_component.ts +6 -0
- package/src/component/payment/sca_confirm.html +14 -0
- package/src/component/payment/sca_confirm.ts +77 -0
- package/src/index.ts +4 -0
- package/src/model/appdata.ts +50 -6
- package/src/service/sca_confirmer.ts +29 -0
- package/src/util/money.ts +29 -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 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
|
-
**
|
|
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]="
|
|
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]="
|
|
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.
|
|
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
|
-
|
|
6
|
-
<
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|
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';
|
package/src/model/appdata.ts
CHANGED
|
@@ -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
|
-
|
|
210
|
-
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
// `<sail-checkout-button
|
|
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
|
+
}
|