@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 +64 -0
- package/package.json +1 -1
- package/src/component/payment/sca_confirm.html +14 -0
- package/src/component/payment/sca_confirm.ts +77 -0
- package/src/index.ts +2 -0
- package/src/model/appdata.ts +19 -0
- package/src/service/sca_confirmer.ts +29 -0
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.
|
|
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';
|
package/src/model/appdata.ts
CHANGED
|
@@ -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');
|