@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 +120 -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/component/payment/sca_confirm.html +14 -0
- package/src/component/payment/sca_confirm.ts +77 -0
- package/src/index.ts +6 -0
- package/src/model/appdata.ts +33 -1
- package/src/service/billing.service.ts +14 -0
- package/src/service/rest_url.ts +2 -0
- package/src/service/sca_confirmer.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 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-
|
|
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>
|
|
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.
|
|
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,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
|
+
}
|
|
@@ -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';
|
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.
|
|
@@ -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));
|
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',
|
|
@@ -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');
|