@nauticana/sail 0.5.7 → 0.6.1
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/package.json +2 -2
- package/src/component/payment/user_payment_methods.ts +159 -0
- package/src/component/payout/bank_info_form.ts +176 -0
- package/src/component/payout/provider_onboarding.ts +182 -0
- package/src/index.ts +5 -0
- package/src/model/appdata.ts +78 -0
- package/src/service/payout.service.ts +48 -0
- package/src/service/rest_url.ts +9 -0
- package/src/service/user_payment_method.service.ts +48 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nauticana/sail",
|
|
3
|
-
"version": "0.
|
|
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.",
|
|
3
|
+
"version": "0.6.1",
|
|
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": [
|
|
7
7
|
"src/",
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnInit, Output, ViewEncapsulation, inject, signal } from '@angular/core';
|
|
2
|
+
import { CommonModule } from '@angular/common';
|
|
3
|
+
import { MatButtonModule } from '@angular/material/button';
|
|
4
|
+
import { MatCardModule } from '@angular/material/card';
|
|
5
|
+
import { MatIconModule } from '@angular/material/icon';
|
|
6
|
+
import { BaseAsync } from '../abstract/base_async';
|
|
7
|
+
import { UserPaymentMethodService } from '../../service/user_payment_method.service';
|
|
8
|
+
import { UserPaymentMethod } from '../../model/appdata';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* End-user saved payment methods.
|
|
12
|
+
*
|
|
13
|
+
* Drop-in screen that lists the caller's cards/wallets, with set-default
|
|
14
|
+
* + delete actions. Inherits loading/error state from BaseAsync.
|
|
15
|
+
*
|
|
16
|
+
* Customise the heading and "add" CTA via inputs; emit (addClicked) so
|
|
17
|
+
* the consumer routes to its own SetupIntent flow (sail does not bundle
|
|
18
|
+
* a SetupIntent UI today — providers vary too much).
|
|
19
|
+
*
|
|
20
|
+
* Selector: <sail-user-payment-methods>
|
|
21
|
+
*/
|
|
22
|
+
@Component({
|
|
23
|
+
selector: 'sail-user-payment-methods',
|
|
24
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
25
|
+
encapsulation: ViewEncapsulation.None,
|
|
26
|
+
imports: [CommonModule, MatButtonModule, MatCardModule, MatIconModule],
|
|
27
|
+
template: `
|
|
28
|
+
<div class="user-payment-methods">
|
|
29
|
+
<h2 class="user-payment-methods__title">{{ title }}</h2>
|
|
30
|
+
|
|
31
|
+
@if (showAdd) {
|
|
32
|
+
<button mat-raised-button color="accent"
|
|
33
|
+
class="user-payment-methods__add"
|
|
34
|
+
(click)="addClicked.emit()">
|
|
35
|
+
<mat-icon>add</mat-icon> {{ addLabel }}
|
|
36
|
+
</button>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
@if (methods().length === 0 && !loading()) {
|
|
40
|
+
<p class="user-payment-methods__empty">{{ emptyLabel }}</p>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
@for (m of methods(); track m.Id) {
|
|
44
|
+
<mat-card class="user-payment-methods__item">
|
|
45
|
+
<mat-card-content>
|
|
46
|
+
<div class="user-payment-methods__row">
|
|
47
|
+
<div class="user-payment-methods__info">
|
|
48
|
+
<mat-icon>{{ iconFor(m) }}</mat-icon>
|
|
49
|
+
<span class="user-payment-methods__brand">{{ m.Brand || m.MethodType }}</span>
|
|
50
|
+
@if (m.LastFour) {
|
|
51
|
+
<span class="user-payment-methods__last4">•••• {{ m.LastFour }}</span>
|
|
52
|
+
}
|
|
53
|
+
@if (m.IsDefault) {
|
|
54
|
+
<span class="user-payment-methods__default">Default</span>
|
|
55
|
+
}
|
|
56
|
+
</div>
|
|
57
|
+
<div class="user-payment-methods__actions">
|
|
58
|
+
@if (!m.IsDefault) {
|
|
59
|
+
<button mat-button (click)="setDefault(m)">Set default</button>
|
|
60
|
+
}
|
|
61
|
+
<button mat-icon-button (click)="remove(m)" aria-label="Delete payment method">
|
|
62
|
+
<mat-icon>delete</mat-icon>
|
|
63
|
+
</button>
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
</mat-card-content>
|
|
67
|
+
</mat-card>
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
@if (errorMsg()) {
|
|
71
|
+
<p class="user-payment-methods__error">{{ errorMsg() }}</p>
|
|
72
|
+
}
|
|
73
|
+
</div>
|
|
74
|
+
`,
|
|
75
|
+
styles: `
|
|
76
|
+
.user-payment-methods { display: flex; flex-direction: column; gap: 12px; }
|
|
77
|
+
.user-payment-methods__title { margin: 0; }
|
|
78
|
+
.user-payment-methods__add { align-self: flex-start; }
|
|
79
|
+
.user-payment-methods__empty { color: var(--mat-app-on-surface-variant, #555); }
|
|
80
|
+
.user-payment-methods__item { background: var(--mat-app-surface, #fff); }
|
|
81
|
+
.user-payment-methods__row { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
|
|
82
|
+
.user-payment-methods__info { display: flex; align-items: center; gap: 8px; }
|
|
83
|
+
.user-payment-methods__brand { font-weight: 600; }
|
|
84
|
+
.user-payment-methods__last4 { color: var(--mat-app-on-surface-variant, #555); }
|
|
85
|
+
.user-payment-methods__default { color: var(--mat-app-primary, #1976d2); margin-left: 8px; font-size: 12px; font-weight: 600; }
|
|
86
|
+
.user-payment-methods__actions { display: flex; align-items: center; gap: 4px; }
|
|
87
|
+
.user-payment-methods__error { color: var(--mat-app-error, #b00020); font-size: 13px; margin: 0; }
|
|
88
|
+
`,
|
|
89
|
+
})
|
|
90
|
+
export class UserPaymentMethodsComponent extends BaseAsync implements OnInit {
|
|
91
|
+
@Input() title = 'Payment Methods';
|
|
92
|
+
@Input() addLabel = 'Add Payment Method';
|
|
93
|
+
@Input() emptyLabel = 'No saved payment methods yet.';
|
|
94
|
+
@Input() showAdd = true;
|
|
95
|
+
|
|
96
|
+
/** Emitted when the "Add" button is tapped. Consumer routes to its SetupIntent flow. */
|
|
97
|
+
@Output() addClicked = new EventEmitter<void>();
|
|
98
|
+
/** Emitted after a successful set-default flip. */
|
|
99
|
+
@Output() defaultChanged = new EventEmitter<UserPaymentMethod>();
|
|
100
|
+
/** Emitted after a successful delete. */
|
|
101
|
+
@Output() deleted = new EventEmitter<UserPaymentMethod>();
|
|
102
|
+
|
|
103
|
+
private readonly service = inject(UserPaymentMethodService);
|
|
104
|
+
|
|
105
|
+
readonly methods = signal<UserPaymentMethod[]>([]);
|
|
106
|
+
readonly errorMsg = signal<string>('');
|
|
107
|
+
|
|
108
|
+
ngOnInit(): void {
|
|
109
|
+
this.load();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
load(): void {
|
|
113
|
+
this.run(
|
|
114
|
+
this.service.list(),
|
|
115
|
+
(rows) => {
|
|
116
|
+
// Sort default-first locally — keel's generic CRUD doesn't
|
|
117
|
+
// honour a sort hint without rest_api_header config.
|
|
118
|
+
const sorted = [...(rows ?? [])].sort((a, b) =>
|
|
119
|
+
Number(b.IsDefault) - Number(a.IsDefault),
|
|
120
|
+
);
|
|
121
|
+
this.methods.set(sorted);
|
|
122
|
+
},
|
|
123
|
+
'Failed to load payment methods.',
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
setDefault(m: UserPaymentMethod): void {
|
|
128
|
+
this.service.setDefault(m.Id).subscribe({
|
|
129
|
+
next: () => {
|
|
130
|
+
this.methods.update((list) =>
|
|
131
|
+
list.map((row) => ({ ...row, IsDefault: row.Id === m.Id })),
|
|
132
|
+
);
|
|
133
|
+
this.defaultChanged.emit(m);
|
|
134
|
+
},
|
|
135
|
+
error: (err) => this.errorMsg.set(err?.error?.detail ?? 'Failed to set default'),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
remove(m: UserPaymentMethod): void {
|
|
140
|
+
this.service.delete(m.Id).subscribe({
|
|
141
|
+
next: () => {
|
|
142
|
+
this.methods.update((list) => list.filter((row) => row.Id !== m.Id));
|
|
143
|
+
this.deleted.emit(m);
|
|
144
|
+
},
|
|
145
|
+
error: (err) => this.errorMsg.set(err?.error?.detail ?? 'Failed to delete'),
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
iconFor(m: UserPaymentMethod): string {
|
|
150
|
+
switch (m.MethodType) {
|
|
151
|
+
case 'card': return 'credit_card';
|
|
152
|
+
case 'bank': return 'account_balance';
|
|
153
|
+
case 'apple_pay':
|
|
154
|
+
case 'google_pay':
|
|
155
|
+
case 'wallet': return 'wallet';
|
|
156
|
+
default: return 'credit_card';
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, computed, inject, signal } from '@angular/core';
|
|
2
|
+
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
|
3
|
+
import { MatButtonModule } from '@angular/material/button';
|
|
4
|
+
import { MatCheckboxModule } from '@angular/material/checkbox';
|
|
5
|
+
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
6
|
+
import { MatInputModule } from '@angular/material/input';
|
|
7
|
+
import { MatSelectModule } from '@angular/material/select';
|
|
8
|
+
import { BankInfoFormValue, CountryProfile } from '../../model/appdata';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Default country profiles bundled with sail. Covers CA / US / EU; downstream
|
|
12
|
+
* apps can pass a different list via the `[countryProfiles]` input.
|
|
13
|
+
*
|
|
14
|
+
* Codes match basis constant_header `tax_id_type` seeded by keel:
|
|
15
|
+
* S = SIN (CA), N = SSN (US), E = EIN (US business), V = VAT (EU), O = Other.
|
|
16
|
+
*/
|
|
17
|
+
export const DEFAULT_COUNTRY_PROFILES: CountryProfile[] = [
|
|
18
|
+
{ code: 'CA', label: 'Canada', currency: 'CAD', taxIdType: 'S', taxIdLabel: 'SIN', taxIdPlaceholder: '123456789', taxIdHint: 'Social Insurance Number — shared with the tax authority for annual reporting' },
|
|
19
|
+
{ code: 'US', label: 'United States', currency: 'USD', taxIdType: 'N', taxIdLabel: 'SSN or EIN', taxIdPlaceholder: '123-45-6789', taxIdHint: 'SSN for an individual recipient, EIN for an incorporated recipient' },
|
|
20
|
+
{ code: 'DE', label: 'Germany (EU)', currency: 'EUR', taxIdType: 'V', taxIdLabel: 'VAT number', taxIdPlaceholder: 'DE123456789', taxIdHint: 'VAT registration number' },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Payout bank-info form.
|
|
25
|
+
*
|
|
26
|
+
* Captures the basis `user_bank_info` fields (country, currency, account
|
|
27
|
+
* holder, tax id, billing address, agreement). Emits the form value via
|
|
28
|
+
* `(submitted)` — the consumer decides whether to POST directly to
|
|
29
|
+
* /api/v1/user_bank_info or pipe through its own registration service.
|
|
30
|
+
*
|
|
31
|
+
* `[provider]` defaults to "AW" (Airwallex) to match keel/payout's
|
|
32
|
+
* default --payout_provider flag. Override per deployment if needed.
|
|
33
|
+
*
|
|
34
|
+
* Selector: <sail-payout-bank-info-form>
|
|
35
|
+
*/
|
|
36
|
+
@Component({
|
|
37
|
+
selector: 'sail-payout-bank-info-form',
|
|
38
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
39
|
+
imports: [
|
|
40
|
+
ReactiveFormsModule, MatButtonModule, MatCheckboxModule,
|
|
41
|
+
MatFormFieldModule, MatInputModule, MatSelectModule,
|
|
42
|
+
],
|
|
43
|
+
template: `
|
|
44
|
+
<div class="bank-info-form">
|
|
45
|
+
<h2 class="bank-info-form__title">{{ title }}</h2>
|
|
46
|
+
|
|
47
|
+
@if (intro) {
|
|
48
|
+
<p class="bank-info-form__intro">{{ intro }}</p>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
<form [formGroup]="form" (ngSubmit)="submit()">
|
|
52
|
+
<mat-form-field appearance="outline" class="full-width">
|
|
53
|
+
<mat-label>Country *</mat-label>
|
|
54
|
+
<mat-select formControlName="countryCode">
|
|
55
|
+
@for (c of countryProfiles; track c.code) {
|
|
56
|
+
<mat-option [value]="c.code">{{ c.label }} ({{ c.currency }})</mat-option>
|
|
57
|
+
}
|
|
58
|
+
</mat-select>
|
|
59
|
+
</mat-form-field>
|
|
60
|
+
<p class="hint">Sets your tax ID format + payout currency</p>
|
|
61
|
+
|
|
62
|
+
<mat-form-field appearance="outline" class="full-width">
|
|
63
|
+
<mat-label>Account holder name *</mat-label>
|
|
64
|
+
<input matInput formControlName="accountHolderName">
|
|
65
|
+
</mat-form-field>
|
|
66
|
+
<p class="hint">Legal name on the bank account (must match government ID)</p>
|
|
67
|
+
|
|
68
|
+
<mat-form-field appearance="outline" class="full-width">
|
|
69
|
+
<mat-label>{{ taxIdLabel() }} *</mat-label>
|
|
70
|
+
<input matInput formControlName="taxId" [placeholder]="taxIdPlaceholder()">
|
|
71
|
+
</mat-form-field>
|
|
72
|
+
<p class="hint">{{ taxIdHint() }}</p>
|
|
73
|
+
|
|
74
|
+
<mat-form-field appearance="outline" class="full-width">
|
|
75
|
+
<mat-label>Billing address *</mat-label>
|
|
76
|
+
<input matInput formControlName="billingAddress">
|
|
77
|
+
</mat-form-field>
|
|
78
|
+
<p class="hint">Business or home address — used for tax correspondence</p>
|
|
79
|
+
|
|
80
|
+
<p class="provider-note">
|
|
81
|
+
Bank routing details (account number + institution / ABA /
|
|
82
|
+
IBAN) are collected by the payout provider in the next step;
|
|
83
|
+
this application does not store them.
|
|
84
|
+
</p>
|
|
85
|
+
|
|
86
|
+
<mat-checkbox formControlName="providerAgreement" class="agreement">
|
|
87
|
+
{{ agreementLabel }}
|
|
88
|
+
</mat-checkbox>
|
|
89
|
+
|
|
90
|
+
<div class="nav-buttons">
|
|
91
|
+
@if (showBack) {
|
|
92
|
+
<button mat-stroked-button class="pill-btn" type="button" (click)="back.emit()">
|
|
93
|
+
< Back
|
|
94
|
+
</button>
|
|
95
|
+
}
|
|
96
|
+
<button mat-flat-button class="pill-btn primary-btn" type="submit"
|
|
97
|
+
[disabled]="form.invalid">
|
|
98
|
+
{{ submitLabel }}
|
|
99
|
+
</button>
|
|
100
|
+
</div>
|
|
101
|
+
</form>
|
|
102
|
+
</div>
|
|
103
|
+
`,
|
|
104
|
+
styles: `
|
|
105
|
+
.bank-info-form { display: flex; flex-direction: column; gap: 4px; padding: 0 24px 32px; }
|
|
106
|
+
.bank-info-form__title { margin: 0 0 16px; }
|
|
107
|
+
.bank-info-form__intro { font-size: 14px; color: var(--mat-app-on-surface-variant, #555); margin: 0 0 16px; }
|
|
108
|
+
.full-width { width: 100%; }
|
|
109
|
+
.hint { font-size: 12px; color: var(--mat-app-on-surface-variant, #555); margin: -4px 0 8px; }
|
|
110
|
+
.provider-note { font-size: 13px; color: var(--mat-app-on-surface-variant, #555); background: rgba(0,0,0,0.04); padding: 12px; border-radius: 4px; margin: 12px 0; }
|
|
111
|
+
.agreement { margin: 12px 0 16px; font-size: 14px; }
|
|
112
|
+
.nav-buttons { display: flex; gap: 12px; margin-top: 8px; }
|
|
113
|
+
.pill-btn { border-radius: 24px; height: 48px; font-size: 15px; font-weight: 600; flex: 1; }
|
|
114
|
+
.primary-btn { background: var(--mat-app-primary, #1976d2); color: white; }
|
|
115
|
+
`,
|
|
116
|
+
})
|
|
117
|
+
export class PayoutBankInfoFormComponent {
|
|
118
|
+
/** Step title rendered above the form. */
|
|
119
|
+
@Input() title = 'Payment and Tax';
|
|
120
|
+
/** Optional explainer rendered below the title. */
|
|
121
|
+
@Input() intro = 'Tax + payout details. Bank account itself is set up with the payout provider in a separate step — only tax-reporting and dispute-correspondence fields are collected here.';
|
|
122
|
+
/** Override the list of supported countries — keep the default for most apps. */
|
|
123
|
+
@Input() countryProfiles: CountryProfile[] = DEFAULT_COUNTRY_PROFILES;
|
|
124
|
+
/** Provider code persisted on user_bank_info.provider. Default AW (Airwallex) matches keel's default --payout_provider. */
|
|
125
|
+
@Input() provider = 'AW';
|
|
126
|
+
/** Wording on the provider-agreement checkbox. Override to name the live provider. */
|
|
127
|
+
@Input() agreementLabel = 'I agree to the payout provider account agreement';
|
|
128
|
+
/** Submit-button label — wizards usually use "Next >". */
|
|
129
|
+
@Input() submitLabel = 'Next >';
|
|
130
|
+
/** When true, the back button renders alongside submit. */
|
|
131
|
+
@Input() showBack = true;
|
|
132
|
+
|
|
133
|
+
/** Emitted on submit; payload maps 1:1 to basis user_bank_info columns. */
|
|
134
|
+
@Output() submitted = new EventEmitter<BankInfoFormValue>();
|
|
135
|
+
/** Emitted when the user clicks Back. */
|
|
136
|
+
@Output() back = new EventEmitter<void>();
|
|
137
|
+
|
|
138
|
+
private readonly fb = inject(FormBuilder);
|
|
139
|
+
|
|
140
|
+
readonly form = this.fb.group({
|
|
141
|
+
countryCode: ['CA', Validators.required],
|
|
142
|
+
accountHolderName: ['', Validators.required],
|
|
143
|
+
taxId: ['', Validators.required],
|
|
144
|
+
billingAddress: ['', Validators.required],
|
|
145
|
+
providerAgreement: [false, Validators.requiredTrue],
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
private readonly selectedCountry = signal<CountryProfile>(this.countryProfiles[0]);
|
|
149
|
+
|
|
150
|
+
readonly taxIdLabel = computed(() => this.selectedCountry().taxIdLabel);
|
|
151
|
+
readonly taxIdPlaceholder = computed(() => this.selectedCountry().taxIdPlaceholder);
|
|
152
|
+
readonly taxIdHint = computed(() => this.selectedCountry().taxIdHint);
|
|
153
|
+
|
|
154
|
+
constructor() {
|
|
155
|
+
this.form.controls.countryCode.valueChanges.subscribe((code) => {
|
|
156
|
+
const profile = this.countryProfiles.find((c) => c.code === code) ?? this.countryProfiles[0];
|
|
157
|
+
this.selectedCountry.set(profile);
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
submit() {
|
|
162
|
+
if (this.form.invalid) return;
|
|
163
|
+
const v = this.form.value;
|
|
164
|
+
const country = this.selectedCountry();
|
|
165
|
+
this.submitted.emit({
|
|
166
|
+
countryCode: country.code,
|
|
167
|
+
currency: country.currency,
|
|
168
|
+
accountHolderName: v.accountHolderName!,
|
|
169
|
+
billingAddress: v.billingAddress!,
|
|
170
|
+
taxIdType: country.taxIdType,
|
|
171
|
+
taxId: v.taxId!,
|
|
172
|
+
provider: this.provider,
|
|
173
|
+
providerAgreement: v.providerAgreement!,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnInit, Output, inject, signal } from '@angular/core';
|
|
2
|
+
import { MatButtonModule } from '@angular/material/button';
|
|
3
|
+
import { MatCardModule } from '@angular/material/card';
|
|
4
|
+
import { MatIconModule } from '@angular/material/icon';
|
|
5
|
+
import { MatRadioModule } from '@angular/material/radio';
|
|
6
|
+
import { PayoutService } from '../../service/payout.service';
|
|
7
|
+
import { ReusableAccount } from '../../model/appdata';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Payout provider onboarding step.
|
|
11
|
+
*
|
|
12
|
+
* Drop-in screen for any keel-backed app that uses keel/payout. Renders
|
|
13
|
+
* two paths:
|
|
14
|
+
* 1. Reuse an existing provider account from another partner row
|
|
15
|
+
* (zero-KYC; calls /api/v1/payout/reusable/link).
|
|
16
|
+
* 2. Launch the provider's hosted-KYC page in a new tab
|
|
17
|
+
* (/api/v1/payout/onboard/start).
|
|
18
|
+
*
|
|
19
|
+
* Routing is consumer-owned via the `(linked)` / `(skipped)` outputs —
|
|
20
|
+
* the component itself never calls Router. Optional `title` and
|
|
21
|
+
* `skipLabel` inputs cover the common UX strings.
|
|
22
|
+
*
|
|
23
|
+
* Selector: <sail-payout-provider-onboarding>
|
|
24
|
+
*/
|
|
25
|
+
@Component({
|
|
26
|
+
selector: 'sail-payout-provider-onboarding',
|
|
27
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
28
|
+
imports: [MatButtonModule, MatCardModule, MatIconModule, MatRadioModule],
|
|
29
|
+
template: `
|
|
30
|
+
<div class="payout-onboarding">
|
|
31
|
+
<h2 class="payout-onboarding__title">{{ title }}</h2>
|
|
32
|
+
|
|
33
|
+
<p class="payout-onboarding__intro">
|
|
34
|
+
Bank routing details are collected by the payout provider, not
|
|
35
|
+
this application. After this step the provider will run KYC and
|
|
36
|
+
notify us when the account is ready for payouts.
|
|
37
|
+
</p>
|
|
38
|
+
|
|
39
|
+
@if (reusable().length > 0) {
|
|
40
|
+
<mat-card class="reuse-card">
|
|
41
|
+
<mat-card-header>
|
|
42
|
+
<mat-card-title>Reuse an existing account</mat-card-title>
|
|
43
|
+
</mat-card-header>
|
|
44
|
+
<mat-card-content>
|
|
45
|
+
<p class="hint">
|
|
46
|
+
You already have a payout account on
|
|
47
|
+
{{ reusable().length === 1 ? 'another partner' : 'other partners' }}.
|
|
48
|
+
You can reuse it here (no KYC redo) if the currency matches.
|
|
49
|
+
</p>
|
|
50
|
+
<mat-radio-group [value]="selectedAccountId()" (change)="selectAccount($any($event.value))">
|
|
51
|
+
@for (acc of reusable(); track acc.providerAccountId) {
|
|
52
|
+
<mat-radio-button [value]="acc.providerAccountId" class="reuse-option">
|
|
53
|
+
<strong>{{ acc.partnerCaption }}</strong>
|
|
54
|
+
— {{ acc.countryCode }} / {{ acc.currency }} ({{ acc.provider }})
|
|
55
|
+
</mat-radio-button>
|
|
56
|
+
}
|
|
57
|
+
</mat-radio-group>
|
|
58
|
+
<button mat-flat-button class="payout-onboarding__btn payout-onboarding__btn--primary"
|
|
59
|
+
[disabled]="!selectedAccountId() || busy()"
|
|
60
|
+
(click)="linkExisting()">
|
|
61
|
+
Use this account
|
|
62
|
+
</button>
|
|
63
|
+
</mat-card-content>
|
|
64
|
+
</mat-card>
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
<mat-card>
|
|
68
|
+
<mat-card-header>
|
|
69
|
+
<mat-card-title>Set up a new account</mat-card-title>
|
|
70
|
+
</mat-card-header>
|
|
71
|
+
<mat-card-content>
|
|
72
|
+
<p class="hint">
|
|
73
|
+
Opens the provider's secure onboarding page. You'll be asked
|
|
74
|
+
for bank routing details and government ID. The process
|
|
75
|
+
usually takes a few minutes.
|
|
76
|
+
</p>
|
|
77
|
+
@if (errorMsg()) {
|
|
78
|
+
<p class="payout-onboarding__error">{{ errorMsg() }}</p>
|
|
79
|
+
}
|
|
80
|
+
<button mat-flat-button class="payout-onboarding__btn payout-onboarding__btn--primary"
|
|
81
|
+
[disabled]="busy()"
|
|
82
|
+
(click)="startProviderKyc()">
|
|
83
|
+
<mat-icon>open_in_new</mat-icon>
|
|
84
|
+
Start onboarding
|
|
85
|
+
</button>
|
|
86
|
+
</mat-card-content>
|
|
87
|
+
</mat-card>
|
|
88
|
+
|
|
89
|
+
<div class="payout-onboarding__nav">
|
|
90
|
+
@if (showBack) {
|
|
91
|
+
<button mat-stroked-button class="payout-onboarding__btn" type="button" (click)="back.emit()">
|
|
92
|
+
< Back
|
|
93
|
+
</button>
|
|
94
|
+
}
|
|
95
|
+
<button mat-stroked-button class="payout-onboarding__btn" type="button" (click)="skipped.emit()">
|
|
96
|
+
{{ skipLabel }}
|
|
97
|
+
</button>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
`,
|
|
101
|
+
styles: `
|
|
102
|
+
.payout-onboarding { display: flex; flex-direction: column; gap: 16px; padding: 0 24px 32px; }
|
|
103
|
+
.payout-onboarding__title { margin: 0; }
|
|
104
|
+
.payout-onboarding__intro { font-size: 14px; color: var(--mat-app-on-surface-variant, #555); margin: 0; }
|
|
105
|
+
.reuse-card { background: var(--mat-app-surface-variant, #f5f5f5); }
|
|
106
|
+
.reuse-option { display: block; margin: 8px 0; }
|
|
107
|
+
.hint { font-size: 13px; color: var(--mat-app-on-surface-variant, #555); margin: 0 0 12px; }
|
|
108
|
+
.payout-onboarding__error { color: var(--mat-app-error, #b00020); font-size: 13px; margin: 0 0 12px; }
|
|
109
|
+
.payout-onboarding__nav { display: flex; gap: 12px; margin-top: 8px; }
|
|
110
|
+
.payout-onboarding__btn { border-radius: 24px; height: 48px; font-size: 15px; font-weight: 600; flex: 1; }
|
|
111
|
+
.payout-onboarding__btn--primary { background: var(--mat-app-primary, #1976d2); color: white; }
|
|
112
|
+
`,
|
|
113
|
+
})
|
|
114
|
+
export class PayoutProviderOnboardingComponent implements OnInit {
|
|
115
|
+
/** Step title — defaults match the most common payout-step framing. */
|
|
116
|
+
@Input() title = 'Bank Account Setup';
|
|
117
|
+
/** Skip-button label — covers wizards where skipping returns to the parent flow. */
|
|
118
|
+
@Input() skipLabel = "I'll do this later";
|
|
119
|
+
/** When true, the back button renders alongside skip. */
|
|
120
|
+
@Input() showBack = true;
|
|
121
|
+
|
|
122
|
+
/** Emitted after a successful reuse-link operation. */
|
|
123
|
+
@Output() linked = new EventEmitter<void>();
|
|
124
|
+
/** Emitted when the user dismisses the step (skip). */
|
|
125
|
+
@Output() skipped = new EventEmitter<void>();
|
|
126
|
+
/** Emitted when the user clicks Back. */
|
|
127
|
+
@Output() back = new EventEmitter<void>();
|
|
128
|
+
/** Emitted after the hosted page is opened in a new tab. Consumers can route away or show a "waiting on provider" screen. */
|
|
129
|
+
@Output() started = new EventEmitter<void>();
|
|
130
|
+
|
|
131
|
+
private readonly payoutService = inject(PayoutService);
|
|
132
|
+
|
|
133
|
+
readonly reusable = signal<ReusableAccount[]>([]);
|
|
134
|
+
readonly selectedAccountId = signal<string>('');
|
|
135
|
+
readonly busy = signal<boolean>(false);
|
|
136
|
+
readonly errorMsg = signal<string>('');
|
|
137
|
+
|
|
138
|
+
ngOnInit() {
|
|
139
|
+
this.payoutService.listReusable().subscribe({
|
|
140
|
+
next: ({ accounts }) => this.reusable.set(accounts ?? []),
|
|
141
|
+
error: () => this.reusable.set([]),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
selectAccount(id: string) {
|
|
146
|
+
this.selectedAccountId.set(id);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
linkExisting() {
|
|
150
|
+
const id = this.selectedAccountId();
|
|
151
|
+
if (!id) return;
|
|
152
|
+
this.busy.set(true);
|
|
153
|
+
this.payoutService.linkReusable(id).subscribe({
|
|
154
|
+
next: () => {
|
|
155
|
+
this.busy.set(false);
|
|
156
|
+
this.linked.emit();
|
|
157
|
+
},
|
|
158
|
+
error: (err) => {
|
|
159
|
+
this.errorMsg.set(err?.error?.detail ?? 'Failed to link existing account');
|
|
160
|
+
this.busy.set(false);
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
startProviderKyc() {
|
|
166
|
+
this.busy.set(true);
|
|
167
|
+
this.payoutService.startOnboarding().subscribe({
|
|
168
|
+
next: (res) => {
|
|
169
|
+
this.busy.set(false);
|
|
170
|
+
// Open in the platform browser. Mobile webview wrappers
|
|
171
|
+
// intercept this and pop the system browser so the provider
|
|
172
|
+
// page works in its expected environment.
|
|
173
|
+
window.open(res.url, '_blank');
|
|
174
|
+
this.started.emit();
|
|
175
|
+
},
|
|
176
|
+
error: (err) => {
|
|
177
|
+
this.errorMsg.set(err?.error?.detail ?? 'Failed to start onboarding');
|
|
178
|
+
this.busy.set(false);
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -32,8 +32,13 @@ export * from './component/security/trusted_devices';
|
|
|
32
32
|
export * from './component/billing/plan_selector';
|
|
33
33
|
export * from './component/billing/checkout_button';
|
|
34
34
|
export * from './component/billing/payment_methods';
|
|
35
|
+
export * from './component/payout/provider_onboarding';
|
|
36
|
+
export * from './component/payout/bank_info_form';
|
|
37
|
+
export * from './component/payment/user_payment_methods';
|
|
35
38
|
export * from './service/auth.service';
|
|
36
39
|
export * from './service/billing.service';
|
|
40
|
+
export * from './service/payout.service';
|
|
41
|
+
export * from './service/user_payment_method.service';
|
|
37
42
|
export * from './service/script_loader';
|
|
38
43
|
export * from './service/auth.interceptor';
|
|
39
44
|
export * from './service/api-response.interceptor';
|
package/src/model/appdata.ts
CHANGED
|
@@ -274,3 +274,81 @@ export interface Invoice {
|
|
|
274
274
|
issuedAt: string;
|
|
275
275
|
paidAt?: string;
|
|
276
276
|
}
|
|
277
|
+
|
|
278
|
+
// ── Payout types ──
|
|
279
|
+
// See keel/payout for the backend contract. user_bank_info is owned by
|
|
280
|
+
// basis; these types mirror keel/payout's ReusableAccount and
|
|
281
|
+
// StartOnboardingResult one-to-one.
|
|
282
|
+
|
|
283
|
+
// One provider account the user already has on a different partner.
|
|
284
|
+
// PayoutService.listReusable() returns these so multi-partner users can
|
|
285
|
+
// pick an existing account instead of redoing KYC.
|
|
286
|
+
export interface ReusableAccount {
|
|
287
|
+
partnerId: number;
|
|
288
|
+
partnerCaption: string;
|
|
289
|
+
provider: string; // 'AW' Airwallex, 'SC' Stripe Connect, 'WI' Wise
|
|
290
|
+
providerAccountId: string;
|
|
291
|
+
countryCode: string; // ISO 3166-1 alpha-2
|
|
292
|
+
currency: string; // ISO 4217
|
|
293
|
+
onboardedAt: string;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Result of PayoutService.startOnboarding(). url is opened in a webview /
|
|
297
|
+
// external browser; the provider posts back via webhook when activation
|
|
298
|
+
// lands.
|
|
299
|
+
export interface PayoutOnboardingSession {
|
|
300
|
+
url: string;
|
|
301
|
+
externalAccountId: string;
|
|
302
|
+
expiresAt: string;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Payload the bank-info form emits. Maps 1:1 to basis user_bank_info
|
|
306
|
+
// columns. The consumer decides where to POST it — typically a direct
|
|
307
|
+
// generic-CRUD insert against /api/v1/user_bank_info.
|
|
308
|
+
export interface BankInfoFormValue {
|
|
309
|
+
countryCode: string;
|
|
310
|
+
currency: string;
|
|
311
|
+
accountHolderName: string;
|
|
312
|
+
billingAddress: string;
|
|
313
|
+
taxIdType: string; // 'S' SIN, 'N' SSN, 'E' EIN, 'V' VAT, 'O' other
|
|
314
|
+
taxId: string;
|
|
315
|
+
provider: string;
|
|
316
|
+
providerAgreement: boolean;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// CountryProfile drives the country dropdown in the bank-info form.
|
|
320
|
+
// Each profile bundles ISO codes + tax-id labelling so the form's
|
|
321
|
+
// hint/placeholder text follows the country selection.
|
|
322
|
+
export interface CountryProfile {
|
|
323
|
+
code: string; // ISO 3166-1 alpha-2
|
|
324
|
+
label: string;
|
|
325
|
+
currency: string; // ISO 4217
|
|
326
|
+
taxIdType: string; // matches basis constant_header 'tax_id_type'
|
|
327
|
+
taxIdLabel: string;
|
|
328
|
+
taxIdPlaceholder: string;
|
|
329
|
+
taxIdHint: string;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ── User payment methods ──
|
|
333
|
+
// Consumer-side saved cards/wallets. Mirrors basis user_payment_method
|
|
334
|
+
// one-to-one. Distinct from PaymentMethod (partner-scoped SaaS billing
|
|
335
|
+
// card).
|
|
336
|
+
//
|
|
337
|
+
// PascalCase fields match keel's generic REST CRUD response shape —
|
|
338
|
+
// list and delete go through that path (the table is UserSpecific so
|
|
339
|
+
// the auto-filter scopes reads + owner-locks DELETE). Only set-default
|
|
340
|
+
// has a custom endpoint because it needs atomic multi-row UPDATE.
|
|
341
|
+
|
|
342
|
+
export interface UserPaymentMethod {
|
|
343
|
+
Id: number;
|
|
344
|
+
UserId?: number;
|
|
345
|
+
MethodType: string; // matches basis constant_header 'payment_method_type' — card / bank / wallet / apple_pay / google_pay
|
|
346
|
+
Provider: string; // 'stripe', ...
|
|
347
|
+
LastFour?: string;
|
|
348
|
+
Brand?: string;
|
|
349
|
+
ExpiryMonth?: number;
|
|
350
|
+
ExpiryYear?: number;
|
|
351
|
+
Currency: string;
|
|
352
|
+
IsDefault: boolean;
|
|
353
|
+
CreatedAt: string;
|
|
354
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { inject, Injectable } from '@angular/core';
|
|
2
|
+
import { HttpClient } from '@angular/common/http';
|
|
3
|
+
import { Observable } from 'rxjs';
|
|
4
|
+
import { RestURL } from './rest_url';
|
|
5
|
+
import {
|
|
6
|
+
PayoutOnboardingSession,
|
|
7
|
+
ReusableAccount,
|
|
8
|
+
} from '../model/appdata';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* PayoutService — shared API client for keel/payout.
|
|
12
|
+
*
|
|
13
|
+
* Mirrors keel/payout/OnboardingService on the frontend: hosted-KYC
|
|
14
|
+
* launch, status check, and the multi-partner reuse flow that lets a
|
|
15
|
+
* user share one provider_account_id across more than one
|
|
16
|
+
* (user, partner) row in user_bank_info.
|
|
17
|
+
*
|
|
18
|
+
* Endpoints live under /api/v1/payout/* — paths configurable via
|
|
19
|
+
* RestURL.payout*URL.
|
|
20
|
+
*/
|
|
21
|
+
@Injectable({ providedIn: 'root' })
|
|
22
|
+
export class PayoutService {
|
|
23
|
+
private readonly http = inject(HttpClient);
|
|
24
|
+
|
|
25
|
+
private url(path: string): string {
|
|
26
|
+
return RestURL.httpHost + path;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Launch the hosted-KYC flow. Returns a URL to open + provider-side handle. */
|
|
30
|
+
startOnboarding(): Observable<PayoutOnboardingSession> {
|
|
31
|
+
return this.http.post<PayoutOnboardingSession>(this.url(RestURL.payoutOnboardStartURL), {});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** List provider accounts the user already has on OTHER partners. */
|
|
35
|
+
listReusable(): Observable<{ accounts: ReusableAccount[] }> {
|
|
36
|
+
return this.http.post<{ accounts: ReusableAccount[] }>(this.url(RestURL.payoutReusableURL), {});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Copy an existing provider_account_id onto the calling partner's row. */
|
|
40
|
+
linkReusable(providerAccountId: string): Observable<{ message: string }> {
|
|
41
|
+
return this.http.post<{ message: string }>(this.url(RestURL.payoutReusableLinkURL), { providerAccountId });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** True when the calling user has a populated provider_account_id on the active partner row. */
|
|
45
|
+
status(): Observable<{ complete: boolean }> {
|
|
46
|
+
return this.http.post<{ complete: boolean }>(this.url(RestURL.payoutStatusURL), {});
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/service/rest_url.ts
CHANGED
|
@@ -31,6 +31,15 @@ export const RestURL = {
|
|
|
31
31
|
deleteAccountURL: '/api/user/account',
|
|
32
32
|
pushRegisterURL: '/api/push/register',
|
|
33
33
|
pushRevokeURL: '/api/push/revoke',
|
|
34
|
+
// Payout — keel/payout endpoints.
|
|
35
|
+
payoutOnboardStartURL: '/api/v1/payout/onboard/start',
|
|
36
|
+
payoutReusableURL: '/api/v1/payout/reusable',
|
|
37
|
+
payoutReusableLinkURL: '/api/v1/payout/reusable/link',
|
|
38
|
+
payoutStatusURL: '/api/v1/payout/status',
|
|
39
|
+
// User payment methods — list/delete go through keel's generic REST
|
|
40
|
+
// CRUD against the UserSpecific basis table; only set-default has a
|
|
41
|
+
// custom endpoint (atomic multi-row UPDATE).
|
|
42
|
+
paymentMethodSetDefaultURL: '/api/v1/payment-methods/set-default',
|
|
34
43
|
};
|
|
35
44
|
|
|
36
45
|
/** Call this at app startup to set the backend host URL */
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { inject, Injectable } from '@angular/core';
|
|
2
|
+
import { HttpClient } from '@angular/common/http';
|
|
3
|
+
import { Observable } from 'rxjs';
|
|
4
|
+
import { RestURL } from './rest_url';
|
|
5
|
+
import { BackendService } from './rest_service';
|
|
6
|
+
import { UserPaymentMethod } from '../model/appdata';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* UserPaymentMethodService — consumer-side saved-payment-methods API.
|
|
10
|
+
*
|
|
11
|
+
* list and delete go through keel's generic REST CRUD against the
|
|
12
|
+
* basis user_payment_method table — that table is UserSpecific (FK
|
|
13
|
+
* column `user_id`), so the auto-filter scopes reads + owner-locks
|
|
14
|
+
* DELETE without any custom server-side glue. setDefault has a custom
|
|
15
|
+
* endpoint because it needs an atomic multi-row UPDATE
|
|
16
|
+
* (`SET is_default = (id = ?) WHERE user_id = session`) that abstract
|
|
17
|
+
* single-row CRUD can't express.
|
|
18
|
+
*/
|
|
19
|
+
@Injectable({ providedIn: 'root' })
|
|
20
|
+
export class UserPaymentMethodService {
|
|
21
|
+
private readonly http = inject(HttpClient);
|
|
22
|
+
private readonly backend = inject(BackendService);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* List the caller's saved payment methods. Returns rows with PascalCase
|
|
26
|
+
* fields per the generic-CRUD convention; the default row sorts first
|
|
27
|
+
* server-side (filter ordering is set on rest_api_header).
|
|
28
|
+
*/
|
|
29
|
+
list(): Observable<UserPaymentMethod[]> {
|
|
30
|
+
return this.backend.list<UserPaymentMethod>('user_payment_method');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Delete a saved payment method. user_id is auto-scoped by keel. */
|
|
34
|
+
delete(id: number): Observable<{ message: string }> {
|
|
35
|
+
return this.backend.delete('user_payment_method', { Id: String(id) });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Mark one row as default; clears every other row in the same call.
|
|
40
|
+
* Custom endpoint — see service-class doc for the rationale.
|
|
41
|
+
*/
|
|
42
|
+
setDefault(id: number): Observable<{ message: string }> {
|
|
43
|
+
return this.http.post<{ message: string }>(
|
|
44
|
+
RestURL.httpHost + RestURL.paymentMethodSetDefaultURL,
|
|
45
|
+
{ id },
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
}
|