@nauticana/sail 1.0.1 → 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
@@ -529,6 +529,70 @@ A complete screen is the pieces above assembled over `BillingService` — no met
529
529
  <!-- invoices: render listInvoices() rows directly -->
530
530
  ```
531
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
+
532
596
  ## Consent capture
533
597
 
534
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.1",
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,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
@@ -41,7 +41,9 @@ export * from './component/billing/dunning_banner';
41
41
  export * from './component/payout/provider_onboarding';
42
42
  export * from './component/payout/bank_info_form';
43
43
  export * from './component/payment/user_payment_methods';
44
+ export * from './component/payment/sca_confirm';
44
45
  export * from './service/auth.service';
46
+ export * from './service/sca_confirmer';
45
47
  export * from './service/billing.service';
46
48
  export * from './service/payout.service';
47
49
  export * from './service/user_payment_method.service';
@@ -284,6 +284,25 @@ export interface UsageMeter {
284
284
  limit: number;
285
285
  }
286
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
+
287
306
  // REST list endpoints return paginated wrappers. `BackendService.list<T>()`
288
307
  // unwraps to the array; use `listPaginated<T>()` to get the metadata.
289
308
  export interface PaginatedList<T> {
@@ -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');