@fluid-app/fluid-cli-payments 0.0.0-stage → 0.1.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/README.md +68 -2
- package/dist/index.d.mts +20 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +1922 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +81 -3
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1922 @@
|
|
|
1
|
+
import prompts from "prompts";
|
|
2
|
+
import { NAV_HELP, assertInteractive, createDomainCommand, terminalPrompter } from "@fluid-app/fluid-cli";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { basename, extname } from "node:path";
|
|
5
|
+
//#region src/fields.ts
|
|
6
|
+
const STEPS = [
|
|
7
|
+
{
|
|
8
|
+
id: "business-type",
|
|
9
|
+
name: "The Basics"
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
id: "business-info",
|
|
13
|
+
name: "Business Info"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
id: "legal-entities",
|
|
17
|
+
name: "Legal Entities"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
id: "bank-accounts",
|
|
21
|
+
name: "Bank Accounts"
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: "personal-info",
|
|
25
|
+
name: "Personal Info"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
id: "relevant-persons",
|
|
29
|
+
name: "Relevant Persons"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
id: "countries",
|
|
33
|
+
name: "Countries"
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: "underwriting",
|
|
37
|
+
name: "Underwriting"
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: "terms-and-conditions",
|
|
41
|
+
name: "Terms & Conditions"
|
|
42
|
+
}
|
|
43
|
+
];
|
|
44
|
+
const f = (key, type, label, step, required = "always", extra = {}) => ({
|
|
45
|
+
key,
|
|
46
|
+
group: key.split(".")[0],
|
|
47
|
+
type,
|
|
48
|
+
label,
|
|
49
|
+
required,
|
|
50
|
+
step,
|
|
51
|
+
...extra
|
|
52
|
+
});
|
|
53
|
+
const MLM = "underwriting.company_is_mlm is true";
|
|
54
|
+
const HEALTH = "underwriting.sells_health_supplements is true";
|
|
55
|
+
const INGREDIENTS = [
|
|
56
|
+
["kratom", "Kratom"],
|
|
57
|
+
["ephedra", "Ephedra"],
|
|
58
|
+
["cannabis", "Cannabis"],
|
|
59
|
+
["cbd", "CBD"],
|
|
60
|
+
["aristolochia", "Aristolochia"],
|
|
61
|
+
["germanium", "Germanium"]
|
|
62
|
+
];
|
|
63
|
+
const DISEASE_CLAIMS = [
|
|
64
|
+
["cancer", "cancer"],
|
|
65
|
+
["impotency", "impotency"],
|
|
66
|
+
["alzheimers", "Alzheimer's"],
|
|
67
|
+
["memory_loss", "memory loss"]
|
|
68
|
+
];
|
|
69
|
+
const FIELDS = [
|
|
70
|
+
f("info.beneficial_individual_owner", "boolean", "Does any individual own 25% or more of the business?", "business-type"),
|
|
71
|
+
f("info.user_is_owner", "boolean", "Are you one of those owners?", "business-type", "conditional", { when: "info.beneficial_individual_owner is true" }),
|
|
72
|
+
f("info.beneficial_business_owner", "boolean", "Does another business own 25% or more of the business?", "business-type"),
|
|
73
|
+
f("info.other_legal_entities", "boolean", "Does the business operate through more than one legal entity?", "business-type"),
|
|
74
|
+
f("info.user_has_authority", "boolean", "Are you authorised to act for the business?", "business-type"),
|
|
75
|
+
f("info.user_is_signatory", "boolean", "Are you an authorised signatory?", "business-type"),
|
|
76
|
+
f("info.operates_internationally", "boolean", "Does the business sell outside its home country?", "business-type", "optional"),
|
|
77
|
+
f("info.business_structure_complexity", "enum", "Ownership structure", "business-type", "optional", { options: ["simple", "complex"] }),
|
|
78
|
+
f("underwriting.company_is_mlm", "boolean", "Is the business a direct-sales (MLM) company? Turns on the MLM questions.", "business-type", "optional"),
|
|
79
|
+
f("entity.legal_name", "string", "Legal company name", "business-info"),
|
|
80
|
+
f("entity.trading_name", "string", "Trading (DBA) name", "business-info", "optional"),
|
|
81
|
+
f("entity.classification", "business_type", "Business type, from `options business-types`", "business-info"),
|
|
82
|
+
f("entity.business_identification_number", "string", "Business tax ID: EIN in the US, VAT ID in VAT countries", "business-info", "always", { sensitive: true }),
|
|
83
|
+
f("entity.registration_number", "string", "Business registration number", "business-info"),
|
|
84
|
+
f("entity.date_of_incorporation", "date", "Date of incorporation (YYYY-MM-DD)", "business-info"),
|
|
85
|
+
f("entity.phone", "phone", "Business phone, at least 8 digits", "business-info"),
|
|
86
|
+
f("entity.phone_country_code", "string", "Phone country code, e.g. +1", "business-info", "optional"),
|
|
87
|
+
f("entity.website", "url", "Website, starting with http:// or https://", "business-info"),
|
|
88
|
+
f("entity.primary_mcc", "mcc", "Primary merchant category code, from `options mcc-codes`", "business-info"),
|
|
89
|
+
f("entity.secondary_mcc", "mcc", "Secondary merchant category code", "business-info", "optional"),
|
|
90
|
+
f("entity.address1", "string", "Registered street address", "business-info"),
|
|
91
|
+
f("entity.address2", "string", "Registered address line 2", "business-info", "optional"),
|
|
92
|
+
f("entity.city", "string", "Registered city", "business-info"),
|
|
93
|
+
f("entity.province", "string", "Registered state or province", "business-info"),
|
|
94
|
+
f("entity.postal_code", "string", "Registered postal code", "business-info"),
|
|
95
|
+
f("entity.country_iso", "country", "Registered country (ISO code, e.g. US)", "business-info"),
|
|
96
|
+
f("entity.business_registration_certificate", "file", "Business registration certificate", "business-info"),
|
|
97
|
+
f("entity.business_license", "file", "Business license", "business-info"),
|
|
98
|
+
f("entity.articles_of_incorporation", "file", "Articles of incorporation", "business-info"),
|
|
99
|
+
f("entity.w_9", "file", "IRS form W-9", "business-info", "conditional", { when: "entity.country_iso is US" }),
|
|
100
|
+
f("entity.last_3_months_processing_statements", "files", "Last three months of card processing statements", "business-info", "optional"),
|
|
101
|
+
f("bank.bank_name", "string", "Bank name", "bank-accounts"),
|
|
102
|
+
f("bank.holder_name", "string", "Account holder name", "bank-accounts"),
|
|
103
|
+
f("bank.account_number", "string", "Account number or IBAN", "bank-accounts", "always", { sensitive: true }),
|
|
104
|
+
f("bank.routing_number", "string", "Routing number", "bank-accounts", "conditional", {
|
|
105
|
+
when: "bank.country_iso is US",
|
|
106
|
+
sensitive: true
|
|
107
|
+
}),
|
|
108
|
+
f("bank.swift_bic", "string", "SWIFT/BIC code", "bank-accounts", "conditional", { when: "bank.country_iso is not US" }),
|
|
109
|
+
f("bank.country_iso", "country", "Bank country (ISO code)", "bank-accounts"),
|
|
110
|
+
f("bank.currency_iso", "currency", "Account currency (ISO code, e.g. USD)", "bank-accounts"),
|
|
111
|
+
f("bank.city", "string", "Bank city", "bank-accounts"),
|
|
112
|
+
f("bank.entity_id", "number", "Legal entity the account belongs to (defaults to the primary entity)", "bank-accounts", "optional"),
|
|
113
|
+
f("bank.last_3_months_bank_statements", "files", "Last three months of bank statements", "bank-accounts"),
|
|
114
|
+
f("bank.bank_letter_or_voided_check", "file", "Bank letter or voided check", "bank-accounts"),
|
|
115
|
+
f("owner.full_name", "string", "Full legal name", "personal-info"),
|
|
116
|
+
f("owner.work_email", "email", "Work email", "personal-info"),
|
|
117
|
+
f("owner.date_of_birth", "date", "Date of birth (YYYY-MM-DD)", "personal-info", "always", { sensitive: true }),
|
|
118
|
+
f("owner.place_of_birth", "string", "Place of birth", "personal-info"),
|
|
119
|
+
f("owner.nationality", "country", "Nationality (ISO code)", "personal-info"),
|
|
120
|
+
f("owner.country_iso", "country", "Country of residence (ISO code)", "personal-info"),
|
|
121
|
+
f("owner.street_address", "string", "Home street address", "personal-info"),
|
|
122
|
+
f("owner.city", "string", "Home city", "personal-info"),
|
|
123
|
+
f("owner.state", "string", "Home state or province", "personal-info"),
|
|
124
|
+
f("owner.postcode", "string", "Home postcode", "personal-info"),
|
|
125
|
+
f("owner.phone", "phone", "Phone", "personal-info"),
|
|
126
|
+
f("owner.identification_number", "string", "Personal ID number (SSN in the US)", "personal-info", "always", { sensitive: true }),
|
|
127
|
+
f("owner.position", "string", "Job title", "personal-info"),
|
|
128
|
+
f("owner.native_name", "string", "Name in native script", "personal-info", "optional"),
|
|
129
|
+
f("owner.is_managing_director", "boolean", "Managing director", "relevant-persons", "optional"),
|
|
130
|
+
f("owner.is_beneficial_owner", "boolean", "Owns 25% or more", "relevant-persons", "optional"),
|
|
131
|
+
f("owner.percent_ownership", "percent", "Ownership percentage, 25 or more for a beneficial owner", "relevant-persons", "conditional", { when: "owner.is_beneficial_owner is true" }),
|
|
132
|
+
f("owner.is_authorized_signatory", "boolean", "Authorised signatory", "relevant-persons", "optional"),
|
|
133
|
+
f("owner.onboarding_contact", "boolean", "The person filling in the form (one per company)", "personal-info", "optional"),
|
|
134
|
+
f("owner.entity_id", "number", "Legal entity the person belongs to (defaults to the primary entity)", "relevant-persons", "optional"),
|
|
135
|
+
f("owner.government_issued_ids", "files", "Government ID: passport photo page, or the front and back of a driver's license or national ID. Pass --id-type.", "personal-info"),
|
|
136
|
+
f("owner.proof_of_residence", "file", "Proof of residence, such as a utility bill", "personal-info"),
|
|
137
|
+
f("country.<ISO>.currency", "currency", "Currency customers pay in for that country", "countries"),
|
|
138
|
+
f("country.<ISO>.settlement_currency", "currency", "Currency payouts settle in", "countries"),
|
|
139
|
+
f("country.<ISO>.entity_id", "number", "Legal entity that sells in that country (defaults to the primary entity)", "countries"),
|
|
140
|
+
f("country.<ISO>.entity_legally_registered", "boolean", "The entity is legally registered to sell there; must be true", "countries"),
|
|
141
|
+
f("underwriting.company_description", "text", "What the company does", "underwriting"),
|
|
142
|
+
f("underwriting.management_stability", "text", "How long the management team has been in place", "underwriting"),
|
|
143
|
+
f("underwriting.uses_third_party_accounting", "yes_no", "Uses a third-party accounting firm (Yes - firm name / No)", "underwriting"),
|
|
144
|
+
f("underwriting.financials_audited", "boolean", "Financial statements are audited (true or false). The six financial statements are required either way.", "underwriting"),
|
|
145
|
+
f("underwriting.projected_annual_sales_volume", "number", "Projected annual sales volume", "underwriting"),
|
|
146
|
+
f("underwriting.average_order_value", "number", "Average order value", "underwriting"),
|
|
147
|
+
f("underwriting.cit_share_percentage", "percent", "% of sales that are one-time purchases (CIT); CIT + MIT = 100", "underwriting"),
|
|
148
|
+
f("underwriting.mit_share_percentage", "percent", "% of sales that are subscriptions (MIT); CIT + MIT = 100", "underwriting"),
|
|
149
|
+
f("underwriting.balance_sheet_year1", "file", "Balance sheet, most recent year", "underwriting"),
|
|
150
|
+
f("underwriting.balance_sheet_year2", "file", "Balance sheet, previous year", "underwriting"),
|
|
151
|
+
f("underwriting.income_statement_year1", "file", "Income statement, most recent year", "underwriting"),
|
|
152
|
+
f("underwriting.income_statement_year2", "file", "Income statement, previous year", "underwriting"),
|
|
153
|
+
f("underwriting.cash_flow_year1", "file", "Cash flow statement or P&L, most recent year", "underwriting"),
|
|
154
|
+
f("underwriting.cash_flow_year2", "file", "Cash flow statement or P&L, previous year", "underwriting"),
|
|
155
|
+
f("underwriting.has_compliance_officer", "yes_no", "Has a compliance officer (Yes - name / No)", "underwriting"),
|
|
156
|
+
f("underwriting.compliance_officer_responsibilities", "text", "The compliance officer's responsibilities", "underwriting"),
|
|
157
|
+
f("underwriting.distributor_monitoring_policies", "text", "How the company monitors its members' activity", "underwriting"),
|
|
158
|
+
f("underwriting.suspicious_activity_escalation_process", "text", "How suspicious activity is escalated", "underwriting"),
|
|
159
|
+
f("underwriting.retains_outside_legal_counsel", "yes_no", "Retains outside legal counsel (Yes - firm / No)", "underwriting"),
|
|
160
|
+
f("underwriting.investigations_description", "text", "Past or current regulatory investigations, or \"None\"", "underwriting"),
|
|
161
|
+
f("underwriting.negative_media_events", "text", "Negative media coverage, or \"None\"", "underwriting"),
|
|
162
|
+
f("underwriting.bbb_rating", "string", "Better Business Bureau rating, or \"N/A\"", "underwriting"),
|
|
163
|
+
f("underwriting.trustpilot_rating", "string", "Trustpilot rating, or \"N/A\"", "underwriting"),
|
|
164
|
+
f("underwriting.terms_and_conditions", "link_or_file", "Storefront terms & conditions: a URL (.link) or a file", "underwriting"),
|
|
165
|
+
f("underwriting.refund_or_return_policy", "link_or_file", "Refund or return policy: a URL (.link) or a file", "underwriting"),
|
|
166
|
+
f("underwriting.privacy_policy", "link_or_file", "Privacy policy: a URL (.link) or a file", "underwriting"),
|
|
167
|
+
f("underwriting.mlm_ownership_structure", "text", "How the business is owned and controlled", "underwriting", "conditional", { when: MLM }),
|
|
168
|
+
f("underwriting.mlm_ownership_structure_file", "file", "Ownership chart", "underwriting", "conditional", { when: MLM }),
|
|
169
|
+
f("underwriting.commission_payment_method", "text", "How commissions are paid", "underwriting", "conditional", { when: MLM }),
|
|
170
|
+
f("underwriting.commission_payment_channels", "text", "Channels commissions are paid through", "underwriting", "conditional", { when: MLM }),
|
|
171
|
+
f("underwriting.commission_payment_countries", "text", "Countries commissions are paid in", "underwriting", "conditional", { when: MLM }),
|
|
172
|
+
f("underwriting.compensation_plan", "files", "Compensation plan", "underwriting", "conditional", { when: MLM }),
|
|
173
|
+
f("underwriting.recruiter_commission_for_recruiting", "yes_no", "Members earn commission just for recruiting (Yes - details / No)", "underwriting", "conditional", { when: MLM }),
|
|
174
|
+
f("underwriting.all_distributors_can_profit", "yes_no", "Every member can profit (Yes / No - details)", "underwriting", "conditional", { when: MLM }),
|
|
175
|
+
f("underwriting.provides_average_compensation_report", "yes_no", "Publishes an average compensation report (Yes / No)", "underwriting", "conditional", { when: MLM }),
|
|
176
|
+
f("underwriting.income_disclosure_statement", "link_or_file", "Income disclosure statement", "underwriting", "conditional", { when: MLM }),
|
|
177
|
+
f("underwriting.distributor_identification_requirements", "text", "How members are identified at sign-up", "underwriting", "conditional", { when: MLM }),
|
|
178
|
+
f("underwriting.distributor_pii_collected", "text", "Personal data collected from members", "underwriting", "conditional", { when: MLM }),
|
|
179
|
+
f("underwriting.distributor_due_diligence", "text", "Due diligence on new members", "underwriting", "conditional", { when: MLM }),
|
|
180
|
+
f("underwriting.distributor_country_restrictions", "text", "Countries members may not operate in", "underwriting", "conditional", { when: MLM }),
|
|
181
|
+
f("underwriting.distributor_decline_termination_reasons", "text", "Reasons a member is declined or terminated", "underwriting", "conditional", { when: MLM }),
|
|
182
|
+
f("underwriting.distributor_cross_border_sales", "yes_no", "Members may sell internationally (Yes - details / No)", "underwriting", "conditional", { when: MLM }),
|
|
183
|
+
f("underwriting.distributor_training_description", "text", "Training given to members", "underwriting", "conditional", { when: MLM }),
|
|
184
|
+
f("underwriting.distributor_policies", "link_or_file", "Member policies and procedures", "underwriting", "conditional", { when: MLM }),
|
|
185
|
+
f("underwriting.sample_distributor_agreement", "link_or_file", "Sample member agreement", "underwriting", "conditional", { when: MLM }),
|
|
186
|
+
f("underwriting.business_opportunity_disclosures", "link_or_file", "Business opportunity disclosures", "underwriting", "conditional", { when: MLM }),
|
|
187
|
+
f("underwriting.fulfillment_agreement", "link_or_file", "Fulfillment agreement", "underwriting", "conditional", { when: MLM }),
|
|
188
|
+
f("underwriting.manufacturing_agreement", "link_or_file", "Manufacturing agreement", "underwriting", "conditional", { when: MLM }),
|
|
189
|
+
f("underwriting.distributor_retention_turnover_rates", "text", "Member retention and turnover rates", "underwriting", "optional"),
|
|
190
|
+
f("underwriting.owners_principals_list", "text", "Owners and principals", "underwriting", "optional"),
|
|
191
|
+
f("underwriting.product_service_description", "text", "Products and services sold", "underwriting", "optional"),
|
|
192
|
+
f("underwriting.revenue_from_recruiting_percentage", "percent", "% of revenue from recruiting", "underwriting", "optional", { sendAsText: true }),
|
|
193
|
+
f("underwriting.revenue_from_website_percentage", "percent", "% of revenue from the website", "underwriting", "optional", { sendAsText: true }),
|
|
194
|
+
f("underwriting.retail_sales_outside_downline_percentage", "percent", "% of retail sales to people outside the downline", "underwriting", "optional", { sendAsText: true }),
|
|
195
|
+
f("underwriting.compliance_monitoring_processes", "text", "Compliance monitoring processes", "underwriting", "optional"),
|
|
196
|
+
f("underwriting.signup_fees_description", "text", "Sign-up fees", "underwriting", "optional"),
|
|
197
|
+
f("underwriting.training_types_description", "text", "Types of training offered", "underwriting", "optional"),
|
|
198
|
+
f("underwriting.purchase_requirements_description", "text", "Purchase requirements for members", "underwriting", "optional"),
|
|
199
|
+
f("underwriting.ftc_compliance_description", "text", "How the company complies with FTC guidance", "underwriting", "optional"),
|
|
200
|
+
f("underwriting.sells_health_supplements", "boolean", "Sells health products or supplements. Turns on the health questions.", "underwriting", "optional"),
|
|
201
|
+
f("underwriting.is_product_manufacturer", "yes_no", "Manufactures its own products (Yes / No - manufacturer)", "underwriting", "conditional", { when: HEALTH }),
|
|
202
|
+
f("underwriting.marketing_methods_description", "text", "How the products are marketed", "underwriting", "conditional", { when: HEALTH }),
|
|
203
|
+
f("underwriting.cancellation_methods_description", "text", "How customers cancel subscriptions", "underwriting", "conditional", { when: HEALTH }),
|
|
204
|
+
f("underwriting.scientific_studies_description", "text", "Scientific studies or evidence behind the products", "underwriting", "conditional", { when: HEALTH }),
|
|
205
|
+
f("underwriting.health_product_lawsuit_history", "text", "Lawsuits or investigations about the products, or \"None\"", "underwriting", "conditional", { when: HEALTH }),
|
|
206
|
+
...INGREDIENTS.flatMap(([id, label]) => [f(`underwriting.contains_${id}`, "boolean", `A product contains ${label}`, "underwriting", "optional"), f(`underwriting.${id}_product_names`, "text", `Products containing ${label}`, "underwriting", "conditional", { when: `underwriting.contains_${id} is true` })]),
|
|
207
|
+
f("underwriting.contains_none_listed", "boolean", "No product contains any listed ingredient. With health products, one contains_* must be true.", "underwriting", "optional"),
|
|
208
|
+
...DISEASE_CLAIMS.flatMap(([id, label]) => [f(`underwriting.claims_${id}_treatment`, "boolean", `A product claims to treat ${label}`, "underwriting", "optional"), f(`underwriting.${id}_treatment_products`, "text", `Products claiming to treat ${label}`, "underwriting", "conditional", { when: `underwriting.claims_${id}_treatment is true` })]),
|
|
209
|
+
f("underwriting.claims_no_diseases", "boolean", "No product claims to treat a disease. With health products, one claims_* must be true.", "underwriting", "optional")
|
|
210
|
+
];
|
|
211
|
+
const INGREDIENT_IDS = INGREDIENTS.map(([id, label]) => ({
|
|
212
|
+
id,
|
|
213
|
+
label
|
|
214
|
+
}));
|
|
215
|
+
const DISEASE_CLAIM_IDS = DISEASE_CLAIMS.map(([id, label]) => ({
|
|
216
|
+
id,
|
|
217
|
+
label
|
|
218
|
+
}));
|
|
219
|
+
const ID_TYPES = [
|
|
220
|
+
"passport",
|
|
221
|
+
"license",
|
|
222
|
+
"national_id"
|
|
223
|
+
];
|
|
224
|
+
/** Document uploads are refused above this size. */
|
|
225
|
+
const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
|
|
226
|
+
const BY_KEY = new Map(FIELDS.map((field) => [field.key, field]));
|
|
227
|
+
/** The definition for a `--set`/`--file` key, normalising a country key
|
|
228
|
+
* like `country.us.currency` to its `country.<ISO>.currency` entry. */
|
|
229
|
+
function fieldFor(key) {
|
|
230
|
+
const country = /^country\.([A-Za-z]{2})\.(\w+)$/.exec(key);
|
|
231
|
+
if (country) return BY_KEY.get(`country.<ISO>.${country[2]}`);
|
|
232
|
+
return BY_KEY.get(key);
|
|
233
|
+
}
|
|
234
|
+
function isSensitive(key) {
|
|
235
|
+
return fieldFor(key)?.sensitive === true;
|
|
236
|
+
}
|
|
237
|
+
/** Strip the group prefix: `entity.legal_name` -> `legal_name`. */
|
|
238
|
+
function attributeOf(key) {
|
|
239
|
+
return key.slice(key.indexOf(".") + 1);
|
|
240
|
+
}
|
|
241
|
+
//#endregion
|
|
242
|
+
//#region src/requirements.ts
|
|
243
|
+
/** The server's step keys. Submission means all of these are 100 and the
|
|
244
|
+
* terms are agreed (`PaymentsStatus::IndexAction#onboarding_completed?`). */
|
|
245
|
+
const SERVER_STEP_KEYS = [
|
|
246
|
+
"basic_info",
|
|
247
|
+
"business_info",
|
|
248
|
+
"legal_entities",
|
|
249
|
+
"bank_accounts_i",
|
|
250
|
+
"bank_accounts_ii",
|
|
251
|
+
"personal_info",
|
|
252
|
+
"relevant_persons",
|
|
253
|
+
"countries",
|
|
254
|
+
"underwriting"
|
|
255
|
+
];
|
|
256
|
+
const filled = (value) => typeof value === "string" ? value.trim() !== "" : value !== null && value !== void 0 && value !== false;
|
|
257
|
+
const hasFiles = (value) => Array.isArray(value) && value.length > 0;
|
|
258
|
+
function targetFlag(target) {
|
|
259
|
+
if (!target) return "";
|
|
260
|
+
if (target.entity !== void 0) return `--entity ${target.entity} `;
|
|
261
|
+
if (target.bank !== void 0) return `--bank ${target.bank} `;
|
|
262
|
+
if (target.owner !== void 0) return `--owner ${target.owner} `;
|
|
263
|
+
return "";
|
|
264
|
+
}
|
|
265
|
+
function fixFor(field, target) {
|
|
266
|
+
const type = fieldFor(field)?.type;
|
|
267
|
+
const prefix = targetFlag(target);
|
|
268
|
+
if (type === "file" || type === "files") return `${prefix}--file ${field}=<path>${field === "owner.government_issued_ids" ? " --id-type <passport|license|national_id>" : ""}`;
|
|
269
|
+
if (type === "link_or_file") return `${prefix}--set ${field}.link=<url> (or --file ${field}=<path>)`;
|
|
270
|
+
return `${prefix}--set ${field}=<value>`;
|
|
271
|
+
}
|
|
272
|
+
var Collector = class {
|
|
273
|
+
items = [];
|
|
274
|
+
add(step, field, message, target) {
|
|
275
|
+
const key = `${step}|${field}|${JSON.stringify(target ?? {})}`;
|
|
276
|
+
if (this.items.some((m) => `${m.step}|${m.field}|${JSON.stringify(m.target ?? {})}` === key)) return;
|
|
277
|
+
this.items.push({
|
|
278
|
+
step,
|
|
279
|
+
field,
|
|
280
|
+
message,
|
|
281
|
+
...target ? { target } : {},
|
|
282
|
+
fix: fixFor(field, target)
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
const VAT_COUNTRIES = new Set([
|
|
287
|
+
"GB",
|
|
288
|
+
"DE",
|
|
289
|
+
"FR",
|
|
290
|
+
"IT",
|
|
291
|
+
"ES",
|
|
292
|
+
"NL",
|
|
293
|
+
"BE",
|
|
294
|
+
"AT",
|
|
295
|
+
"SE",
|
|
296
|
+
"DK",
|
|
297
|
+
"FI",
|
|
298
|
+
"NO",
|
|
299
|
+
"CH",
|
|
300
|
+
"PL",
|
|
301
|
+
"CZ",
|
|
302
|
+
"HU",
|
|
303
|
+
"RO",
|
|
304
|
+
"BG",
|
|
305
|
+
"HR",
|
|
306
|
+
"SI",
|
|
307
|
+
"SK",
|
|
308
|
+
"EE",
|
|
309
|
+
"LV",
|
|
310
|
+
"LT",
|
|
311
|
+
"MT",
|
|
312
|
+
"CY",
|
|
313
|
+
"LU",
|
|
314
|
+
"IE",
|
|
315
|
+
"PT",
|
|
316
|
+
"GR"
|
|
317
|
+
]);
|
|
318
|
+
function primaryEntity(entities) {
|
|
319
|
+
return entities.find((e) => e.primary === true) ?? entities[0];
|
|
320
|
+
}
|
|
321
|
+
function onboardingContact(owners) {
|
|
322
|
+
return owners.find((o) => o.onboarding_contact === true);
|
|
323
|
+
}
|
|
324
|
+
function primaryBankAccount(snapshot) {
|
|
325
|
+
const explicit = snapshot.bankAccounts.find((a) => a.primary === true);
|
|
326
|
+
if (explicit) return explicit;
|
|
327
|
+
const entity = primaryEntity(snapshot.entities);
|
|
328
|
+
return entity ? snapshot.bankAccounts.find((a) => a.entity_id === entity.id) : void 0;
|
|
329
|
+
}
|
|
330
|
+
function entityCountry(entity) {
|
|
331
|
+
return String(entity.country_iso || entity.registered_address?.["country_code"] || "").toUpperCase();
|
|
332
|
+
}
|
|
333
|
+
function checkEntity(c, step, entity, target) {
|
|
334
|
+
const value = (attr) => entity[attr];
|
|
335
|
+
const nested = {
|
|
336
|
+
province: "state",
|
|
337
|
+
country_iso: "country_code"
|
|
338
|
+
};
|
|
339
|
+
const address = (attr) => filled(value(attr)) ? value(attr) : entity.registered_address?.[nested[attr] ?? attr];
|
|
340
|
+
for (const [attr, message] of [
|
|
341
|
+
["legal_name", "Legal company name is required"],
|
|
342
|
+
["classification", "Business type is required"],
|
|
343
|
+
["registration_number", "Business registration number is required"],
|
|
344
|
+
["date_of_incorporation", "Date of incorporation is required"],
|
|
345
|
+
["phone", "Phone number is required"],
|
|
346
|
+
["website", "Website is required"],
|
|
347
|
+
["primary_mcc", "Primary MCC is required"]
|
|
348
|
+
]) if (!filled(value(attr))) c.add(step, `entity.${attr}`, message, target);
|
|
349
|
+
for (const [attr, message] of [
|
|
350
|
+
["address1", "Street address is required"],
|
|
351
|
+
["city", "City is required"],
|
|
352
|
+
["province", "State/Province is required"],
|
|
353
|
+
["postal_code", "Postal code is required"],
|
|
354
|
+
["country_iso", "Country is required"]
|
|
355
|
+
]) if (!filled(address(attr))) c.add(step, `entity.${attr}`, message, target);
|
|
356
|
+
const country = entityCountry(entity);
|
|
357
|
+
if (!filled(value("business_identification_number"))) c.add(step, "entity.business_identification_number", country === "US" ? "Employer Identification Number (EIN) is required" : VAT_COUNTRIES.has(country) ? "VAT ID is required" : "Business tax ID is required", target);
|
|
358
|
+
const files = [
|
|
359
|
+
["business_registration_certificate", "Business registration certificate is required"],
|
|
360
|
+
["business_license", "Business license is required"],
|
|
361
|
+
["articles_of_incorporation", "Articles of incorporation is required"]
|
|
362
|
+
];
|
|
363
|
+
if (country === "US") files.push(["w_9", "W-9 is required for US entities"]);
|
|
364
|
+
for (const [base, message] of files) if (!filled(value(`${base}_file_url`))) c.add(step, `entity.${base}`, message, target);
|
|
365
|
+
const website = String(value("website") ?? "").trim();
|
|
366
|
+
if (website && !/^https?:\/\//.test(website)) c.add(step, "entity.website", "Website must start with http:// or https://", target);
|
|
367
|
+
const phone = String(value("phone") ?? "").trim();
|
|
368
|
+
if (phone && !/^[\d\s\-+()]{8,}$/.test(phone)) c.add(step, "entity.phone", "Phone number must have at least 8 digits", target);
|
|
369
|
+
}
|
|
370
|
+
function checkBank(c, account, target) {
|
|
371
|
+
const step = "bank-accounts";
|
|
372
|
+
for (const [attr, message] of [
|
|
373
|
+
["bank_name", "Bank name is required"],
|
|
374
|
+
["holder_name", "Account holder name is required"],
|
|
375
|
+
["account_number", "Account number is required"],
|
|
376
|
+
["country_iso", "Bank country is required"],
|
|
377
|
+
["currency_iso", "Currency is required"],
|
|
378
|
+
["city", "Bank city is required"]
|
|
379
|
+
]) if (!filled(account[attr])) c.add(step, `bank.${attr}`, message, target);
|
|
380
|
+
if (!filled(account["bank_letter_or_voided_check_file_url"])) c.add(step, "bank.bank_letter_or_voided_check", "Bank letter or voided check is required", target);
|
|
381
|
+
const country = String(account.country_iso ?? "").toUpperCase();
|
|
382
|
+
if (country === "US") {
|
|
383
|
+
if (!filled(account["routing_number"])) c.add(step, "bank.routing_number", "Routing number is required for US accounts", target);
|
|
384
|
+
} else if (country) {
|
|
385
|
+
if (!filled(account["swift_bic"])) c.add(step, "bank.swift_bic", "SWIFT/BIC is required outside the US", target);
|
|
386
|
+
} else c.add(step, "bank.routing_number", "Routing number (US) or SWIFT/BIC (elsewhere) is required once the country is set", target);
|
|
387
|
+
if (!(filled(account["last_3_months_bank_statements_file_url"]) || hasFiles(account.last_3_months_bank_statements))) c.add(step, "bank.last_3_months_bank_statements", "Last 3 months of bank statements are required", target);
|
|
388
|
+
}
|
|
389
|
+
function checkOwner(c, step, owner, target) {
|
|
390
|
+
for (const [attr, message] of [
|
|
391
|
+
["full_name", "Full name is required"],
|
|
392
|
+
["work_email", "Work email is required"],
|
|
393
|
+
["date_of_birth", "Date of birth is required"],
|
|
394
|
+
["place_of_birth", "Place of birth is required"],
|
|
395
|
+
["nationality", "Nationality is required"],
|
|
396
|
+
["country_iso", "Country of residence is required"],
|
|
397
|
+
["street_address", "Street address is required"],
|
|
398
|
+
["city", "City is required"],
|
|
399
|
+
["state", "State/Province is required"],
|
|
400
|
+
["postcode", "Postcode is required"],
|
|
401
|
+
["phone", "Phone number is required"],
|
|
402
|
+
["identification_number", "Identification number is required"],
|
|
403
|
+
["position", "Job title is required"]
|
|
404
|
+
]) if (!filled(owner[attr])) c.add(step, `owner.${attr}`, message, target);
|
|
405
|
+
const email = String(owner["work_email"] ?? "").trim();
|
|
406
|
+
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) c.add(step, "owner.work_email", "Work email is not a valid address", target);
|
|
407
|
+
const ids = Array.isArray(owner.government_issued_ids) ? owner.government_issued_ids : [];
|
|
408
|
+
if (ids.length === 0) c.add(step, "owner.government_issued_ids", "Government-issued ID is required", target);
|
|
409
|
+
else {
|
|
410
|
+
const type = ids[0]?.file_type;
|
|
411
|
+
if ((type === "license" || type === "national_id") && ids.length === 1) c.add(step, "owner.government_issued_ids", `Upload both the front and back of the ${type === "license" ? "driver's license" : "national ID"}`, target);
|
|
412
|
+
}
|
|
413
|
+
if (!filled(owner["proof_of_residence_file_url"])) c.add(step, "owner.proof_of_residence", "Proof of residence is required", target);
|
|
414
|
+
if (owner.is_beneficial_owner === true) {
|
|
415
|
+
const raw = String(owner.percent_ownership ?? "").trim();
|
|
416
|
+
const pct = raw === "" ? NaN : Number.parseFloat(raw);
|
|
417
|
+
if (raw === "") c.add(step, "owner.percent_ownership", "Ownership percentage is required for a beneficial owner", target);
|
|
418
|
+
else if (Number.isNaN(pct) || pct < 25 || pct > 100) c.add(step, "owner.percent_ownership", "A beneficial owner must own between 25% and 100%", target);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
const POLICY_FIELDS = [
|
|
422
|
+
"terms_and_conditions",
|
|
423
|
+
"refund_or_return_policy",
|
|
424
|
+
"privacy_policy"
|
|
425
|
+
];
|
|
426
|
+
const MLM_DOCUMENTS = [
|
|
427
|
+
"distributor_policies",
|
|
428
|
+
"sample_distributor_agreement",
|
|
429
|
+
"business_opportunity_disclosures",
|
|
430
|
+
"income_disclosure_statement",
|
|
431
|
+
"fulfillment_agreement",
|
|
432
|
+
"manufacturing_agreement"
|
|
433
|
+
];
|
|
434
|
+
const ALWAYS_TEXT = [
|
|
435
|
+
"company_description",
|
|
436
|
+
"management_stability",
|
|
437
|
+
"uses_third_party_accounting",
|
|
438
|
+
"has_compliance_officer",
|
|
439
|
+
"compliance_officer_responsibilities",
|
|
440
|
+
"distributor_monitoring_policies",
|
|
441
|
+
"suspicious_activity_escalation_process",
|
|
442
|
+
"retains_outside_legal_counsel",
|
|
443
|
+
"investigations_description",
|
|
444
|
+
"negative_media_events",
|
|
445
|
+
"bbb_rating",
|
|
446
|
+
"trustpilot_rating",
|
|
447
|
+
"projected_annual_sales_volume",
|
|
448
|
+
"average_order_value"
|
|
449
|
+
];
|
|
450
|
+
const MLM_TEXT = [
|
|
451
|
+
"mlm_ownership_structure",
|
|
452
|
+
"commission_payment_method",
|
|
453
|
+
"commission_payment_channels",
|
|
454
|
+
"commission_payment_countries",
|
|
455
|
+
"recruiter_commission_for_recruiting",
|
|
456
|
+
"all_distributors_can_profit",
|
|
457
|
+
"provides_average_compensation_report",
|
|
458
|
+
"distributor_identification_requirements",
|
|
459
|
+
"distributor_pii_collected",
|
|
460
|
+
"distributor_due_diligence",
|
|
461
|
+
"distributor_country_restrictions",
|
|
462
|
+
"distributor_decline_termination_reasons",
|
|
463
|
+
"distributor_cross_border_sales",
|
|
464
|
+
"distributor_training_description"
|
|
465
|
+
];
|
|
466
|
+
const HEALTH_TEXT = [
|
|
467
|
+
"is_product_manufacturer",
|
|
468
|
+
"marketing_methods_description",
|
|
469
|
+
"cancellation_methods_description",
|
|
470
|
+
"scientific_studies_description",
|
|
471
|
+
"health_product_lawsuit_history"
|
|
472
|
+
];
|
|
473
|
+
const FINANCIAL_FILES = [
|
|
474
|
+
"balance_sheet_year1",
|
|
475
|
+
"balance_sheet_year2",
|
|
476
|
+
"income_statement_year1",
|
|
477
|
+
"income_statement_year2",
|
|
478
|
+
"cash_flow_year1",
|
|
479
|
+
"cash_flow_year2"
|
|
480
|
+
];
|
|
481
|
+
function linkOrFile(u, field) {
|
|
482
|
+
const obj = u[field] ?? {};
|
|
483
|
+
return filled(obj.link) || filled(obj.file_url) || filled(u[`${field}_file_url`]);
|
|
484
|
+
}
|
|
485
|
+
function checkUnderwriting(c, u) {
|
|
486
|
+
const step = "underwriting";
|
|
487
|
+
const isMlm = u["company_is_mlm"] === true;
|
|
488
|
+
const add = (attr, message = "This field is required") => c.add(step, `underwriting.${attr}`, message);
|
|
489
|
+
for (const attr of ALWAYS_TEXT) if (!filled(u[attr])) add(attr);
|
|
490
|
+
if (typeof u["financials_audited"] !== "boolean") add("financials_audited");
|
|
491
|
+
for (const base of FINANCIAL_FILES) if (!filled(u[`${base}_file_url`])) add(base);
|
|
492
|
+
const cit = String(u["cit_share_percentage"] ?? "").trim();
|
|
493
|
+
const mit = String(u["mit_share_percentage"] ?? "").trim();
|
|
494
|
+
if (!cit) add("cit_share_percentage", "% one-time purchases (CIT) is required");
|
|
495
|
+
if (!mit) add("mit_share_percentage", "% subscriptions (MIT) is required");
|
|
496
|
+
if (cit && mit) {
|
|
497
|
+
const citN = Number.parseFloat(cit);
|
|
498
|
+
const mitN = Number.parseFloat(mit);
|
|
499
|
+
if (Number.isNaN(citN) || citN < 0 || citN > 100) add("cit_share_percentage", "CIT must be between 0 and 100");
|
|
500
|
+
if (Number.isNaN(mitN) || mitN < 0 || mitN > 100) add("mit_share_percentage", "MIT must be between 0 and 100");
|
|
501
|
+
if (!Number.isNaN(citN) && !Number.isNaN(mitN) && Math.abs(citN + mitN - 100) > .01) add("cit_share_percentage", "CIT and MIT must total 100");
|
|
502
|
+
}
|
|
503
|
+
for (const field of POLICY_FIELDS) if (!linkOrFile(u, field)) add(field, "A URL or an uploaded file is required");
|
|
504
|
+
if (isMlm) {
|
|
505
|
+
for (const attr of MLM_TEXT) if (!filled(u[attr])) add(attr);
|
|
506
|
+
if (!filled(u["mlm_ownership_structure_file_url"])) add("mlm_ownership_structure_file", "An ownership chart is required");
|
|
507
|
+
for (const field of MLM_DOCUMENTS) if (!linkOrFile(u, field)) add(field, "A URL or an uploaded file is required");
|
|
508
|
+
if (!hasFiles(u["compensation_plan"]) && !filled(u["compensation_plan_file_url"])) add("compensation_plan", "Compensation plan is required");
|
|
509
|
+
for (const attr of [
|
|
510
|
+
"revenue_from_recruiting_percentage",
|
|
511
|
+
"revenue_from_website_percentage",
|
|
512
|
+
"retail_sales_outside_downline_percentage"
|
|
513
|
+
]) {
|
|
514
|
+
const raw = String(u[attr] ?? "").trim();
|
|
515
|
+
if (raw) {
|
|
516
|
+
const n = Number.parseFloat(raw);
|
|
517
|
+
if (Number.isNaN(n) || n < 0 || n > 100) add(attr, "Must be a number between 0 and 100");
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
if (u["sells_health_supplements"] === true) {
|
|
522
|
+
for (const attr of HEALTH_TEXT) if (!filled(u[attr])) add(attr);
|
|
523
|
+
if (!(INGREDIENT_IDS.some(({ id }) => u[`contains_${id}`] === true) || u["contains_none_listed"] === true)) add("contains_none_listed", "Set one contains_* field, or contains_none_listed=true");
|
|
524
|
+
for (const { id, label } of INGREDIENT_IDS) if (u[`contains_${id}`] === true && !filled(u[`${id}_product_names`])) add(`${id}_product_names`, `List the products containing ${label}`);
|
|
525
|
+
if (!(DISEASE_CLAIM_IDS.some(({ id }) => u[`claims_${id}_treatment`] === true) || u["claims_no_diseases"] === true)) add("claims_no_diseases", "Set one claims_* field, or claims_no_diseases=true");
|
|
526
|
+
for (const { id, label } of DISEASE_CLAIM_IDS) if (u[`claims_${id}_treatment`] === true && !filled(u[`${id}_treatment_products`])) add(`${id}_treatment_products`, `List the products claiming to treat ${label}`);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
/** Every required field still missing, in form order. */
|
|
530
|
+
function missingFields(snapshot) {
|
|
531
|
+
const c = new Collector();
|
|
532
|
+
const info = snapshot.info ?? {};
|
|
533
|
+
for (const key of [
|
|
534
|
+
"beneficial_individual_owner",
|
|
535
|
+
"beneficial_business_owner",
|
|
536
|
+
"other_legal_entities",
|
|
537
|
+
"user_has_authority",
|
|
538
|
+
"user_is_signatory"
|
|
539
|
+
]) if (typeof info[key] !== "boolean") c.add("business-type", `info.${key}`, "Answer true or false");
|
|
540
|
+
if (info.beneficial_individual_owner === true && typeof info.user_is_owner !== "boolean") c.add("business-type", "info.user_is_owner", "Answer true or false");
|
|
541
|
+
const primary = primaryEntity(snapshot.entities);
|
|
542
|
+
checkEntity(c, "business-info", primary ?? {}, primary ? { entity: primary.id } : void 0);
|
|
543
|
+
for (const entity of snapshot.entities) if (entity !== primary) checkEntity(c, "legal-entities", entity, { entity: entity.id });
|
|
544
|
+
const primaryAccount = primaryBankAccount(snapshot);
|
|
545
|
+
checkBank(c, primaryAccount ?? {}, primaryAccount ? { bank: primaryAccount.id } : void 0);
|
|
546
|
+
for (const account of snapshot.bankAccounts) if (account !== primaryAccount) checkBank(c, account, { bank: account.id });
|
|
547
|
+
const contact = onboardingContact(snapshot.owners);
|
|
548
|
+
checkOwner(c, "personal-info", contact ?? {}, contact ? { owner: contact.id } : void 0);
|
|
549
|
+
for (const owner of snapshot.owners) if (owner !== contact) checkOwner(c, "relevant-persons", owner, { owner: owner.id });
|
|
550
|
+
const countries = info.countries_info ?? [];
|
|
551
|
+
const entityIds = new Set(snapshot.entities.map((e) => e.id));
|
|
552
|
+
if (countries.length === 0) c.add("countries", "country.<ISO>.currency", "Add at least one country the company sells in");
|
|
553
|
+
for (const entry of countries) {
|
|
554
|
+
const iso = entry.country_iso;
|
|
555
|
+
if (!filled(entry.currency)) c.add("countries", `country.${iso}.currency`, "Currency is required");
|
|
556
|
+
if (!filled(entry.settlement_currency)) c.add("countries", `country.${iso}.settlement_currency`, "Settlement currency is required");
|
|
557
|
+
if (!entry.entity_id || !entityIds.has(entry.entity_id)) c.add("countries", `country.${iso}.entity_id`, "Choose the legal entity that sells there");
|
|
558
|
+
if (entry.entity_legally_registered !== true) c.add("countries", `country.${iso}.entity_legally_registered`, "Confirm the entity is legally registered there (true)");
|
|
559
|
+
}
|
|
560
|
+
checkUnderwriting(c, info.underwriting_info ?? {});
|
|
561
|
+
return c.items;
|
|
562
|
+
}
|
|
563
|
+
function termsAgreed(snapshot) {
|
|
564
|
+
return snapshot.info.terms_and_conditions_info?.terms_and_conditions_agreed === true;
|
|
565
|
+
}
|
|
566
|
+
/** Same rule the server uses to call the form submitted. */
|
|
567
|
+
function isSubmitted(snapshot) {
|
|
568
|
+
if (!termsAgreed(snapshot)) return false;
|
|
569
|
+
const steps = snapshot.info.onboarding_steps ?? {};
|
|
570
|
+
return SERVER_STEP_KEYS.every((key) => Number(steps[key]) === 100);
|
|
571
|
+
}
|
|
572
|
+
function statusOf(snapshot) {
|
|
573
|
+
if (isSubmitted(snapshot)) return "submitted";
|
|
574
|
+
const info = snapshot.info;
|
|
575
|
+
return snapshot.entities.length > 0 || snapshot.bankAccounts.length > 0 || snapshot.owners.length > 0 || typeof info.beneficial_individual_owner === "boolean" || Object.keys(info.underwriting_info ?? {}).length > 0 ? "in_progress" : "not_started";
|
|
576
|
+
}
|
|
577
|
+
function stepReports(snapshot, missing) {
|
|
578
|
+
return STEPS.map((step) => {
|
|
579
|
+
const count = step.id === "terms-and-conditions" ? termsAgreed(snapshot) ? 0 : 1 : missing.filter((m) => m.step === step.id).length;
|
|
580
|
+
return {
|
|
581
|
+
id: step.id,
|
|
582
|
+
name: step.name,
|
|
583
|
+
complete: count === 0,
|
|
584
|
+
missing: count
|
|
585
|
+
};
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
//#endregion
|
|
589
|
+
//#region src/types.ts
|
|
590
|
+
var OnboardingError = class extends Error {
|
|
591
|
+
constructor(message, details) {
|
|
592
|
+
super(message);
|
|
593
|
+
this.details = details;
|
|
594
|
+
this.name = "OnboardingError";
|
|
595
|
+
}
|
|
596
|
+
};
|
|
597
|
+
//#endregion
|
|
598
|
+
//#region src/api.ts
|
|
599
|
+
const readLocalFile = async (path) => {
|
|
600
|
+
let bytes;
|
|
601
|
+
try {
|
|
602
|
+
bytes = await readFile(path);
|
|
603
|
+
} catch {
|
|
604
|
+
throw new OnboardingError(`Can't read ${path}.`);
|
|
605
|
+
}
|
|
606
|
+
return {
|
|
607
|
+
name: basename(path),
|
|
608
|
+
bytes
|
|
609
|
+
};
|
|
610
|
+
};
|
|
611
|
+
/** The company the token acts for. */
|
|
612
|
+
async function currentCompanyId(client) {
|
|
613
|
+
const me = await client.get("/api/company/v1/companies/me");
|
|
614
|
+
const id = me.data?.company?.id ?? me.company?.id;
|
|
615
|
+
if (typeof id !== "number") throw new OnboardingError("Couldn't tell which company this token belongs to.");
|
|
616
|
+
return id;
|
|
617
|
+
}
|
|
618
|
+
const base = (companyId) => `/api/companies/${companyId}`;
|
|
619
|
+
/**
|
|
620
|
+
* The admin's onboarding form for the environment the CLI talks to: the
|
|
621
|
+
* admin host sits beside the API host (api.fluid.app → admin.fluid.app,
|
|
622
|
+
* api.fluid-staging.app → admin.fluid-staging.app). Null for an API host
|
|
623
|
+
* that doesn't follow that pattern, such as a local server.
|
|
624
|
+
*/
|
|
625
|
+
function onboardingFormUrl(apiBase = process.env["FLUID_API_BASE"] ?? "https://api.fluid.app") {
|
|
626
|
+
try {
|
|
627
|
+
const url = new URL(apiBase);
|
|
628
|
+
if (!url.hostname.startsWith("api.")) return null;
|
|
629
|
+
return `${url.protocol}//admin.${url.hostname.slice(4)}/settings/onboarding`;
|
|
630
|
+
} catch {
|
|
631
|
+
return null;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
/** Where a person submits: the form's URL, or where to find it. */
|
|
635
|
+
function onboardingFormLocation() {
|
|
636
|
+
return onboardingFormUrl() ?? `Settings > Onboarding in the admin`;
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* Whether the token is a person's sign-in. Submitting records who accepted
|
|
640
|
+
* the terms, so it needs one: `fluid login` with email does, while a company
|
|
641
|
+
* API token has no person behind it and `/api/me` refuses it.
|
|
642
|
+
*/
|
|
643
|
+
async function signedInAsPerson(client) {
|
|
644
|
+
try {
|
|
645
|
+
await client.get("/api/me");
|
|
646
|
+
return true;
|
|
647
|
+
} catch (error) {
|
|
648
|
+
const status = error.status;
|
|
649
|
+
if (status === 401 || status === 403) return false;
|
|
650
|
+
throw error;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
async function loadSnapshot(client, companyId) {
|
|
654
|
+
const [info, entities, banks, owners] = await Promise.all([
|
|
655
|
+
client.get(`${base(companyId)}/onboarding_info`),
|
|
656
|
+
client.get(`${base(companyId)}/entities`),
|
|
657
|
+
client.get(`${base(companyId)}/bank_accounts`),
|
|
658
|
+
client.get(`${base(companyId)}/owners`)
|
|
659
|
+
]);
|
|
660
|
+
return {
|
|
661
|
+
companyId,
|
|
662
|
+
info: info.company.onboarding_info ?? {},
|
|
663
|
+
version: info.company.onboarding_info_version ?? null,
|
|
664
|
+
entities: entities.entities ?? [],
|
|
665
|
+
bankAccounts: banks.bank_accounts ?? [],
|
|
666
|
+
owners: owners.owners ?? []
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
async function termsUrl(client, companyId) {
|
|
670
|
+
try {
|
|
671
|
+
return (await client.get(`${base(companyId)}/onboarding_info/terms_and_conditions`)).terms_and_conditions?.url ?? null;
|
|
672
|
+
} catch {
|
|
673
|
+
return null;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
/** Per-processor progress after submission. Null when the token can't read
|
|
677
|
+
* payment accounts. */
|
|
678
|
+
async function providerStatuses(client, companyId) {
|
|
679
|
+
try {
|
|
680
|
+
const res = await client.get(`${base(companyId)}/payments_status`);
|
|
681
|
+
return [...res.providers?.psps ?? [], ...res.providers?.apms ?? []].map((p) => ({
|
|
682
|
+
name: p.display_name ?? p.integration_class ?? "unknown",
|
|
683
|
+
type: p.provider_type ?? null,
|
|
684
|
+
onboardingStatus: p.onboarding_status ?? null,
|
|
685
|
+
timeEstimate: p.onboarding_time_estimate ?? null
|
|
686
|
+
}));
|
|
687
|
+
} catch (error) {
|
|
688
|
+
const status = error.status;
|
|
689
|
+
if (status === 401 || status === 403) return null;
|
|
690
|
+
throw error;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
const CONTENT_TYPES = {
|
|
694
|
+
".pdf": "application/pdf",
|
|
695
|
+
".png": "image/png",
|
|
696
|
+
".jpg": "image/jpeg",
|
|
697
|
+
".jpeg": "image/jpeg",
|
|
698
|
+
".heic": "image/heic",
|
|
699
|
+
".webp": "image/webp",
|
|
700
|
+
".doc": "application/msword",
|
|
701
|
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
|
702
|
+
};
|
|
703
|
+
/** Upload one document. Returns the stored reference; the signed URL is
|
|
704
|
+
* never printed. */
|
|
705
|
+
async function uploadDocument(client, companyId, file, documentType) {
|
|
706
|
+
if (file.bytes.byteLength > 10485760) throw new OnboardingError(`${file.name} is over the 10 MB upload limit.`);
|
|
707
|
+
const form = new FormData();
|
|
708
|
+
const type = CONTENT_TYPES[extname(file.name).toLowerCase()] ?? "application/octet-stream";
|
|
709
|
+
form.append("file", new Blob([new Uint8Array(file.bytes)], { type }), file.name);
|
|
710
|
+
form.append("document_type", documentType);
|
|
711
|
+
const res = await client.requestWithFormData(`${base(companyId)}/onboarding_info/upload_document`, form, { method: "POST" });
|
|
712
|
+
return {
|
|
713
|
+
file_url: res.url,
|
|
714
|
+
file_name: res.filename || file.name
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
function parseTarget(flag, raw) {
|
|
718
|
+
if (raw === void 0) return void 0;
|
|
719
|
+
if (raw === "new") return "new";
|
|
720
|
+
const id = Number(raw);
|
|
721
|
+
if (!Number.isInteger(id) || id <= 0) throw new OnboardingError(`${flag} takes a record id or "new".`);
|
|
722
|
+
return id;
|
|
723
|
+
}
|
|
724
|
+
function findOr(records, id, label) {
|
|
725
|
+
const found = records.find((r) => r.id === id);
|
|
726
|
+
if (!found) throw new OnboardingError(`No ${label} with id ${id}. Run \`fluid payments onboarding show\` for the ids.`);
|
|
727
|
+
return found;
|
|
728
|
+
}
|
|
729
|
+
const DOCUMENT_TYPES = {
|
|
730
|
+
government_issued_ids: "government_id",
|
|
731
|
+
mlm_ownership_structure_file: "organizational_chart",
|
|
732
|
+
refund_or_return_policy: "refund_return_policy",
|
|
733
|
+
sample_distributor_agreement: "distributor_agreement",
|
|
734
|
+
income_disclosure_statement: "income_disclosure"
|
|
735
|
+
};
|
|
736
|
+
const documentTypeOf = (attr) => DOCUMENT_TYPES[attr] ?? attr.replace(/_year\d$/, "").replace(/_file$/, "");
|
|
737
|
+
/**
|
|
738
|
+
* Save a plan. Order matters: an entity first, because bank accounts,
|
|
739
|
+
* people, and countries default to the primary entity; the onboarding info
|
|
740
|
+
* last, carrying the revision it was read at.
|
|
741
|
+
*/
|
|
742
|
+
async function applyPlan(client, snapshot, plan, targets, readLocal = readLocalFile) {
|
|
743
|
+
const companyId = snapshot.companyId;
|
|
744
|
+
const saved = [];
|
|
745
|
+
const filesFor = (group) => plan.files.filter((f) => f.field.group === group);
|
|
746
|
+
const entityTouched = Object.keys(plan.entity).length > 0 || filesFor("entity").length > 0;
|
|
747
|
+
const bankTouched = Object.keys(plan.bank).length > 0 || filesFor("bank").length > 0;
|
|
748
|
+
const ownerTouched = Object.keys(plan.owner).length > 0 || filesFor("owner").length > 0;
|
|
749
|
+
let entity;
|
|
750
|
+
if (targets.entity !== "new" && targets.entity !== void 0) entity = findOr(snapshot.entities, targets.entity, "entity");
|
|
751
|
+
else if (targets.entity === void 0) entity = primaryEntity(snapshot.entities);
|
|
752
|
+
if (entityTouched && !entity && typeof plan.entity["legal_name"] !== "string") throw new OnboardingError("Creating an entity needs entity.legal_name.");
|
|
753
|
+
let bank;
|
|
754
|
+
if (targets.bank !== "new" && targets.bank !== void 0) bank = findOr(snapshot.bankAccounts, targets.bank, "bank account");
|
|
755
|
+
else if (targets.bank === void 0) bank = primaryBankAccount(snapshot);
|
|
756
|
+
let owner;
|
|
757
|
+
if (targets.owner !== "new" && targets.owner !== void 0) owner = findOr(snapshot.owners, targets.owner, "person");
|
|
758
|
+
else if (targets.owner === void 0) owner = onboardingContact(snapshot.owners);
|
|
759
|
+
if (ownerTouched && !owner && typeof plan.owner["full_name"] !== "string") throw new OnboardingError("Adding a person needs owner.full_name.");
|
|
760
|
+
if (plan.files.filter((f) => f.key === "owner.government_issued_ids").length > 0 && !targets.idType) throw new OnboardingError("Uploading a government ID needs --id-type passport, license, or national_id.");
|
|
761
|
+
const willHaveEntity = entity !== void 0 || entityTouched || snapshot.entities.length > 0;
|
|
762
|
+
const entityFirst = (what) => new OnboardingError(`Add the legal entity first (--set entity.legal_name=...), then ${what}.`);
|
|
763
|
+
if (bankTouched && !bank && plan.bank["entity_id"] === void 0 && !willHaveEntity) throw entityFirst("add a bank account");
|
|
764
|
+
if (ownerTouched && !owner && plan.owner["entity_id"] === void 0 && !willHaveEntity) throw entityFirst("add a person");
|
|
765
|
+
const existingCountries = snapshot.info.countries_info ?? [];
|
|
766
|
+
const newIsos = Object.keys(plan.countries).filter((iso) => !existingCountries.some((c) => c.country_iso === iso));
|
|
767
|
+
if (newIsos.some((iso) => plan.countries[iso]?.["entity_id"] === void 0) && !willHaveEntity) throw entityFirst("add a country");
|
|
768
|
+
const catalogue = newIsos.length > 0 ? await listCountries(client) : [];
|
|
769
|
+
for (const iso of newIsos) if (!catalogue.some((c) => c.iso.toUpperCase() === iso)) throw new OnboardingError(`${iso} isn't a country Fluid knows. Run \`fluid payments onboarding options countries\`.`);
|
|
770
|
+
const entityPatch = { ...plan.entity };
|
|
771
|
+
const bankPatch = { ...plan.bank };
|
|
772
|
+
const ownerPatch = { ...plan.owner };
|
|
773
|
+
const underwritingPatch = { ...plan.underwriting };
|
|
774
|
+
const existingUnderwriting = snapshot.info.underwriting_info ?? {};
|
|
775
|
+
const patches = {
|
|
776
|
+
entity: {
|
|
777
|
+
patch: entityPatch,
|
|
778
|
+
existing: entity ?? {}
|
|
779
|
+
},
|
|
780
|
+
bank: {
|
|
781
|
+
patch: bankPatch,
|
|
782
|
+
existing: bank ?? {}
|
|
783
|
+
},
|
|
784
|
+
owner: {
|
|
785
|
+
patch: ownerPatch,
|
|
786
|
+
existing: owner ?? {}
|
|
787
|
+
},
|
|
788
|
+
underwriting: {
|
|
789
|
+
patch: underwritingPatch,
|
|
790
|
+
existing: existingUnderwriting
|
|
791
|
+
}
|
|
792
|
+
};
|
|
793
|
+
for (const [attr, link] of Object.entries(plan.underwritingLinks)) underwritingPatch[attr] = {
|
|
794
|
+
...underwritingPatch[attr] ?? existingUnderwriting[attr] ?? {},
|
|
795
|
+
link
|
|
796
|
+
};
|
|
797
|
+
for (const file of plan.files) {
|
|
798
|
+
const attr = attributeOf(file.key);
|
|
799
|
+
const target = patches[file.field.group];
|
|
800
|
+
if (!target) continue;
|
|
801
|
+
const ref = await uploadDocument(client, companyId, await readLocal(file.path), documentTypeOf(attr));
|
|
802
|
+
if (file.field.type === "file") {
|
|
803
|
+
target.patch[`${attr}_file_url`] = ref.file_url;
|
|
804
|
+
target.patch[`${attr}_file_name`] = ref.file_name;
|
|
805
|
+
} else if (file.field.type === "link_or_file") {
|
|
806
|
+
const current = target.patch[attr] ?? target.existing[attr] ?? {};
|
|
807
|
+
target.patch[attr] = {
|
|
808
|
+
...current,
|
|
809
|
+
...ref
|
|
810
|
+
};
|
|
811
|
+
} else {
|
|
812
|
+
const item = attr === "government_issued_ids" ? {
|
|
813
|
+
...ref,
|
|
814
|
+
file_type: targets.idType
|
|
815
|
+
} : ref;
|
|
816
|
+
let current = target.patch[attr] ?? (targets.replaceFiles ? [] : target.existing[attr]) ?? [];
|
|
817
|
+
if (attr === "government_issued_ids" && current.some((c) => c.file_type !== targets.idType)) current = [];
|
|
818
|
+
target.patch[attr] = [...current, item];
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
try {
|
|
822
|
+
if (entityTouched) if (entity) {
|
|
823
|
+
entity = (await client.put(`${base(companyId)}/entities/${entity.id}`, { entity: entityPatch })).entity ?? entity;
|
|
824
|
+
saved.push({
|
|
825
|
+
record: "entity",
|
|
826
|
+
id: entity.id,
|
|
827
|
+
action: "updated"
|
|
828
|
+
});
|
|
829
|
+
} else {
|
|
830
|
+
if (snapshot.entities.length === 0 && entityPatch["primary"] === void 0) entityPatch["primary"] = true;
|
|
831
|
+
entity = (await client.post(`${base(companyId)}/entities`, { entity: entityPatch })).entity;
|
|
832
|
+
saved.push({
|
|
833
|
+
record: "entity",
|
|
834
|
+
id: entity?.id ?? null,
|
|
835
|
+
action: "created"
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
const defaultEntityId = (entity ?? primaryEntity(snapshot.entities))?.id;
|
|
839
|
+
const needEntity = (what) => {
|
|
840
|
+
if (defaultEntityId === void 0) throw new OnboardingError(`Add the legal entity first (--set entity.legal_name=...), then ${what}.`);
|
|
841
|
+
return defaultEntityId;
|
|
842
|
+
};
|
|
843
|
+
if (bankTouched) if (bank) {
|
|
844
|
+
const res = await client.put(`${base(companyId)}/bank_accounts/${bank.id}`, { bank_account: bankPatch });
|
|
845
|
+
saved.push({
|
|
846
|
+
record: "bank",
|
|
847
|
+
id: res.bank_account?.id ?? bank.id,
|
|
848
|
+
action: "updated"
|
|
849
|
+
});
|
|
850
|
+
} else {
|
|
851
|
+
bankPatch["entity_id"] ??= needEntity("add a bank account");
|
|
852
|
+
if (snapshot.bankAccounts.length === 0 && bankPatch["primary"] === void 0) bankPatch["primary"] = true;
|
|
853
|
+
const res = await client.post(`${base(companyId)}/bank_accounts`, { bank_account: bankPatch });
|
|
854
|
+
saved.push({
|
|
855
|
+
record: "bank",
|
|
856
|
+
id: res.bank_account?.id ?? null,
|
|
857
|
+
action: "created"
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
if (ownerTouched) if (owner) {
|
|
861
|
+
const res = await client.put(`${base(companyId)}/owners/${owner.id}`, { owner: ownerPatch });
|
|
862
|
+
saved.push({
|
|
863
|
+
record: "owner",
|
|
864
|
+
id: res.owner?.id ?? owner.id,
|
|
865
|
+
action: "updated"
|
|
866
|
+
});
|
|
867
|
+
} else {
|
|
868
|
+
ownerPatch["entity_id"] ??= needEntity("add a person");
|
|
869
|
+
if (!onboardingContact(snapshot.owners) && ownerPatch["onboarding_contact"] === void 0) ownerPatch["onboarding_contact"] = true;
|
|
870
|
+
const res = await client.post(`${base(companyId)}/owners`, { owner: ownerPatch });
|
|
871
|
+
saved.push({
|
|
872
|
+
record: "owner",
|
|
873
|
+
id: res.owner?.id ?? null,
|
|
874
|
+
action: "created"
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
const info = { ...plan.info };
|
|
878
|
+
if (Object.keys(underwritingPatch).length > 0) info["underwriting_info"] = underwritingPatch;
|
|
879
|
+
if (Object.keys(plan.countries).length > 0) info["countries_info"] = mergeCountries(existingCountries, catalogue, plan.countries, () => needEntity("add a country"));
|
|
880
|
+
if (Object.keys(info).length > 0) {
|
|
881
|
+
await putInfo(client, snapshot, info);
|
|
882
|
+
saved.push({
|
|
883
|
+
record: "onboarding_info",
|
|
884
|
+
id: null,
|
|
885
|
+
action: "updated"
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
} catch (error) {
|
|
889
|
+
if (saved.length === 0) throw error;
|
|
890
|
+
throw partialSave(saved, error);
|
|
891
|
+
}
|
|
892
|
+
return saved;
|
|
893
|
+
}
|
|
894
|
+
/** A write failed after earlier ones in the same `set` were saved. Say
|
|
895
|
+
* exactly what was kept, so a retry doesn't create a record twice. */
|
|
896
|
+
function partialSave(saved, error) {
|
|
897
|
+
const body = error.body;
|
|
898
|
+
const reason = typeof body?.["error_message"] === "string" ? body["error_message"] : error instanceof Error ? error.message : String(error);
|
|
899
|
+
return new OnboardingError(`Partly saved: ${saved.map((r) => `${r.record}${r.id !== null ? ` ${r.id}` : ""} ${r.action}`).join(", ")}. The next write failed: ${reason} Run \`fluid payments onboarding show\`, then retry only what's still missing, targeting created records by id rather than with "new".`, {
|
|
900
|
+
saved,
|
|
901
|
+
failure: {
|
|
902
|
+
...typeof error.status === "number" ? { status: error.status } : {},
|
|
903
|
+
...body?.["errors"] !== void 0 ? { errors: body["errors"] } : {}
|
|
904
|
+
}
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
async function listCountries(client) {
|
|
908
|
+
const res = await client.get("/api/countries");
|
|
909
|
+
return Array.isArray(res) ? res : res.countries ?? [];
|
|
910
|
+
}
|
|
911
|
+
function mergeCountries(existing, catalogue, changes, defaultEntity) {
|
|
912
|
+
const merged = existing.map((c) => ({ ...c }));
|
|
913
|
+
for (const [iso, change] of Object.entries(changes)) {
|
|
914
|
+
let entry = merged.find((c) => c.country_iso === iso);
|
|
915
|
+
if (!entry) {
|
|
916
|
+
const country = catalogue.find((c) => c.iso.toUpperCase() === iso);
|
|
917
|
+
if (!country) throw new OnboardingError(`${iso} isn't a country Fluid knows. Run \`fluid payments onboarding options countries\`.`);
|
|
918
|
+
entry = {
|
|
919
|
+
country_id: country.id,
|
|
920
|
+
country_iso: iso,
|
|
921
|
+
entity_id: typeof change["entity_id"] === "number" ? change["entity_id"] : defaultEntity(),
|
|
922
|
+
currency: country.currency_code ?? "",
|
|
923
|
+
settlement_currency: "",
|
|
924
|
+
entity_legally_registered: false
|
|
925
|
+
};
|
|
926
|
+
merged.push(entry);
|
|
927
|
+
}
|
|
928
|
+
Object.assign(entry, change);
|
|
929
|
+
}
|
|
930
|
+
return merged;
|
|
931
|
+
}
|
|
932
|
+
async function putInfo(client, snapshot, info) {
|
|
933
|
+
try {
|
|
934
|
+
await client.put(`${base(snapshot.companyId)}/onboarding_info`, {
|
|
935
|
+
...snapshot.version !== null ? { onboarding_info_version: snapshot.version } : {},
|
|
936
|
+
onboarding_info: info
|
|
937
|
+
});
|
|
938
|
+
} catch (error) {
|
|
939
|
+
if (error.status === 409) throw new OnboardingError("Someone else changed the onboarding form since it was read. Run the command again.");
|
|
940
|
+
throw error;
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
/** The step keys the admin app records as complete, in its own spelling. */
|
|
944
|
+
const COMPLETED_STEP_KEYS = [
|
|
945
|
+
"basic_info",
|
|
946
|
+
"business_info",
|
|
947
|
+
"legal_entities",
|
|
948
|
+
"bank_accounts_i",
|
|
949
|
+
"bank_accounts_ii",
|
|
950
|
+
"personal_info",
|
|
951
|
+
"relevant_persons",
|
|
952
|
+
"countries",
|
|
953
|
+
"compliance",
|
|
954
|
+
"terms_and_conditions"
|
|
955
|
+
];
|
|
956
|
+
/** Refuse while required fields are missing, naming each one. */
|
|
957
|
+
function assertComplete(snapshot) {
|
|
958
|
+
const missing = missingFields(snapshot);
|
|
959
|
+
if (missing.length > 0) throw new OnboardingError(`${missing.length} required field(s) are still missing. Run \`fluid payments onboarding show\`.`, missing.map((m) => ({
|
|
960
|
+
field: m.field,
|
|
961
|
+
...m.target ? { target: m.target } : {},
|
|
962
|
+
message: m.message
|
|
963
|
+
})));
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* Submit: mark every step complete and accept the terms, which is the
|
|
967
|
+
* moment the server counts the form as submitted. Refuses while anything is
|
|
968
|
+
* missing.
|
|
969
|
+
*/
|
|
970
|
+
async function submitOnboarding(client, snapshot) {
|
|
971
|
+
if (isSubmitted(snapshot)) return "already_submitted";
|
|
972
|
+
assertComplete(snapshot);
|
|
973
|
+
const steps = Object.fromEntries(SERVER_STEP_KEYS.map((key) => [key, 100]));
|
|
974
|
+
steps["terms_and_conditions"] = 100;
|
|
975
|
+
await putInfo(client, snapshot, {
|
|
976
|
+
onboarding_steps: steps,
|
|
977
|
+
kyc_flow_current_step: "terms_and_conditions",
|
|
978
|
+
kyc_flow_completed_steps: COMPLETED_STEP_KEYS.join(","),
|
|
979
|
+
terms_and_conditions_info: { terms_and_conditions_agreed: true }
|
|
980
|
+
});
|
|
981
|
+
return "submitted";
|
|
982
|
+
}
|
|
983
|
+
async function removeRecord(client, snapshot, record, id) {
|
|
984
|
+
const companyId = snapshot.companyId;
|
|
985
|
+
if (record === "country") {
|
|
986
|
+
const iso = id.toUpperCase();
|
|
987
|
+
const existing = snapshot.info.countries_info ?? [];
|
|
988
|
+
if (!existing.some((c) => c.country_iso === iso)) throw new OnboardingError(`${iso} isn't on the form.`);
|
|
989
|
+
await putInfo(client, snapshot, { countries_info: existing.filter((c) => c.country_iso !== iso) });
|
|
990
|
+
return {
|
|
991
|
+
removed: "country",
|
|
992
|
+
id: iso
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
const numeric = Number(id);
|
|
996
|
+
if (!Number.isInteger(numeric) || numeric <= 0) throw new OnboardingError(`"${id}" isn't a record id.`);
|
|
997
|
+
const path = {
|
|
998
|
+
entity: "entities",
|
|
999
|
+
bank: "bank_accounts",
|
|
1000
|
+
owner: "owners"
|
|
1001
|
+
}[record];
|
|
1002
|
+
const list = {
|
|
1003
|
+
entity: snapshot.entities,
|
|
1004
|
+
bank: snapshot.bankAccounts,
|
|
1005
|
+
owner: snapshot.owners
|
|
1006
|
+
}[record];
|
|
1007
|
+
findOr(list, numeric, record === "bank" ? "bank account" : record === "owner" ? "person" : "entity");
|
|
1008
|
+
await client.delete(`${base(companyId)}/${path}/${numeric}`);
|
|
1009
|
+
return {
|
|
1010
|
+
removed: record,
|
|
1011
|
+
id: String(numeric)
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
1014
|
+
const OPTION_LISTS = [
|
|
1015
|
+
"business-types",
|
|
1016
|
+
"mcc-codes",
|
|
1017
|
+
"countries",
|
|
1018
|
+
"currencies"
|
|
1019
|
+
];
|
|
1020
|
+
async function listOptions(client, list, country) {
|
|
1021
|
+
switch (list) {
|
|
1022
|
+
case "business-types": return (await client.get("/api/business_types", country ? { country_code: country.toUpperCase() } : void 0)).business_types ?? [];
|
|
1023
|
+
case "mcc-codes": return (await client.get("/api/mcc_codes")).mcc_codes ?? [];
|
|
1024
|
+
case "countries": return (await listCountries(client)).map((c) => ({
|
|
1025
|
+
iso: c.iso,
|
|
1026
|
+
name: c.name ?? null,
|
|
1027
|
+
currency: c.currency_code ?? null
|
|
1028
|
+
}));
|
|
1029
|
+
case "currencies": return (await client.get("/api/currencies")).currencies ?? [];
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
//#endregion
|
|
1033
|
+
//#region src/mask.ts
|
|
1034
|
+
/** Keep only the last four characters, e.g. `*****6789`. Short values and
|
|
1035
|
+
* dates are hidden entirely. */
|
|
1036
|
+
function maskValue(value) {
|
|
1037
|
+
if (value === null || value === void 0 || value === "") return value;
|
|
1038
|
+
const text = String(value);
|
|
1039
|
+
if (/^\d{4}-\d{2}-\d{2}/.test(text)) return "****-**-**";
|
|
1040
|
+
const visible = text.replace(/[^A-Za-z0-9]/g, "");
|
|
1041
|
+
if (visible.length <= 4) return "****";
|
|
1042
|
+
return `${"*".repeat(Math.min(visible.length - 4, 8))}${visible.slice(-4)}`;
|
|
1043
|
+
}
|
|
1044
|
+
/** Mask a value when its `--set` key is a sensitive field. */
|
|
1045
|
+
function maskFor(key, value) {
|
|
1046
|
+
return isSensitive(key) ? maskValue(value) : value;
|
|
1047
|
+
}
|
|
1048
|
+
//#endregion
|
|
1049
|
+
//#region src/assign.ts
|
|
1050
|
+
function emptyPlan() {
|
|
1051
|
+
return {
|
|
1052
|
+
info: {},
|
|
1053
|
+
underwriting: {},
|
|
1054
|
+
underwritingLinks: {},
|
|
1055
|
+
entity: {},
|
|
1056
|
+
bank: {},
|
|
1057
|
+
owner: {},
|
|
1058
|
+
countries: {},
|
|
1059
|
+
files: []
|
|
1060
|
+
};
|
|
1061
|
+
}
|
|
1062
|
+
function isEmptyPlan(plan) {
|
|
1063
|
+
return plan.files.length === 0 && [
|
|
1064
|
+
plan.info,
|
|
1065
|
+
plan.underwriting,
|
|
1066
|
+
plan.underwritingLinks,
|
|
1067
|
+
plan.entity,
|
|
1068
|
+
plan.bank,
|
|
1069
|
+
plan.owner,
|
|
1070
|
+
plan.countries
|
|
1071
|
+
].every((part) => Object.keys(part).length === 0);
|
|
1072
|
+
}
|
|
1073
|
+
function splitAssignment(raw, flag) {
|
|
1074
|
+
const eq = raw.indexOf("=");
|
|
1075
|
+
if (eq <= 0) throw new OnboardingError(`${flag} expects key=value, got "${raw.split("=")[0]}".`);
|
|
1076
|
+
return [raw.slice(0, eq).trim(), raw.slice(eq + 1)];
|
|
1077
|
+
}
|
|
1078
|
+
const unknownKey = (key) => new OnboardingError(`Unknown field "${key}". Run \`fluid payments onboarding fields\` for the list.`);
|
|
1079
|
+
/** Coerce a raw string to the field's type. Never quotes a sensitive value
|
|
1080
|
+
* back in an error. */
|
|
1081
|
+
function coerce(field, key, raw) {
|
|
1082
|
+
const value = raw.trim();
|
|
1083
|
+
const bad = (expected) => new OnboardingError(`${key} must be ${expected}${field.sensitive ? "" : `, got "${value}"`}.`);
|
|
1084
|
+
switch (field.type) {
|
|
1085
|
+
case "boolean":
|
|
1086
|
+
if (/^(true|yes)$/i.test(value)) return true;
|
|
1087
|
+
if (/^(false|no)$/i.test(value)) return false;
|
|
1088
|
+
throw bad("true or false");
|
|
1089
|
+
case "number":
|
|
1090
|
+
case "percent": {
|
|
1091
|
+
const n = Number(value);
|
|
1092
|
+
if (value === "" || Number.isNaN(n)) throw bad("a number");
|
|
1093
|
+
if (field.type === "percent" && (n < 0 || n > 100)) throw bad("between 0 and 100");
|
|
1094
|
+
return field.sendAsText ? String(n) : n;
|
|
1095
|
+
}
|
|
1096
|
+
case "mcc": {
|
|
1097
|
+
const n = Number(value);
|
|
1098
|
+
if (!Number.isInteger(n) || n <= 0) throw bad("an MCC value from `options mcc-codes`");
|
|
1099
|
+
return n;
|
|
1100
|
+
}
|
|
1101
|
+
case "country":
|
|
1102
|
+
if (!/^[A-Za-z]{2}$/.test(value)) throw bad("a two-letter country code");
|
|
1103
|
+
return value.toUpperCase();
|
|
1104
|
+
case "currency":
|
|
1105
|
+
if (!/^[A-Za-z]{3}$/.test(value)) throw bad("a three-letter currency code");
|
|
1106
|
+
return value.toUpperCase();
|
|
1107
|
+
case "date":
|
|
1108
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || Number.isNaN(Date.parse(value))) throw bad("a date as YYYY-MM-DD");
|
|
1109
|
+
return value;
|
|
1110
|
+
case "email":
|
|
1111
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) throw bad("an email address");
|
|
1112
|
+
return value;
|
|
1113
|
+
case "url":
|
|
1114
|
+
if (!/^https?:\/\/\S+$/.test(value)) throw bad("a URL starting with http:// or https://");
|
|
1115
|
+
return value;
|
|
1116
|
+
case "phone":
|
|
1117
|
+
if (!/^[\d\s\-+()]{8,}$/.test(value)) throw bad("a phone number with at least 8 digits");
|
|
1118
|
+
return value;
|
|
1119
|
+
case "enum":
|
|
1120
|
+
if (!field.options?.includes(value)) throw bad(`one of ${field.options?.join(", ")}`);
|
|
1121
|
+
return value;
|
|
1122
|
+
case "yes_no":
|
|
1123
|
+
if (!/^(yes|no)\b/i.test(value)) throw bad("\"Yes\", \"No\", or \"Yes - details\"");
|
|
1124
|
+
return value;
|
|
1125
|
+
case "file":
|
|
1126
|
+
case "files":
|
|
1127
|
+
case "link_or_file": throw new OnboardingError(`${key} is a document. Upload it with --file ${key}=<path>.`);
|
|
1128
|
+
default:
|
|
1129
|
+
if (value === "") throw bad("non-empty");
|
|
1130
|
+
return value;
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
/** Parse `--set` and `--file` flags into a plan. */
|
|
1134
|
+
function buildPlan(sets, files) {
|
|
1135
|
+
const plan = emptyPlan();
|
|
1136
|
+
for (const raw of sets) {
|
|
1137
|
+
const [key, value] = splitAssignment(raw, "--set");
|
|
1138
|
+
const link = /^underwriting\.(\w+)\.link$/.exec(key);
|
|
1139
|
+
if (link) {
|
|
1140
|
+
if (fieldFor(`underwriting.${link[1]}`)?.type !== "link_or_file") throw unknownKey(key);
|
|
1141
|
+
const url = value.trim();
|
|
1142
|
+
if (!/^https?:\/\/\S+$/.test(url)) throw new OnboardingError(`${key} must be a URL starting with http:// or https://.`);
|
|
1143
|
+
plan.underwritingLinks[link[1]] = url;
|
|
1144
|
+
continue;
|
|
1145
|
+
}
|
|
1146
|
+
const field = fieldFor(key);
|
|
1147
|
+
if (!field) throw unknownKey(key);
|
|
1148
|
+
const coerced = coerce(field, key, value);
|
|
1149
|
+
const country = /^country\.([A-Za-z]{2})\.(\w+)$/.exec(key);
|
|
1150
|
+
if (country) {
|
|
1151
|
+
const iso = country[1].toUpperCase();
|
|
1152
|
+
(plan.countries[iso] ??= {})[country[2]] = coerced;
|
|
1153
|
+
continue;
|
|
1154
|
+
}
|
|
1155
|
+
const group = field.group;
|
|
1156
|
+
plan[group][attributeOf(key)] = coerced;
|
|
1157
|
+
}
|
|
1158
|
+
for (const raw of files) {
|
|
1159
|
+
const [key, path] = splitAssignment(raw, "--file");
|
|
1160
|
+
const field = fieldFor(key);
|
|
1161
|
+
if (!field || ![
|
|
1162
|
+
"file",
|
|
1163
|
+
"files",
|
|
1164
|
+
"link_or_file"
|
|
1165
|
+
].includes(field.type)) throw new OnboardingError(field ? `${key} isn't a document; use --set.` : `Unknown document "${key}". Run \`fluid payments onboarding fields\`.`);
|
|
1166
|
+
if (!path.trim()) throw new OnboardingError(`--file ${key} needs a path.`);
|
|
1167
|
+
plan.files.push({
|
|
1168
|
+
key,
|
|
1169
|
+
field,
|
|
1170
|
+
path: path.trim()
|
|
1171
|
+
});
|
|
1172
|
+
}
|
|
1173
|
+
return plan;
|
|
1174
|
+
}
|
|
1175
|
+
function parseIdType(raw) {
|
|
1176
|
+
if (raw === void 0) return void 0;
|
|
1177
|
+
if (!ID_TYPES.includes(raw)) throw new OnboardingError(`--id-type must be one of ${ID_TYPES.join(", ")}.`);
|
|
1178
|
+
return raw;
|
|
1179
|
+
}
|
|
1180
|
+
/** What `set` changed, safe to print: sensitive values masked, documents by
|
|
1181
|
+
* file name only. */
|
|
1182
|
+
function describePlan(plan) {
|
|
1183
|
+
const out = {};
|
|
1184
|
+
const add = (group, part) => {
|
|
1185
|
+
for (const [attr, value] of Object.entries(part)) {
|
|
1186
|
+
const key = `${group}.${attr}`;
|
|
1187
|
+
out[key] = maskFor(key, value);
|
|
1188
|
+
}
|
|
1189
|
+
};
|
|
1190
|
+
add("info", plan.info);
|
|
1191
|
+
add("underwriting", plan.underwriting);
|
|
1192
|
+
for (const [attr, url] of Object.entries(plan.underwritingLinks)) out[`underwriting.${attr}.link`] = url;
|
|
1193
|
+
add("entity", plan.entity);
|
|
1194
|
+
add("bank", plan.bank);
|
|
1195
|
+
add("owner", plan.owner);
|
|
1196
|
+
for (const [iso, part] of Object.entries(plan.countries)) add(`country.${iso}`, part);
|
|
1197
|
+
for (const file of plan.files) {
|
|
1198
|
+
const name = file.path.split(/[\\/]/).pop();
|
|
1199
|
+
const prior = out[file.key];
|
|
1200
|
+
out[file.key] = prior ? [prior, `uploaded ${name}`].flat() : `uploaded ${name}`;
|
|
1201
|
+
}
|
|
1202
|
+
return out;
|
|
1203
|
+
}
|
|
1204
|
+
//#endregion
|
|
1205
|
+
//#region src/summary.ts
|
|
1206
|
+
function summarize(snapshot) {
|
|
1207
|
+
const missing = missingFields(snapshot);
|
|
1208
|
+
const status = statusOf(snapshot);
|
|
1209
|
+
return {
|
|
1210
|
+
companyId: snapshot.companyId,
|
|
1211
|
+
status,
|
|
1212
|
+
submitted: status === "submitted",
|
|
1213
|
+
termsAgreed: termsAgreed(snapshot),
|
|
1214
|
+
readyToSubmit: missing.length === 0 && status !== "submitted",
|
|
1215
|
+
missingCount: missing.length,
|
|
1216
|
+
steps: stepReports(snapshot, missing),
|
|
1217
|
+
missing,
|
|
1218
|
+
records: {
|
|
1219
|
+
entities: snapshot.entities.map((e) => ({
|
|
1220
|
+
id: e.id,
|
|
1221
|
+
legal_name: e.legal_name ?? null,
|
|
1222
|
+
primary: e.primary === true,
|
|
1223
|
+
status: e.status ?? null,
|
|
1224
|
+
country_iso: e.country_iso ?? null,
|
|
1225
|
+
business_identification_number: maskValue(e["business_identification_number"] ?? null)
|
|
1226
|
+
})),
|
|
1227
|
+
bankAccounts: snapshot.bankAccounts.map((b) => ({
|
|
1228
|
+
id: b.id,
|
|
1229
|
+
entity_id: b.entity_id,
|
|
1230
|
+
bank_name: b["bank_name"] ?? null,
|
|
1231
|
+
primary: b.primary === true,
|
|
1232
|
+
country_iso: b.country_iso ?? null,
|
|
1233
|
+
currency_iso: b["currency_iso"] ?? null,
|
|
1234
|
+
account_number: maskValue(b["account_number"] ?? null)
|
|
1235
|
+
})),
|
|
1236
|
+
people: snapshot.owners.map((o) => ({
|
|
1237
|
+
id: o.id,
|
|
1238
|
+
entity_id: o.entity_id ?? null,
|
|
1239
|
+
full_name: o.full_name ?? null,
|
|
1240
|
+
position: o["position"] ?? null,
|
|
1241
|
+
onboarding_contact: o.onboarding_contact === true,
|
|
1242
|
+
is_beneficial_owner: o.is_beneficial_owner === true,
|
|
1243
|
+
percent_ownership: o.percent_ownership ?? null
|
|
1244
|
+
})),
|
|
1245
|
+
countries: (snapshot.info.countries_info ?? []).map((c) => ({
|
|
1246
|
+
country_iso: c.country_iso,
|
|
1247
|
+
currency: c.currency,
|
|
1248
|
+
settlement_currency: c.settlement_currency,
|
|
1249
|
+
entity_id: c.entity_id,
|
|
1250
|
+
entity_legally_registered: c.entity_legally_registered
|
|
1251
|
+
}))
|
|
1252
|
+
}
|
|
1253
|
+
};
|
|
1254
|
+
}
|
|
1255
|
+
//#endregion
|
|
1256
|
+
//#region src/walk.ts
|
|
1257
|
+
const slotKey = (slot) => `${slot.field}|${JSON.stringify(slot.target ?? {})}`;
|
|
1258
|
+
const stepName = (id) => STEPS.find((s) => s.id === id)?.name ?? id;
|
|
1259
|
+
const fromMissing = (m) => ({
|
|
1260
|
+
field: m.field,
|
|
1261
|
+
...m.target ? { target: m.target } : {},
|
|
1262
|
+
reason: m.message
|
|
1263
|
+
});
|
|
1264
|
+
function recordFor(snapshot, slot) {
|
|
1265
|
+
const group = slot.field.split(".")[0];
|
|
1266
|
+
const t = slot.target ?? {};
|
|
1267
|
+
if (group === "entity") return t.entity ? snapshot.entities.find((e) => e.id === t.entity) : primaryEntity(snapshot.entities);
|
|
1268
|
+
if (group === "bank") return t.bank ? snapshot.bankAccounts.find((b) => b.id === t.bank) : primaryBankAccount(snapshot);
|
|
1269
|
+
if (group === "owner") return t.owner ? snapshot.owners.find((o) => o.id === t.owner) : onboardingContact(snapshot.owners);
|
|
1270
|
+
}
|
|
1271
|
+
/** The saved value for a slot, or undefined when there's none. */
|
|
1272
|
+
function savedValue(snapshot, slot) {
|
|
1273
|
+
const [group, ...path] = slot.field.split(".");
|
|
1274
|
+
const info = snapshot.info ?? {};
|
|
1275
|
+
let node;
|
|
1276
|
+
if (group === "info") node = info;
|
|
1277
|
+
else if (group === "underwriting") node = info.underwriting_info ?? {};
|
|
1278
|
+
else if (group === "country") {
|
|
1279
|
+
const iso = path.shift()?.toUpperCase();
|
|
1280
|
+
node = (info.countries_info ?? []).find((c) => c.country_iso === iso);
|
|
1281
|
+
} else node = recordFor(snapshot, slot);
|
|
1282
|
+
for (const part of path) {
|
|
1283
|
+
if (node === null || typeof node !== "object") return void 0;
|
|
1284
|
+
node = node[part];
|
|
1285
|
+
}
|
|
1286
|
+
return node === null || node === "" ? void 0 : node;
|
|
1287
|
+
}
|
|
1288
|
+
/** A saved value as one short line, masked when sensitive. */
|
|
1289
|
+
function display(key, value) {
|
|
1290
|
+
if (value === void 0) return void 0;
|
|
1291
|
+
const masked = maskFor(key, value);
|
|
1292
|
+
if (typeof masked === "boolean") return masked ? "Yes" : "No";
|
|
1293
|
+
if (Array.isArray(masked)) return masked.map((v) => display(key, v) ?? "").filter(Boolean).join(", ");
|
|
1294
|
+
if (masked && typeof masked === "object") {
|
|
1295
|
+
const ref = masked;
|
|
1296
|
+
return ref.link ?? ref.file_name ?? void 0;
|
|
1297
|
+
}
|
|
1298
|
+
return String(masked);
|
|
1299
|
+
}
|
|
1300
|
+
function optionSource(api, snapshot, field) {
|
|
1301
|
+
switch (field.type) {
|
|
1302
|
+
case "country": return async () => (await listCountries(api)).map((c) => ({
|
|
1303
|
+
title: c.name ? `${c.name} (${c.iso})` : c.iso,
|
|
1304
|
+
value: c.iso
|
|
1305
|
+
}));
|
|
1306
|
+
case "currency": return async () => (await listOptions(api, "currencies")).filter((c) => c.code).map((c) => ({
|
|
1307
|
+
title: `${c.code} — ${c.name ?? ""}`,
|
|
1308
|
+
value: c.code
|
|
1309
|
+
}));
|
|
1310
|
+
case "mcc": return async () => (await listOptions(api, "mcc-codes")).filter((c) => c.value !== void 0).map((c) => ({
|
|
1311
|
+
title: `${c.name} (${c.value})`,
|
|
1312
|
+
value: String(c.value)
|
|
1313
|
+
}));
|
|
1314
|
+
case "business_type": {
|
|
1315
|
+
const country = primaryEntity(snapshot.entities)?.country_iso ?? void 0;
|
|
1316
|
+
return async () => (await listOptions(api, "business-types", country ?? void 0)).filter((c) => c.value).map((c) => ({
|
|
1317
|
+
title: c.name ?? c.value,
|
|
1318
|
+
value: c.value
|
|
1319
|
+
}));
|
|
1320
|
+
}
|
|
1321
|
+
default: return;
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
const TYPE_HINTS = {
|
|
1325
|
+
date: "YYYY-MM-DD",
|
|
1326
|
+
percent: "0 to 100",
|
|
1327
|
+
email: "name@example.com",
|
|
1328
|
+
url: "https://…",
|
|
1329
|
+
phone: "with country code",
|
|
1330
|
+
yes_no: "Yes, No, or \"Yes - details\"",
|
|
1331
|
+
file: "path to the file",
|
|
1332
|
+
files: "path to the file",
|
|
1333
|
+
link_or_file: "a https:// link or a file path"
|
|
1334
|
+
};
|
|
1335
|
+
async function questionFor(slot, field, current, options, section, canGoBack) {
|
|
1336
|
+
const base = {
|
|
1337
|
+
section,
|
|
1338
|
+
message: field.label,
|
|
1339
|
+
current,
|
|
1340
|
+
canGoBack
|
|
1341
|
+
};
|
|
1342
|
+
if (field.type === "boolean") return {
|
|
1343
|
+
...base,
|
|
1344
|
+
kind: "select",
|
|
1345
|
+
choices: [{
|
|
1346
|
+
title: "Yes",
|
|
1347
|
+
value: "true"
|
|
1348
|
+
}, {
|
|
1349
|
+
title: "No",
|
|
1350
|
+
value: "false"
|
|
1351
|
+
}]
|
|
1352
|
+
};
|
|
1353
|
+
if (field.type === "enum" && field.options) return {
|
|
1354
|
+
...base,
|
|
1355
|
+
kind: "select",
|
|
1356
|
+
choices: field.options.map((o) => ({
|
|
1357
|
+
title: o,
|
|
1358
|
+
value: o
|
|
1359
|
+
}))
|
|
1360
|
+
};
|
|
1361
|
+
if (options && options.length > 0) return {
|
|
1362
|
+
...base,
|
|
1363
|
+
kind: "search",
|
|
1364
|
+
choices: options,
|
|
1365
|
+
hint: "type to filter"
|
|
1366
|
+
};
|
|
1367
|
+
const hint = [TYPE_HINTS[field.type], slot.reason].filter(Boolean).join(" · ");
|
|
1368
|
+
return {
|
|
1369
|
+
...base,
|
|
1370
|
+
kind: field.sensitive ? "secret" : "text",
|
|
1371
|
+
...hint ? { hint } : {}
|
|
1372
|
+
};
|
|
1373
|
+
}
|
|
1374
|
+
async function walkOnboarding(deps, opts = {}) {
|
|
1375
|
+
const { api, companyId, prompter } = deps;
|
|
1376
|
+
let snapshot = await loadSnapshot(api, companyId);
|
|
1377
|
+
const history = [];
|
|
1378
|
+
const skipped = /* @__PURE__ */ new Set();
|
|
1379
|
+
const optionCache = /* @__PURE__ */ new Map();
|
|
1380
|
+
let focus = opts.step;
|
|
1381
|
+
let forced;
|
|
1382
|
+
let answered = 0;
|
|
1383
|
+
let submitted = false;
|
|
1384
|
+
let submitUrl;
|
|
1385
|
+
prompter.note(NAV_HELP);
|
|
1386
|
+
const options = async (field) => {
|
|
1387
|
+
const source = optionSource(api, snapshot, field);
|
|
1388
|
+
if (!source) return void 0;
|
|
1389
|
+
const cacheKey = field.type === "business_type" ? `${field.type}:${primaryEntity(snapshot.entities)?.country_iso ?? ""}` : field.type;
|
|
1390
|
+
if (!optionCache.has(cacheKey)) try {
|
|
1391
|
+
optionCache.set(cacheKey, await source());
|
|
1392
|
+
} catch {
|
|
1393
|
+
optionCache.set(cacheKey, []);
|
|
1394
|
+
}
|
|
1395
|
+
return optionCache.get(cacheKey);
|
|
1396
|
+
};
|
|
1397
|
+
const countryChoices = async () => {
|
|
1398
|
+
if (!optionCache.has("country-pick")) try {
|
|
1399
|
+
optionCache.set("country-pick", (await listCountries(api)).map((c) => ({
|
|
1400
|
+
title: c.name ? `${c.name} (${c.iso})` : c.iso,
|
|
1401
|
+
value: c.iso
|
|
1402
|
+
})));
|
|
1403
|
+
} catch {
|
|
1404
|
+
optionCache.set("country-pick", []);
|
|
1405
|
+
}
|
|
1406
|
+
return optionCache.get("country-pick");
|
|
1407
|
+
};
|
|
1408
|
+
const goBack = () => {
|
|
1409
|
+
const previous = history.pop();
|
|
1410
|
+
if (!previous) {
|
|
1411
|
+
prompter.note("This is the first question.");
|
|
1412
|
+
return false;
|
|
1413
|
+
}
|
|
1414
|
+
skipped.delete(slotKey(previous));
|
|
1415
|
+
forced = previous;
|
|
1416
|
+
return true;
|
|
1417
|
+
};
|
|
1418
|
+
/** Save one answer. False (after saying why) when it was refused. */
|
|
1419
|
+
const save = async (slot, field, raw) => {
|
|
1420
|
+
const value = raw.trim();
|
|
1421
|
+
let idType;
|
|
1422
|
+
try {
|
|
1423
|
+
let plan;
|
|
1424
|
+
if (field.type === "link_or_file" && /^https?:\/\//i.test(value)) plan = buildPlan([`${slot.field}.link=${value}`], []);
|
|
1425
|
+
else if ([
|
|
1426
|
+
"file",
|
|
1427
|
+
"files",
|
|
1428
|
+
"link_or_file"
|
|
1429
|
+
].includes(field.type)) {
|
|
1430
|
+
plan = buildPlan([], [`${slot.field}=${value}`]);
|
|
1431
|
+
if (slot.field === "owner.government_issued_ids") {
|
|
1432
|
+
const choice = await prompter.menu("Which kind of ID is it?", ID_TYPES.map((t) => ({
|
|
1433
|
+
title: t.replace("_", " "),
|
|
1434
|
+
value: t
|
|
1435
|
+
})));
|
|
1436
|
+
if (!choice) return false;
|
|
1437
|
+
idType = choice;
|
|
1438
|
+
}
|
|
1439
|
+
} else plan = buildPlan([`${slot.field}=${value}`], []);
|
|
1440
|
+
await applyPlan(api, snapshot, plan, {
|
|
1441
|
+
entity: slot.target?.entity,
|
|
1442
|
+
bank: slot.target?.bank,
|
|
1443
|
+
owner: slot.target?.owner,
|
|
1444
|
+
idType
|
|
1445
|
+
}, deps.readLocal);
|
|
1446
|
+
} catch (error) {
|
|
1447
|
+
prompter.note(`Not saved: ${error instanceof Error ? error.message : String(error)}`);
|
|
1448
|
+
return false;
|
|
1449
|
+
}
|
|
1450
|
+
snapshot = await loadSnapshot(api, companyId);
|
|
1451
|
+
return true;
|
|
1452
|
+
};
|
|
1453
|
+
/** Pick a field (and a record, when there are several) in a step. */
|
|
1454
|
+
const pickInStep = async (step) => {
|
|
1455
|
+
const slots = FIELDS.filter((f) => f.step === step && !f.key.includes("<ISO>")).map((f) => ({ field: f.key }));
|
|
1456
|
+
for (const country of snapshot.info.countries_info ?? []) for (const f of FIELDS.filter((x) => x.step === step && x.key.includes("<ISO>"))) slots.push({ field: f.key.replace("<ISO>", country.country_iso) });
|
|
1457
|
+
if (slots.length === 0) {
|
|
1458
|
+
prompter.note(`${stepName(step)} has nothing to change here.`);
|
|
1459
|
+
return null;
|
|
1460
|
+
}
|
|
1461
|
+
const group = slots[0].field.split(".")[0];
|
|
1462
|
+
let target;
|
|
1463
|
+
const records = group === "entity" ? snapshot.entities : group === "bank" ? snapshot.bankAccounts : group === "owner" ? snapshot.owners : [];
|
|
1464
|
+
if (records.length > 1) {
|
|
1465
|
+
const id = await prompter.menu("Which one?", records.map((r) => ({
|
|
1466
|
+
title: String(r.legal_name ?? r.full_name ?? r.country_iso ?? `#${r.id}`),
|
|
1467
|
+
value: String(r.id)
|
|
1468
|
+
})));
|
|
1469
|
+
if (!id) return null;
|
|
1470
|
+
target = { [group]: Number(id) };
|
|
1471
|
+
}
|
|
1472
|
+
const choice = await prompter.menu(`${stepName(step)}: which answer?`, slots.map((s, i) => {
|
|
1473
|
+
const def = fieldFor(s.field);
|
|
1474
|
+
const saved = display(s.field, savedValue(snapshot, {
|
|
1475
|
+
...s,
|
|
1476
|
+
target
|
|
1477
|
+
}));
|
|
1478
|
+
return {
|
|
1479
|
+
title: `${def.label}${saved ? ` — ${saved}` : " — (empty)"}`,
|
|
1480
|
+
value: String(i)
|
|
1481
|
+
};
|
|
1482
|
+
}));
|
|
1483
|
+
if (choice === null) return null;
|
|
1484
|
+
return {
|
|
1485
|
+
...slots[Number(choice)],
|
|
1486
|
+
...target ? { target } : {}
|
|
1487
|
+
};
|
|
1488
|
+
};
|
|
1489
|
+
/** The menu: jump to a step, change an answer, submit, or stop. */
|
|
1490
|
+
const openMenu = async () => {
|
|
1491
|
+
const missing = missingFields(snapshot);
|
|
1492
|
+
const action = await prompter.menu("What next?", [
|
|
1493
|
+
{
|
|
1494
|
+
title: "Continue",
|
|
1495
|
+
value: "continue"
|
|
1496
|
+
},
|
|
1497
|
+
{
|
|
1498
|
+
title: "Back to the previous question",
|
|
1499
|
+
value: "back",
|
|
1500
|
+
disabled: history.length === 0
|
|
1501
|
+
},
|
|
1502
|
+
{
|
|
1503
|
+
title: "Go to a step",
|
|
1504
|
+
value: "step"
|
|
1505
|
+
},
|
|
1506
|
+
{
|
|
1507
|
+
title: "Add a country",
|
|
1508
|
+
value: "country"
|
|
1509
|
+
},
|
|
1510
|
+
{
|
|
1511
|
+
title: "Submit",
|
|
1512
|
+
value: "submit",
|
|
1513
|
+
disabled: missing.length > 0
|
|
1514
|
+
},
|
|
1515
|
+
{
|
|
1516
|
+
title: "Stop for now (answers are saved)",
|
|
1517
|
+
value: "quit"
|
|
1518
|
+
}
|
|
1519
|
+
]);
|
|
1520
|
+
if (action === null || action === "quit") return "quit";
|
|
1521
|
+
if (action === "back") return goBack() ? "moved" : "stay";
|
|
1522
|
+
if (action === "country") {
|
|
1523
|
+
forced = { field: "country.<ISO>.currency" };
|
|
1524
|
+
return "moved";
|
|
1525
|
+
}
|
|
1526
|
+
if (action === "step") {
|
|
1527
|
+
const step = await prompter.menu("Which step?", STEPS.filter((s) => s.id !== "terms-and-conditions").map((s) => {
|
|
1528
|
+
const left = missing.filter((m) => m.step === s.id).length;
|
|
1529
|
+
return {
|
|
1530
|
+
title: `${left === 0 ? "✓" : `${left} left`} ${s.name}`,
|
|
1531
|
+
value: s.id
|
|
1532
|
+
};
|
|
1533
|
+
}));
|
|
1534
|
+
if (step) {
|
|
1535
|
+
if (missing.filter((m) => m.step === step && !skipped.has(slotKey(fromMissing(m)))).length > 0) {
|
|
1536
|
+
focus = step;
|
|
1537
|
+
return "moved";
|
|
1538
|
+
}
|
|
1539
|
+
const slot = await pickInStep(step);
|
|
1540
|
+
if (slot) {
|
|
1541
|
+
forced = slot;
|
|
1542
|
+
return "moved";
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
if (action === "submit" && await offerSubmit()) return "quit";
|
|
1547
|
+
return "stay";
|
|
1548
|
+
};
|
|
1549
|
+
/** True when the walk is over: submitted, or handed to the admin form. */
|
|
1550
|
+
const offerSubmit = async () => {
|
|
1551
|
+
if (!await signedInAsPerson(api)) {
|
|
1552
|
+
submitUrl = onboardingFormUrl() ?? void 0;
|
|
1553
|
+
prompter.note(`Everything is filled in. Submitting records the person who accepts Fluid's terms, so a person has to submit at ${onboardingFormLocation()}, or sign in with \`fluid login\` and run this again.`);
|
|
1554
|
+
return true;
|
|
1555
|
+
}
|
|
1556
|
+
const url = await termsUrl(api, companyId);
|
|
1557
|
+
if (await prompter.menu(`Submitting accepts Fluid's payments terms & conditions for the company${url ? ` (${url})` : ""}.`, [{
|
|
1558
|
+
title: "I've read the terms. Submit",
|
|
1559
|
+
value: "submit"
|
|
1560
|
+
}, {
|
|
1561
|
+
title: "Not now",
|
|
1562
|
+
value: "later"
|
|
1563
|
+
}]) !== "submit") return false;
|
|
1564
|
+
try {
|
|
1565
|
+
await submitOnboarding(api, snapshot);
|
|
1566
|
+
} catch (error) {
|
|
1567
|
+
const status = error.status;
|
|
1568
|
+
if (typeof status === "number" && status >= 500) {
|
|
1569
|
+
submitUrl = onboardingFormUrl() ?? void 0;
|
|
1570
|
+
prompter.note(`The API couldn't record the acceptance. Submit at ${onboardingFormLocation()}.`);
|
|
1571
|
+
return true;
|
|
1572
|
+
}
|
|
1573
|
+
throw error;
|
|
1574
|
+
}
|
|
1575
|
+
snapshot = await loadSnapshot(api, companyId);
|
|
1576
|
+
submitted = true;
|
|
1577
|
+
prompter.note("Submitted. Fluid's team will review it.");
|
|
1578
|
+
return true;
|
|
1579
|
+
};
|
|
1580
|
+
for (;;) {
|
|
1581
|
+
const missing = missingFields(snapshot);
|
|
1582
|
+
let slot = forced;
|
|
1583
|
+
forced = void 0;
|
|
1584
|
+
if (!slot) {
|
|
1585
|
+
const open = missing.filter((m) => !skipped.has(slotKey(fromMissing(m))));
|
|
1586
|
+
let pool = focus ? open.filter((m) => m.step === focus) : open;
|
|
1587
|
+
if (focus && pool.length === 0) {
|
|
1588
|
+
prompter.note(`${stepName(focus)} is done.`);
|
|
1589
|
+
focus = void 0;
|
|
1590
|
+
pool = open;
|
|
1591
|
+
}
|
|
1592
|
+
slot = pool[0] ? fromMissing(pool[0]) : void 0;
|
|
1593
|
+
}
|
|
1594
|
+
if (!slot) {
|
|
1595
|
+
if (missing.length === 0) {
|
|
1596
|
+
const choice = await prompter.menu("Every required answer is in.", [
|
|
1597
|
+
{
|
|
1598
|
+
title: "Submit",
|
|
1599
|
+
value: "submit"
|
|
1600
|
+
},
|
|
1601
|
+
{
|
|
1602
|
+
title: "Change an answer",
|
|
1603
|
+
value: "menu"
|
|
1604
|
+
},
|
|
1605
|
+
{
|
|
1606
|
+
title: "Stop for now",
|
|
1607
|
+
value: "quit"
|
|
1608
|
+
}
|
|
1609
|
+
]);
|
|
1610
|
+
if (choice === "submit" && await offerSubmit()) break;
|
|
1611
|
+
if (choice === "menu" && await openMenu() !== "quit") continue;
|
|
1612
|
+
if (choice === "submit") continue;
|
|
1613
|
+
break;
|
|
1614
|
+
}
|
|
1615
|
+
const choice = await prompter.menu(`${skipped.size} skipped question(s) left.`, [
|
|
1616
|
+
{
|
|
1617
|
+
title: "Go through the skipped questions",
|
|
1618
|
+
value: "again"
|
|
1619
|
+
},
|
|
1620
|
+
{
|
|
1621
|
+
title: "Menu…",
|
|
1622
|
+
value: "menu"
|
|
1623
|
+
},
|
|
1624
|
+
{
|
|
1625
|
+
title: "Stop for now",
|
|
1626
|
+
value: "quit"
|
|
1627
|
+
}
|
|
1628
|
+
]);
|
|
1629
|
+
if (choice === "again") {
|
|
1630
|
+
skipped.clear();
|
|
1631
|
+
continue;
|
|
1632
|
+
}
|
|
1633
|
+
if (choice === "menu" && await openMenu() !== "quit") continue;
|
|
1634
|
+
break;
|
|
1635
|
+
}
|
|
1636
|
+
if (slot.field.includes("<ISO>")) {
|
|
1637
|
+
const existing = new Set((snapshot.info.countries_info ?? []).map((c) => c.country_iso.toUpperCase()));
|
|
1638
|
+
const countries = (await countryChoices()).filter((c) => !existing.has(c.value));
|
|
1639
|
+
const reply = await prompter.ask({
|
|
1640
|
+
section: stepName("countries"),
|
|
1641
|
+
message: "Which country do you sell in? (add more any time with ? → Add a country)",
|
|
1642
|
+
kind: countries.length > 0 ? "search" : "text",
|
|
1643
|
+
...countries.length > 0 ? {
|
|
1644
|
+
choices: countries,
|
|
1645
|
+
hint: "type to filter"
|
|
1646
|
+
} : { hint: "two-letter code, e.g. US" },
|
|
1647
|
+
canGoBack: history.length > 0
|
|
1648
|
+
});
|
|
1649
|
+
if ("nav" in reply) {
|
|
1650
|
+
if (reply.nav === "quit") break;
|
|
1651
|
+
if (reply.nav === "back") {
|
|
1652
|
+
if (!goBack()) forced = slot;
|
|
1653
|
+
} else if (reply.nav === "skip") {
|
|
1654
|
+
skipped.add(slotKey(slot));
|
|
1655
|
+
history.push(slot);
|
|
1656
|
+
} else {
|
|
1657
|
+
const after = await openMenu();
|
|
1658
|
+
if (after === "quit") break;
|
|
1659
|
+
if (after === "stay") forced = slot;
|
|
1660
|
+
}
|
|
1661
|
+
continue;
|
|
1662
|
+
}
|
|
1663
|
+
const iso = reply.value.trim().toUpperCase();
|
|
1664
|
+
if (!/^[A-Z]{2}$/.test(iso)) {
|
|
1665
|
+
prompter.note("Pick a country, or type its two-letter code.");
|
|
1666
|
+
forced = slot;
|
|
1667
|
+
continue;
|
|
1668
|
+
}
|
|
1669
|
+
if (existing.has(iso)) {
|
|
1670
|
+
prompter.note(`${iso} is already on the form. To change it, open the menu and go to the Countries step.`);
|
|
1671
|
+
forced = slot;
|
|
1672
|
+
continue;
|
|
1673
|
+
}
|
|
1674
|
+
forced = {
|
|
1675
|
+
...slot,
|
|
1676
|
+
field: slot.field.replace("<ISO>", iso)
|
|
1677
|
+
};
|
|
1678
|
+
continue;
|
|
1679
|
+
}
|
|
1680
|
+
const field = fieldFor(slot.field);
|
|
1681
|
+
if (!field) {
|
|
1682
|
+
skipped.add(slotKey(slot));
|
|
1683
|
+
continue;
|
|
1684
|
+
}
|
|
1685
|
+
const step = field.step;
|
|
1686
|
+
const left = missing.filter((m) => m.step === step).length;
|
|
1687
|
+
const current = display(slot.field, savedValue(snapshot, slot));
|
|
1688
|
+
const question = await questionFor(slot, field, current, await options(field), `${stepName(step)}${left > 0 ? ` · ${left} left` : ""}`, history.length > 0);
|
|
1689
|
+
const reply = await prompter.ask(question);
|
|
1690
|
+
if ("nav" in reply) {
|
|
1691
|
+
if (reply.nav === "quit") break;
|
|
1692
|
+
if (reply.nav === "back") {
|
|
1693
|
+
if (!goBack()) forced = slot;
|
|
1694
|
+
continue;
|
|
1695
|
+
}
|
|
1696
|
+
if (reply.nav === "skip") {
|
|
1697
|
+
skipped.add(slotKey(slot));
|
|
1698
|
+
history.push(slot);
|
|
1699
|
+
continue;
|
|
1700
|
+
}
|
|
1701
|
+
const after = await openMenu();
|
|
1702
|
+
if (after === "quit") break;
|
|
1703
|
+
if (after === "stay") forced = slot;
|
|
1704
|
+
continue;
|
|
1705
|
+
}
|
|
1706
|
+
if (reply.value.trim() === "") {
|
|
1707
|
+
if (current !== void 0) history.push(slot);
|
|
1708
|
+
else {
|
|
1709
|
+
prompter.note("Type an answer, or > to skip.");
|
|
1710
|
+
forced = slot;
|
|
1711
|
+
}
|
|
1712
|
+
continue;
|
|
1713
|
+
}
|
|
1714
|
+
if (await save(slot, field, reply.value)) {
|
|
1715
|
+
answered += 1;
|
|
1716
|
+
history.push(slot);
|
|
1717
|
+
} else forced = slot;
|
|
1718
|
+
}
|
|
1719
|
+
const after = summarize(snapshot);
|
|
1720
|
+
return {
|
|
1721
|
+
answered,
|
|
1722
|
+
skipped: [...skipped].map((k) => k.split("|")[0]),
|
|
1723
|
+
status: after.status,
|
|
1724
|
+
readyToSubmit: after.readyToSubmit,
|
|
1725
|
+
missingCount: after.missingCount,
|
|
1726
|
+
submitted,
|
|
1727
|
+
...submitUrl ? { submitUrl } : {}
|
|
1728
|
+
};
|
|
1729
|
+
}
|
|
1730
|
+
//#endregion
|
|
1731
|
+
//#region src/index.ts
|
|
1732
|
+
async function client(ctx) {
|
|
1733
|
+
return await ctx.getClient();
|
|
1734
|
+
}
|
|
1735
|
+
/**
|
|
1736
|
+
* Changes to the onboarding form need an explicit yes. Scripts and agents
|
|
1737
|
+
* pass `--yes`; an interactive terminal is asked. With neither, nothing runs.
|
|
1738
|
+
*/
|
|
1739
|
+
async function confirmChange(question, yes, interactive = Boolean(process.stdin.isTTY)) {
|
|
1740
|
+
if (yes) return;
|
|
1741
|
+
if (!interactive) throw new OnboardingError(`${question} Pass --yes to confirm.`);
|
|
1742
|
+
if (!(await prompts({
|
|
1743
|
+
type: "confirm",
|
|
1744
|
+
name: "ok",
|
|
1745
|
+
message: question,
|
|
1746
|
+
initial: false
|
|
1747
|
+
})).ok) throw new OnboardingError("Cancelled.");
|
|
1748
|
+
}
|
|
1749
|
+
/**
|
|
1750
|
+
* Print a failure as JSON on stderr and exit non-zero. The API's error body
|
|
1751
|
+
* names fields and reasons, never the submitted values, so its `errors` are
|
|
1752
|
+
* passed through as `details`.
|
|
1753
|
+
*/
|
|
1754
|
+
function fail(err, write = (t) => process.stderr.write(t)) {
|
|
1755
|
+
const status = typeof err.status === "number" ? err.status : void 0;
|
|
1756
|
+
const body = err.body;
|
|
1757
|
+
const apiDetail = body && (body["errors"] ?? body["error"] ?? body["error_message"]);
|
|
1758
|
+
const message = typeof body?.["error_message"] === "string" ? body["error_message"] : typeof apiDetail === "string" ? apiDetail : err instanceof Error ? err.message : String(err);
|
|
1759
|
+
const details = err instanceof OnboardingError && err.details !== void 0 ? err.details : apiDetail && typeof apiDetail === "object" ? apiDetail : void 0;
|
|
1760
|
+
write(JSON.stringify({
|
|
1761
|
+
error: message,
|
|
1762
|
+
...status ? { status } : {},
|
|
1763
|
+
...details !== void 0 ? { details } : {}
|
|
1764
|
+
}) + "\n");
|
|
1765
|
+
process.exitCode = 1;
|
|
1766
|
+
}
|
|
1767
|
+
function run(action) {
|
|
1768
|
+
return action().catch((err) => fail(err));
|
|
1769
|
+
}
|
|
1770
|
+
const collect = (value, previous = []) => [...previous, value];
|
|
1771
|
+
const GROUPS = [
|
|
1772
|
+
"info",
|
|
1773
|
+
"entity",
|
|
1774
|
+
"bank",
|
|
1775
|
+
"owner",
|
|
1776
|
+
"country",
|
|
1777
|
+
"underwriting"
|
|
1778
|
+
];
|
|
1779
|
+
function registerOnboardingCommands(parent, ctx) {
|
|
1780
|
+
const onboarding = parent.command("onboarding").description("Payments onboarding (KYC): see what's missing, fill it in, and submit");
|
|
1781
|
+
onboarding.command("show").description("Status, each step, the required fields still missing, and the records on the form (sensitive values masked)").option("--step <id>", `Only list what's missing in one step: ${STEPS.map((s) => s.id).join(", ")}`).action((opts) => run(async () => {
|
|
1782
|
+
if (opts.step && !STEPS.some((s) => s.id === opts.step)) throw new OnboardingError(`--step must be one of ${STEPS.map((s) => s.id).join(", ")}.`);
|
|
1783
|
+
const api = await client(ctx);
|
|
1784
|
+
const companyId = await currentCompanyId(api);
|
|
1785
|
+
const summary = summarize(await loadSnapshot(api, companyId));
|
|
1786
|
+
const missing = opts.step ? summary.missing.filter((m) => m.step === opts.step) : summary.missing;
|
|
1787
|
+
ctx.output({
|
|
1788
|
+
...summary,
|
|
1789
|
+
missing,
|
|
1790
|
+
termsUrl: summary.termsAgreed ? void 0 : await termsUrl(api, companyId),
|
|
1791
|
+
providers: summary.submitted ? await providerStatuses(api, companyId) : void 0
|
|
1792
|
+
});
|
|
1793
|
+
}));
|
|
1794
|
+
onboarding.command("fields").description("The full field schema: keys for --set/--file, types, options, and when each is required").option("--group <group>", `Only one group: ${GROUPS.join(", ")}`).option("--required", "Only fields that are always or conditionally required", false).action((opts) => run(async () => {
|
|
1795
|
+
if (opts.group && !GROUPS.includes(opts.group)) throw new OnboardingError(`--group must be one of ${GROUPS.join(", ")}.`);
|
|
1796
|
+
const fields = FIELDS.filter((f) => (!opts.group || f.group === opts.group) && (!opts.required || f.required !== "optional"));
|
|
1797
|
+
ctx.output({
|
|
1798
|
+
steps: STEPS,
|
|
1799
|
+
targets: {
|
|
1800
|
+
entity: "--entity <id|new>; defaults to the primary legal entity, created if there is none",
|
|
1801
|
+
bank: "--bank <id|new>; defaults to the primary bank account, created if there is none",
|
|
1802
|
+
owner: "--owner <id|new>; defaults to the onboarding contact (you), created if there is none",
|
|
1803
|
+
country: "country.<ISO>.<field>, e.g. country.US.currency=USD; adds the country if it's new"
|
|
1804
|
+
},
|
|
1805
|
+
documents: {
|
|
1806
|
+
flag: "--file <key>=<path>",
|
|
1807
|
+
maxBytes: MAX_UPLOAD_BYTES,
|
|
1808
|
+
multiFile: "files-type documents add to what's there; pass --replace-files to start over",
|
|
1809
|
+
governmentIdTypes: ID_TYPES,
|
|
1810
|
+
linkOrFile: "link_or_file documents take a URL with --set <key>.link=<url> or a file with --file"
|
|
1811
|
+
},
|
|
1812
|
+
fields
|
|
1813
|
+
});
|
|
1814
|
+
}));
|
|
1815
|
+
onboarding.command("options <list>").description(`Values for choice fields: ${OPTION_LISTS.join(", ")}`).option("--country <iso>", "business-types: the types for one country").action((list, opts) => run(async () => {
|
|
1816
|
+
if (!OPTION_LISTS.includes(list)) throw new OnboardingError(`List must be one of ${OPTION_LISTS.join(", ")}.`);
|
|
1817
|
+
ctx.output(await listOptions(await client(ctx), list, opts.country));
|
|
1818
|
+
}));
|
|
1819
|
+
onboarding.command("set").description("Save fields and upload documents, then print what's still missing (changes live data)").option("--set <key=value>", "A field from `fields`, e.g. entity.legal_name=Acme Inc (repeatable)", collect, []).option("--file <key=path>", "Upload a document, e.g. entity.business_license=./license.pdf (repeatable)", collect, []).option("--entity <id|new>", "Which legal entity entity.* fields go to").option("--bank <id|new>", "Which bank account bank.* fields go to").option("--owner <id|new>", "Which person owner.* fields go to").option("--id-type <type>", `Government ID type for owner.government_issued_ids: ${ID_TYPES.join(", ")}`).option("--replace-files", "Replace a multi-file document's files instead of adding to them", false).option("--yes", "Confirm without a prompt", false).action((opts) => run(async () => {
|
|
1820
|
+
const plan = buildPlan(opts.set, opts.file);
|
|
1821
|
+
if (isEmptyPlan(plan)) throw new OnboardingError("Pass fields with --set key=value or documents with --file key=path.");
|
|
1822
|
+
const targets = {
|
|
1823
|
+
entity: parseTarget("--entity", opts.entity),
|
|
1824
|
+
bank: parseTarget("--bank", opts.bank),
|
|
1825
|
+
owner: parseTarget("--owner", opts.owner),
|
|
1826
|
+
idType: parseIdType(opts.idType),
|
|
1827
|
+
replaceFiles: opts.replaceFiles
|
|
1828
|
+
};
|
|
1829
|
+
const api = await client(ctx);
|
|
1830
|
+
const companyId = await currentCompanyId(api);
|
|
1831
|
+
const snapshot = await loadSnapshot(api, companyId);
|
|
1832
|
+
await confirmChange(`Save ${opts.set.length + opts.file.length} onboarding change(s) for company ${companyId}?`, opts.yes);
|
|
1833
|
+
const saved = await applyPlan(api, snapshot, plan, targets);
|
|
1834
|
+
const after = summarize(await loadSnapshot(api, companyId));
|
|
1835
|
+
ctx.output({
|
|
1836
|
+
saved,
|
|
1837
|
+
changed: describePlan(plan),
|
|
1838
|
+
status: after.status,
|
|
1839
|
+
readyToSubmit: after.readyToSubmit,
|
|
1840
|
+
missingCount: after.missingCount,
|
|
1841
|
+
steps: after.steps,
|
|
1842
|
+
missing: after.missing
|
|
1843
|
+
});
|
|
1844
|
+
}));
|
|
1845
|
+
onboarding.command("walk").description("Answer the form question by question in the terminal: go back, skip, jump to a step, or change an answer; each answer is saved as you go (changes live data)").option("--step <id>", `Start at a step: ${STEPS.map((s) => s.id).join(", ")}`).action((opts) => run(async () => {
|
|
1846
|
+
assertInteractive("`show` to see what's missing and `set` to fill it in");
|
|
1847
|
+
const step = opts.step;
|
|
1848
|
+
if (step && !STEPS.some((s) => s.id === step)) throw new OnboardingError(`Unknown step "${step}". Use one of: ${STEPS.map((s) => s.id).join(", ")}.`);
|
|
1849
|
+
const api = await client(ctx);
|
|
1850
|
+
const companyId = await currentCompanyId(api);
|
|
1851
|
+
ctx.output(await walkOnboarding({
|
|
1852
|
+
api,
|
|
1853
|
+
companyId,
|
|
1854
|
+
prompter: terminalPrompter()
|
|
1855
|
+
}, { step }));
|
|
1856
|
+
}));
|
|
1857
|
+
onboarding.command("remove <record> <id>").description("Remove a legal entity, bank account, or person by id, or a country by ISO code (changes live data)").option("--yes", "Confirm without a prompt", false).action((record, id, opts) => run(async () => {
|
|
1858
|
+
const records = [
|
|
1859
|
+
"entity",
|
|
1860
|
+
"bank",
|
|
1861
|
+
"owner",
|
|
1862
|
+
"country"
|
|
1863
|
+
];
|
|
1864
|
+
if (!records.includes(record)) throw new OnboardingError(`Record must be one of ${records.join(", ")}.`);
|
|
1865
|
+
const api = await client(ctx);
|
|
1866
|
+
const snapshot = await loadSnapshot(api, await currentCompanyId(api));
|
|
1867
|
+
await confirmChange(`Remove ${record} ${id} from the onboarding form?`, opts.yes);
|
|
1868
|
+
ctx.output(await removeRecord(api, snapshot, record, id));
|
|
1869
|
+
}));
|
|
1870
|
+
onboarding.command("submit").description("Accept Fluid's terms and submit the form for review; refused while anything is missing (changes live data)").option("--accept-terms", "Accept Fluid's payments terms & conditions for the company (required)", false).option("--yes", "Confirm without a prompt", false).action((opts) => run(async () => {
|
|
1871
|
+
const api = await client(ctx);
|
|
1872
|
+
const companyId = await currentCompanyId(api);
|
|
1873
|
+
const snapshot = await loadSnapshot(api, companyId);
|
|
1874
|
+
if (summarize(snapshot).submitted) {
|
|
1875
|
+
ctx.output({
|
|
1876
|
+
status: "submitted",
|
|
1877
|
+
alreadySubmitted: true,
|
|
1878
|
+
providers: await providerStatuses(api, companyId)
|
|
1879
|
+
});
|
|
1880
|
+
return;
|
|
1881
|
+
}
|
|
1882
|
+
assertComplete(snapshot);
|
|
1883
|
+
if (!await signedInAsPerson(api)) {
|
|
1884
|
+
ctx.output({
|
|
1885
|
+
status: "ready_to_submit",
|
|
1886
|
+
submitted: false,
|
|
1887
|
+
submitUrl: onboardingFormUrl(),
|
|
1888
|
+
submitAt: onboardingFormLocation(),
|
|
1889
|
+
reason: "Every required field is filled in. Submitting records the person who accepts Fluid's terms, so a person has to submit: open the form at submitAt and submit there, or sign in with `fluid login` (email) and run this again."
|
|
1890
|
+
});
|
|
1891
|
+
return;
|
|
1892
|
+
}
|
|
1893
|
+
if (!opts.acceptTerms) {
|
|
1894
|
+
const url = await termsUrl(api, companyId);
|
|
1895
|
+
throw new OnboardingError(`Submitting accepts Fluid's payments terms & conditions for the company${url ? ` (${url})` : ""}. Read them, then pass --accept-terms.`);
|
|
1896
|
+
}
|
|
1897
|
+
await confirmChange(`Accept the terms and submit payments onboarding for company ${companyId}?`, opts.yes);
|
|
1898
|
+
try {
|
|
1899
|
+
await submitOnboarding(api, snapshot);
|
|
1900
|
+
} catch (error) {
|
|
1901
|
+
const status = error.status;
|
|
1902
|
+
if (typeof status === "number" && status >= 500) throw new OnboardingError(`The API couldn't record the acceptance. Submit from the onboarding form instead: ${onboardingFormLocation()}`);
|
|
1903
|
+
throw error;
|
|
1904
|
+
}
|
|
1905
|
+
ctx.output({
|
|
1906
|
+
status: "submitted",
|
|
1907
|
+
submitted: true,
|
|
1908
|
+
providers: await providerStatuses(api, companyId)
|
|
1909
|
+
});
|
|
1910
|
+
}));
|
|
1911
|
+
}
|
|
1912
|
+
const plugin = {
|
|
1913
|
+
name: "@fluid-app/fluid-cli-payments",
|
|
1914
|
+
version: "0.1.0",
|
|
1915
|
+
register(ctx) {
|
|
1916
|
+
ctx.program.addCommand(createDomainCommand("payments", "Payments onboarding: what's missing, fill it in, and submit", registerOnboardingCommands));
|
|
1917
|
+
}
|
|
1918
|
+
};
|
|
1919
|
+
//#endregion
|
|
1920
|
+
export { confirmChange, plugin as default, fail, registerOnboardingCommands };
|
|
1921
|
+
|
|
1922
|
+
//# sourceMappingURL=index.mjs.map
|