@nauticana/sail 1.1.12 → 1.1.13

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
@@ -273,7 +273,8 @@ interface SailGuiConfig {
273
273
  googleClientId?: string; // Google Identity Services client ID
274
274
  appleServiceId?: string; // Apple Services ID
275
275
  appleRedirectUri?: string; // Apple Sign-In redirect URI
276
- privacyPolicyUrl?: string; // Linked from ConsentGateComponent
276
+ privacyPolicyUrl?: string; // Linked from ConsentGateComponent and the login footer
277
+ termsUrl?: string; // Linked from the SMS-consent disclosure and the login footer
277
278
  defaultPolicyVersion?: string; // Content hash of the deployed policy
278
279
  defaultPolicyLanguage?: string; // ISO 639-1 fallback language
279
280
  accountDeletedRoute?: string; // Route after account deletion (default '/login/local')
@@ -1138,8 +1139,8 @@ The v0.6 / v0.7 line introduced three additive feature groups against keel v0.7.
1138
1139
 
1139
1140
  | From | Export | Purpose |
1140
1141
  |---|---|---|
1141
- | `@nauticana/sail` | `PayoutService` | keel/payout API client — hosted-KYC launch, reuse flow, status |
1142
- | `@nauticana/sail` | `PayoutProviderOnboardingComponent` (`<sail-payout-provider-onboarding>`) | Drop-in onboarding step with reuse picker + hosted-KYC launcher |
1142
+ | `@nauticana/sail` | `PayoutService` | keel/payout API client — onboarding, reuse, status, bank-version replacement, beneficiary registration |
1143
+ | `@nauticana/sail` | `PayoutProviderOnboardingComponent` (`<sail-payout-provider-onboarding>`) | Drop-in onboarding step with reuse picker, hosted-KYC handoff, and asynchronous-confirmation output |
1143
1144
  | `@nauticana/sail` | `PayoutBankInfoFormComponent` (`<sail-payout-bank-info-form>`) | Tax + payout details form (country / currency / tax ID / billing address / agreement) |
1144
1145
  | `@nauticana/sail` | `UserPaymentMethodService` | Saved-card list / delete / set-default API client |
1145
1146
  | `@nauticana/sail` | `UserPaymentMethodsComponent` (`<sail-user-payment-methods>`) | List of saved cards/wallets with set-default + delete |
@@ -1151,10 +1152,12 @@ Make sure your keel deployment exposes these — they're all under `/api/v1/`:
1151
1152
 
1152
1153
  | Endpoint | Method | Purpose |
1153
1154
  |---|---|---|
1154
- | `/api/v1/payout/onboard/start` | POST | Open hosted-KYC; returns `{ url, externalAccountId, expiresAt }` |
1155
+ | `/api/v1/payout/onboard/start` | POST | Start provider onboarding; returns `{ url, externalAccountId, expiresAt }` (`url` is empty when confirmation continues asynchronously) |
1155
1156
  | `/api/v1/payout/reusable` | POST | List provider accounts the user has on other partners |
1156
1157
  | `/api/v1/payout/reusable/link` | POST | Copy a `providerAccountId` onto the active partner's `user_bank_info` row |
1157
1158
  | `/api/v1/payout/status` | POST | `{ complete: true }` once the active partner's row has a `providerAccountId` |
1159
+ | `/api/v1/payout/bank/replace` | POST | Supersede the active bank-info version and insert its replacement atomically; the app must wire the handler's `SealTaxID` hook |
1160
+ | `/api/v1/payout/beneficiary/register` | POST | Create and link a provider beneficiary from provider-collected details |
1158
1161
  | `/api/v1/payment-methods/set-default` | POST | Atomic multi-row UPDATE — sets one row as default, clears the rest |
1159
1162
  | `/api/v1/{table}/{action_name}` | POST | Per-table custom actions resolved from `basis.table_action` |
1160
1163
 
@@ -1176,6 +1179,7 @@ Drop the two components into a wizard step. Routing between them stays in the co
1176
1179
  <sail-payout-provider-onboarding
1177
1180
  (linked)="step.set('done')"
1178
1181
  (started)="step.set('waiting')"
1182
+ (pending)="step.set('awaiting-confirmation')"
1179
1183
  (skipped)="step.set('done')"
1180
1184
  (back)="step.set('bank')">
1181
1185
  </sail-payout-provider-onboarding>
@@ -1183,7 +1187,9 @@ Drop the two components into a wizard step. Routing between them stays in the co
1183
1187
  }
1184
1188
  ```
1185
1189
 
1186
- `BankInfoFormValue` maps 1:1 to `basis.user_bank_info` columns. The consumer decides where to POST it — typically a direct generic-CRUD insert against `/api/v1/user_bank_info`. The provider account itself is created by `<sail-payout-provider-onboarding>` via `PayoutService.startOnboarding()` → hosted KYC → webhook back to keel.
1190
+ `BankInfoFormValue` is application-layer input, not a generic-CRUD row. In particular, its plaintext `taxId` must be sealed into the `tax_id_encrypted` column and must never be sent through the generic `/api/v1/user_bank_info` CRUD surface. First-time setup goes through the consuming app's registration service. Changes to an existing destination use `PayoutService.replaceBankInfo()`, which calls keel's atomic version-replacement endpoint; wire `PayoutHandler.SealTaxID` so that endpoint seals the plaintext before persistence.
1191
+
1192
+ `PayoutService.startOnboarding()` returns a `PayoutOnboardingSession`. When `url` is non-empty, the component emits `started` and navigates to the provider's hosted flow. When `url` is empty, as with Wise email recipients, it emits `pending` instead; consumers must show an awaiting-confirmation state while the recipient completes the provider's email flow. Airwallex apps can pass details collected by the embedded beneficiary component to `PayoutService.registerBeneficiary()`.
1187
1193
 
1188
1194
  For apps that operate on a single keel partner, the reuse picker stays empty and only the "Start onboarding" CTA renders. Multi-partner apps get the picker for free.
1189
1195
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nauticana/sail",
3
- "version": "1.1.12",
3
+ "version": "1.1.13",
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
  "scripts": {
@@ -38,11 +38,17 @@
38
38
  <div class="auth-actions">
39
39
  <a routerLink="/login/chpass">I forgot my password</a>
40
40
  </div>
41
- @if (guiConfig.loginFooterLinks && guiConfig.loginFooterLinks.length > 0) {
41
+ @if (guiConfig.loginFooterLinks?.length || guiConfig.termsUrl || guiConfig.privacyPolicyUrl) {
42
42
  <div class="auth-footer">
43
- @for (link of guiConfig.loginFooterLinks; track link.routerLink) {
43
+ @for (link of guiConfig.loginFooterLinks ?? []; track link.routerLink) {
44
44
  <a [routerLink]="link.routerLink">{{ link.label }}</a>
45
45
  }
46
+ @if (guiConfig.termsUrl) {
47
+ <a [href]="guiConfig.termsUrl" target="_blank" rel="noopener">Terms of Service</a>
48
+ }
49
+ @if (guiConfig.privacyPolicyUrl) {
50
+ <a [href]="guiConfig.privacyPolicyUrl" target="_blank" rel="noopener">Privacy Policy</a>
51
+ }
46
52
  </div>
47
53
  }
48
54
  </div>
@@ -26,8 +26,10 @@ export const DEFAULT_COUNTRY_PROFILES: CountryProfile[] = [
26
26
  *
27
27
  * Captures the basis `user_bank_info` fields (country, currency, account
28
28
  * holder, tax id, billing address, agreement). Emits the form value via
29
- * `(submitted)` — the consumer decides whether to POST directly to
30
- * /api/v1/user_bank_info or pipe through its own registration service.
29
+ * `(submitted)` — first-time setup POSTs through the app's registration
30
+ * service; CHANGES to an existing destination go through
31
+ * PayoutService.replaceBankInfo (atomic version replacement), never a
32
+ * generic CRUD edit.
31
33
  *
32
34
  * Ships no CSS — the consuming app styles the classes globally.
33
35
  *
@@ -58,7 +60,11 @@ export class PayoutBankInfoFormComponent {
58
60
  /** When true, the back button renders alongside submit. */
59
61
  readonly showBack = input(true);
60
62
 
61
- /** Emitted on submit; payload maps 1:1 to basis user_bank_info columns. */
63
+ /**
64
+ * Emitted on submit as application-layer input. The consumer must use its
65
+ * registration service for first-time setup or PayoutService.replaceBankInfo
66
+ * for changes; never send this payload through generic CRUD.
67
+ */
62
68
  readonly submitted = output<BankInfoFormValue>();
63
69
  /** Emitted when the user clicks Back. */
64
70
  readonly back = output<void>();
@@ -14,12 +14,13 @@ import { ReusableAccount } from '../../model/appdata';
14
14
  * two paths:
15
15
  * 1. Reuse an existing provider account from another partner row
16
16
  * (zero-KYC; calls /api/v1/payout/reusable/link).
17
- * 2. Hand off to the provider's hosted-KYC page
18
- * (/api/v1/payout/onboard/start).
17
+ * 2. Start provider onboarding (/api/v1/payout/onboard/start), then
18
+ * either hand off to a hosted-KYC page or emit `pending` when the
19
+ * provider continues asynchronously without a hosted URL.
19
20
  *
20
- * Routing is consumer-owned via the `(linked)` / `(skipped)` outputs —
21
- * the component itself never calls Router. Optional `title` and
22
- * `skipLabel` inputs cover the common UX strings.
21
+ * Routing is consumer-owned via the `(linked)`, `(pending)`, and
22
+ * `(skipped)` outputs — the component itself never calls Router.
23
+ * Optional `title` and `skipLabel` inputs cover the common UX strings.
23
24
  *
24
25
  * Ships no CSS — the consuming app styles the classes globally.
25
26
  *
@@ -47,6 +48,13 @@ export class PayoutProviderOnboardingComponent extends BaseAsync implements OnIn
47
48
  readonly back = output<void>();
48
49
  /** Emitted just before handing off to the hosted-KYC page. */
49
50
  readonly started = output<void>();
51
+ /**
52
+ * Emitted when onboarding started but there is no hosted page to open
53
+ * (Wise email recipients: the account is linked and awaits the
54
+ * recipient's email confirmation). Consumers show a "linked, awaiting
55
+ * confirmation" state instead of navigating.
56
+ */
57
+ readonly pending = output<void>();
50
58
 
51
59
  private readonly payoutService = inject(PayoutService);
52
60
 
@@ -79,6 +87,10 @@ export class PayoutProviderOnboardingComponent extends BaseAsync implements OnIn
79
87
  this.run(
80
88
  this.payoutService.startOnboarding(),
81
89
  (res) => {
90
+ if (!res.url) {
91
+ this.pending.emit();
92
+ return;
93
+ }
82
94
  this.started.emit();
83
95
  // Same-tab handoff: window.open from an async callback is popup-blocked
84
96
  // (always on Safari), which used to leave users on a "waiting" screen
package/src/config.ts CHANGED
@@ -53,9 +53,9 @@ export interface SailGuiConfig {
53
53
  appleServiceId?: string;
54
54
  /** Apple Sign-In redirect URI. Must match the Services ID configuration. */
55
55
  appleRedirectUri?: string;
56
- /** Public URL of your privacy policy. Shown as a link in ConsentGateComponent. */
56
+ /** Public URL of your privacy policy. Linked from ConsentGateComponent and the login footer. */
57
57
  privacyPolicyUrl?: string;
58
- /** Public URL of your terms of service. Shown in the SMS-consent disclosure. */
58
+ /** Public URL of your terms of service. Linked from the SMS-consent disclosure and the login footer. */
59
59
  termsUrl?: string;
60
60
  /** Brand name shown in the SMS/10DLC consent disclosure (e.g. "Daxoom"). */
61
61
  appName?: string;
@@ -377,18 +377,22 @@ export interface ReusableAccount {
377
377
  onboardedAt: string;
378
378
  }
379
379
 
380
- // Result of PayoutService.startOnboarding(). url is opened in a webview /
381
- // external browser; the provider posts back via webhook when activation
382
- // lands.
380
+ // Result of PayoutService.startOnboarding(). A non-empty url is opened in a
381
+ // webview / external browser. Some providers have no hosted flow (for example,
382
+ // Wise email recipients); in that case url and expiresAt are empty and
383
+ // PayoutProviderOnboardingComponent emits `pending` while confirmation
384
+ // continues asynchronously.
383
385
  export interface PayoutOnboardingSession {
384
386
  url: string;
385
387
  externalAccountId: string;
386
388
  expiresAt: string;
387
389
  }
388
390
 
389
- // Payload the bank-info form emits. Maps 1:1 to basis user_bank_info
390
- // columns. The consumer decides where to POST it — typically a direct
391
- // generic-CRUD insert against /api/v1/user_bank_info.
391
+ // Application-layer payload emitted by the bank-info form. It is not a
392
+ // generic-CRUD row: taxId must be sealed into basis.user_bank_info's
393
+ // tax_id_encrypted column. First-time setup goes through the consuming app's
394
+ // registration service; changes use PayoutService.replaceBankInfo so keel can
395
+ // atomically supersede the active destination version.
392
396
  export interface BankInfoFormValue {
393
397
  countryCode: string;
394
398
  currency: string;
@@ -3,6 +3,7 @@ import { Observable } from 'rxjs';
3
3
  import { RestURL } from './rest_url';
4
4
  import { BaseRestService } from './base_rest.service';
5
5
  import {
6
+ BankInfoFormValue,
6
7
  PayoutOnboardingSession,
7
8
  ReusableAccount,
8
9
  } from '../model/appdata';
@@ -20,7 +21,11 @@ import {
20
21
  */
21
22
  @Injectable({ providedIn: 'root' })
22
23
  export class PayoutService extends BaseRestService {
23
- /** Launch the hosted-KYC flow. Returns a URL to open + provider-side handle. */
24
+ /**
25
+ * Start provider onboarding. A non-empty URL is the hosted-KYC handoff;
26
+ * an empty URL means confirmation continues asynchronously (for example,
27
+ * a Wise recipient confirming from email).
28
+ */
24
29
  startOnboarding(): Observable<PayoutOnboardingSession> {
25
30
  return this.http.post<PayoutOnboardingSession>(this.url(RestURL.payoutOnboardStartURL), {});
26
31
  }
@@ -39,4 +44,25 @@ export class PayoutService extends BaseRestService {
39
44
  status(): Observable<{ complete: boolean }> {
40
45
  return this.http.post<{ complete: boolean }>(this.url(RestURL.payoutStatusURL), {});
41
46
  }
47
+
48
+ /**
49
+ * Replace the payout destination with a new bank-info version
50
+ * (atomic supersede + insert on the backend). Identity changes go
51
+ * through this, never through generic CRUD edits. `provider` and
52
+ * `providerAgreement` are server-owned and ignored here.
53
+ */
54
+ replaceBankInfo(value: BankInfoFormValue): Observable<{ message: string }> {
55
+ const { countryCode, currency, accountHolderName, billingAddress, taxIdType, taxId } = value;
56
+ return this.http.post<{ message: string }>(this.url(RestURL.payoutBankReplaceURL),
57
+ { countryCode, currency, accountHolderName, billingAddress, taxIdType, taxId });
58
+ }
59
+
60
+ /**
61
+ * Register a payout beneficiary from provider-collected details
62
+ * (Airwallex embedded beneficiary component). The payload passes
63
+ * through to the provider; the backend links the returned id.
64
+ */
65
+ registerBeneficiary(beneficiary: unknown): Observable<{ beneficiaryId: string }> {
66
+ return this.http.post<{ beneficiaryId: string }>(this.url(RestURL.payoutBeneficiaryURL), { beneficiary });
67
+ }
42
68
  }
@@ -53,6 +53,8 @@ export const RestURL = {
53
53
  payoutReusableURL: '/api/v1/payout/reusable',
54
54
  payoutReusableLinkURL: '/api/v1/payout/reusable/link',
55
55
  payoutStatusURL: '/api/v1/payout/status',
56
+ payoutBankReplaceURL: '/api/v1/payout/bank/replace',
57
+ payoutBeneficiaryURL: '/api/v1/payout/beneficiary/register',
56
58
  // User payment methods — list/delete go through keel's generic REST
57
59
  // CRUD against the UserSpecific basis table; set-default is a
58
60
  // TableAction (basis table_action row), mounted at the conventional