@blamejs/blamejs-shop 0.0.65 → 0.0.70
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/CHANGELOG.md +10 -0
- package/lib/assembly-instructions.js +777 -0
- package/lib/auto-replenish.js +933 -0
- package/lib/business-hours.js +980 -0
- package/lib/click-and-collect.js +711 -0
- package/lib/clickstream.js +713 -0
- package/lib/cost-layers.js +774 -0
- package/lib/credit-limits.js +752 -0
- package/lib/currency-rounding.js +525 -0
- package/lib/customer-activity.js +862 -0
- package/lib/customer-notes.js +712 -0
- package/lib/customer-risk-profile.js +593 -0
- package/lib/customer-surveys.js +1012 -0
- package/lib/damage-photos.js +473 -0
- package/lib/discount-allocation.js +557 -0
- package/lib/dropship-forwarding.js +645 -0
- package/lib/email-templates.js +817 -0
- package/lib/index.js +45 -0
- package/lib/inventory-allocations.js +559 -0
- package/lib/inventory-writeoffs.js +636 -0
- package/lib/knowledge-base.js +1104 -0
- package/lib/locale-router.js +1077 -0
- package/lib/operator-roles.js +768 -0
- package/lib/order-escalation.js +951 -0
- package/lib/order-ratings.js +495 -0
- package/lib/order-tags.js +944 -0
- package/lib/packing-slips.js +810 -0
- package/lib/payment-retries.js +816 -0
- package/lib/pick-lists.js +639 -0
- package/lib/pixel-events.js +995 -0
- package/lib/preorder.js +595 -0
- package/lib/print-queue.js +681 -0
- package/lib/product-qa.js +749 -0
- package/lib/promo-bundles.js +835 -0
- package/lib/push-notifications.js +937 -0
- package/lib/refund-automation.js +853 -0
- package/lib/reorder-reminders.js +798 -0
- package/lib/robots-config.js +753 -0
- package/lib/seller-signup.js +1052 -0
- package/lib/site-redirects.js +690 -0
- package/lib/sitemap-generator.js +717 -0
- package/lib/subscription-gifts.js +710 -0
- package/lib/tax-cert-renewals.js +632 -0
- package/lib/theme-assets.js +711 -0
- package/lib/tier-benefits.js +776 -0
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +2 -0
- package/lib/vendor/blamejs/api-snapshot.json +2 -2
- package/lib/vendor/blamejs/lib/metrics.js +68 -4
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.12.5.json +40 -0
- package/lib/wishlist-alerts.js +842 -0
- package/lib/wishlist-sharing.js +718 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1052 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.sellerSignup
|
|
4
|
+
* @title Seller signup — marketplace seller onboarding funnel
|
|
5
|
+
*
|
|
6
|
+
* @intro
|
|
7
|
+
* The inbound application-to-vendor pipeline. A prospective
|
|
8
|
+
* seller submits an application; the operator (or an internal
|
|
9
|
+
* KYC/AML team) requests supporting documents, the applicant
|
|
10
|
+
* uploads them, the operator either approves (the primitive
|
|
11
|
+
* composes `vendors.registerVendor` to mint the real vendor
|
|
12
|
+
* row), rejects (terminal, reason captured), or requests
|
|
13
|
+
* revisions (re-opens for additional docs).
|
|
14
|
+
*
|
|
15
|
+
* Distinct from `vendors` (the registry of suppliers the
|
|
16
|
+
* operator drop-ships from — the OUTPUT of this primitive on a
|
|
17
|
+
* happy approval path) and from `customer_roles` (which models
|
|
18
|
+
* B2B account hierarchy inside a single customer entity — a
|
|
19
|
+
* distinct relationship class, not a vendor pipeline).
|
|
20
|
+
*
|
|
21
|
+
* FSM:
|
|
22
|
+
*
|
|
23
|
+
* submitted -> docs_pending (requestDocument)
|
|
24
|
+
* docs_pending -> in_review (every requested doc uploaded)
|
|
25
|
+
* in_review -> approved (approveApplication, terminal,
|
|
26
|
+
* composes vendors.registerVendor)
|
|
27
|
+
* in_review -> revisions_requested (requestRevisions)
|
|
28
|
+
* revisions_requested -> docs_pending (auto on first new
|
|
29
|
+
* requestDocument while in this
|
|
30
|
+
* state)
|
|
31
|
+
* submitted | docs_pending | in_review | revisions_requested ->
|
|
32
|
+
* rejected (rejectApplication, terminal)
|
|
33
|
+
*
|
|
34
|
+
* (approved and rejected are terminal — no transitions out.)
|
|
35
|
+
*
|
|
36
|
+
* Composes:
|
|
37
|
+
* - vendors — composed at approveApplication time
|
|
38
|
+
* to mint the real vendor row.
|
|
39
|
+
* Injected as `opts.vendors`.
|
|
40
|
+
* Required at approve time; absent at
|
|
41
|
+
* construction means submit / doc /
|
|
42
|
+
* reject / revisions all still work,
|
|
43
|
+
* but approveApplication refuses.
|
|
44
|
+
* - b.crypto.namespaceHash — contact_email / contact_phone /
|
|
45
|
+
* tax_id are hashed under three
|
|
46
|
+
* distinct namespaces so the raw
|
|
47
|
+
* values never land on disk
|
|
48
|
+
* - b.guardEmail — strict-profile validate + sanitize
|
|
49
|
+
* on contact_email
|
|
50
|
+
* - b.uuid.v7 — application + document PKs
|
|
51
|
+
* (sortable; B-tree locality so the
|
|
52
|
+
* operator inbox keyset-paginates
|
|
53
|
+
* without a second index)
|
|
54
|
+
*
|
|
55
|
+
* Surface:
|
|
56
|
+
* submitApplication({ business_name, contact_email,
|
|
57
|
+
* contact_phone, business_address,
|
|
58
|
+
* tax_id_kind, tax_id_value, category_focus,
|
|
59
|
+
* expected_monthly_volume_minor, currency,
|
|
60
|
+
* references_json? })
|
|
61
|
+
* requestDocument({ application_id, doc_kind, instructions })
|
|
62
|
+
* recordDocumentUploaded({ application_id, doc_kind, sha3_512,
|
|
63
|
+
* byte_size, mime_type })
|
|
64
|
+
* approveApplication({ application_id, approved_by, vendor_slug })
|
|
65
|
+
* rejectApplication({ application_id, reason, rejected_by })
|
|
66
|
+
* requestRevisions({ application_id, fields, instructions })
|
|
67
|
+
* getApplication(id) / listApplications({ status?, cursor? })
|
|
68
|
+
* documentsForApplication(application_id)
|
|
69
|
+
*
|
|
70
|
+
* Storage: `migrations-d1/0146_seller_signup.sql` — two tables,
|
|
71
|
+
* `seller_applications` + `seller_application_documents`. ON
|
|
72
|
+
* DELETE CASCADE drops the docs when the application row is
|
|
73
|
+
* hard-deleted (the primitive only soft-transitions; hard-
|
|
74
|
+
* delete is an operator-side privacy-erasure migration concern).
|
|
75
|
+
*
|
|
76
|
+
* @primitive sellerSignup
|
|
77
|
+
* @related shop.vendors, shop.customerRoles, b.crypto, b.guardEmail, b.uuid
|
|
78
|
+
*/
|
|
79
|
+
|
|
80
|
+
var MAX_BUSINESS_NAME_LEN = 200;
|
|
81
|
+
var MAX_PHONE_LEN = 32;
|
|
82
|
+
var MAX_TAX_ID_LEN = 64;
|
|
83
|
+
var MAX_ADDRESS_JSON_LEN = 4096;
|
|
84
|
+
var MAX_CATEGORY_FOCUS_LEN = 4096;
|
|
85
|
+
var MAX_REFERENCES_JSON_LEN = 8192;
|
|
86
|
+
var MAX_INSTRUCTIONS_LEN = 2000;
|
|
87
|
+
var MAX_REJECT_REASON_LEN = 1000;
|
|
88
|
+
var MAX_OPERATOR_ID_LEN = 256;
|
|
89
|
+
var MAX_MIME_TYPE_LEN = 128;
|
|
90
|
+
var MAX_FIELDS_LEN = 32;
|
|
91
|
+
var MAX_FIELD_NAME_LEN = 64;
|
|
92
|
+
var MAX_LIST_LIMIT = 200;
|
|
93
|
+
var MAX_VOLUME_MINOR = 100000000000; // 1e11 sanity cap
|
|
94
|
+
var MAX_BYTE_SIZE = 5368709120; // 5 GiB sanity cap per doc
|
|
95
|
+
|
|
96
|
+
var EMAIL_NAMESPACE = "seller-signup-email";
|
|
97
|
+
var PHONE_NAMESPACE = "seller-signup-phone";
|
|
98
|
+
var TAX_ID_NAMESPACE = "seller-signup-tax-id";
|
|
99
|
+
|
|
100
|
+
var TAX_ID_KINDS = Object.freeze([
|
|
101
|
+
"us_ssn", "us_ein", "eu_vat", "uk_utr", "au_abn", "ca_bn", "other",
|
|
102
|
+
]);
|
|
103
|
+
|
|
104
|
+
var DOC_KINDS = Object.freeze([
|
|
105
|
+
"w9", "w8ben", "id", "business_license", "utility_bill",
|
|
106
|
+
"insurance", "bank_statement", "other",
|
|
107
|
+
]);
|
|
108
|
+
|
|
109
|
+
var APPLICATION_STATUSES = Object.freeze([
|
|
110
|
+
"submitted", "docs_pending", "in_review", "revisions_requested",
|
|
111
|
+
"approved", "rejected", "withdrawn",
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
var DOCUMENT_STATUSES = Object.freeze([
|
|
115
|
+
"requested", "uploaded", "accepted", "rejected",
|
|
116
|
+
]);
|
|
117
|
+
|
|
118
|
+
var TERMINAL_APPLICATION_STATUSES = Object.freeze([
|
|
119
|
+
"approved", "rejected", "withdrawn",
|
|
120
|
+
]);
|
|
121
|
+
|
|
122
|
+
var PHONE_RE = /^\+?[1-9]\d{1,14}$/;
|
|
123
|
+
var TAX_ID_RE = /^[A-Za-z0-9][A-Za-z0-9 .\-_/]{0,63}$/;
|
|
124
|
+
var FIELD_NAME_RE = /^[a-z][a-z0-9_]{0,63}$/;
|
|
125
|
+
var SHA3_512_RE = /^[0-9a-f]{128}$/;
|
|
126
|
+
var MIME_RE = /^[A-Za-z0-9!#$&^_.+\-]{1,127}\/[A-Za-z0-9!#$&^_.+\-]{1,127}$/;
|
|
127
|
+
|
|
128
|
+
// Control bytes + zero-width / direction-override family — matches
|
|
129
|
+
// the shape vendors / purchase-orders use. Operator-rendered fields
|
|
130
|
+
// refuse these to keep downstream dashboards / KYC printouts safe
|
|
131
|
+
// from header-injection + visual-spoofing attacks.
|
|
132
|
+
var CONTROL_BYTE_RE = /[\x00-\x1f\x7f]/;
|
|
133
|
+
var ZERO_WIDTH_RE = new RegExp(
|
|
134
|
+
"[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
// Lazy framework handle — matches the pattern every other shop
|
|
138
|
+
// primitive uses; avoids the require cycle that would arise from
|
|
139
|
+
// importing `./index` at module-eval time.
|
|
140
|
+
var bShop;
|
|
141
|
+
function _b() {
|
|
142
|
+
if (!bShop) bShop = require("./index");
|
|
143
|
+
return bShop.framework;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---- validators ---------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
function _uuid(s, label) {
|
|
149
|
+
try {
|
|
150
|
+
return _b().guardUuid.sanitize(s, { profile: "strict" });
|
|
151
|
+
} catch (e) {
|
|
152
|
+
throw new TypeError("seller-signup: " + label + " — " + (e && e.message || "invalid UUID"));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function _businessName(s) {
|
|
157
|
+
if (typeof s !== "string") {
|
|
158
|
+
throw new TypeError("seller-signup: business_name must be a string");
|
|
159
|
+
}
|
|
160
|
+
var trimmed = s.trim();
|
|
161
|
+
if (!trimmed.length) {
|
|
162
|
+
throw new TypeError("seller-signup: business_name must be non-empty after trim");
|
|
163
|
+
}
|
|
164
|
+
if (s.length > MAX_BUSINESS_NAME_LEN) {
|
|
165
|
+
throw new TypeError("seller-signup: business_name must be <= " + MAX_BUSINESS_NAME_LEN + " characters");
|
|
166
|
+
}
|
|
167
|
+
if (CONTROL_BYTE_RE.test(s)) {
|
|
168
|
+
throw new TypeError("seller-signup: business_name contains control bytes");
|
|
169
|
+
}
|
|
170
|
+
if (ZERO_WIDTH_RE.test(s)) {
|
|
171
|
+
throw new TypeError("seller-signup: business_name contains zero-width / direction-override bytes");
|
|
172
|
+
}
|
|
173
|
+
return s;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function _normalizeEmail(input) {
|
|
177
|
+
if (typeof input !== "string" || !input.length) {
|
|
178
|
+
throw new TypeError("seller-signup: contact_email must be a non-empty string");
|
|
179
|
+
}
|
|
180
|
+
var guardEmail = _b().guardEmail;
|
|
181
|
+
var report;
|
|
182
|
+
try {
|
|
183
|
+
report = guardEmail.validate(input, { profile: "strict" });
|
|
184
|
+
} catch (e) {
|
|
185
|
+
throw new TypeError("seller-signup: contact_email — " + (e && e.message || "invalid email"));
|
|
186
|
+
}
|
|
187
|
+
if (!report || report.ok === false) {
|
|
188
|
+
var first = (report && report.issues && report.issues[0]) || {};
|
|
189
|
+
throw new TypeError("seller-signup: contact_email — " + (first.snippet || first.ruleId || "refused at strict profile"));
|
|
190
|
+
}
|
|
191
|
+
var canonical;
|
|
192
|
+
try {
|
|
193
|
+
canonical = guardEmail.sanitize(input, { profile: "strict" });
|
|
194
|
+
} catch (e2) {
|
|
195
|
+
throw new TypeError("seller-signup: contact_email — " + (e2 && e2.message || "refused"));
|
|
196
|
+
}
|
|
197
|
+
return canonical.trim().toLowerCase();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function _phone(value) {
|
|
201
|
+
if (typeof value !== "string" || !value.length) {
|
|
202
|
+
throw new TypeError("seller-signup: contact_phone must be a non-empty string");
|
|
203
|
+
}
|
|
204
|
+
var trimmed = value.trim();
|
|
205
|
+
if (!trimmed.length) {
|
|
206
|
+
throw new TypeError("seller-signup: contact_phone must be non-empty after trim");
|
|
207
|
+
}
|
|
208
|
+
if (trimmed.length > MAX_PHONE_LEN) {
|
|
209
|
+
throw new TypeError("seller-signup: contact_phone must be <= " + MAX_PHONE_LEN + " characters");
|
|
210
|
+
}
|
|
211
|
+
if (!PHONE_RE.test(trimmed)) {
|
|
212
|
+
throw new TypeError("seller-signup: contact_phone must match E.164-ish shape (^\\+?[1-9]\\d{1,14}$)");
|
|
213
|
+
}
|
|
214
|
+
return trimmed;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function _businessAddress(value) {
|
|
218
|
+
if (value == null || typeof value !== "object" || Array.isArray(value)) {
|
|
219
|
+
throw new TypeError("seller-signup: business_address must be a plain object");
|
|
220
|
+
}
|
|
221
|
+
var encoded;
|
|
222
|
+
try { encoded = JSON.stringify(value); }
|
|
223
|
+
catch (_e) {
|
|
224
|
+
throw new TypeError("seller-signup: business_address must be JSON-serialisable");
|
|
225
|
+
}
|
|
226
|
+
if (encoded.length > MAX_ADDRESS_JSON_LEN) {
|
|
227
|
+
throw new TypeError("seller-signup: business_address JSON must be <= " + MAX_ADDRESS_JSON_LEN + " characters serialised");
|
|
228
|
+
}
|
|
229
|
+
if (CONTROL_BYTE_RE.test(encoded) || ZERO_WIDTH_RE.test(encoded)) {
|
|
230
|
+
throw new TypeError("seller-signup: business_address contains control / zero-width bytes");
|
|
231
|
+
}
|
|
232
|
+
return encoded;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function _taxIdKind(s) {
|
|
236
|
+
if (typeof s !== "string" || TAX_ID_KINDS.indexOf(s) === -1) {
|
|
237
|
+
throw new TypeError("seller-signup: tax_id_kind must be one of " + TAX_ID_KINDS.join(", "));
|
|
238
|
+
}
|
|
239
|
+
return s;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function _taxIdValue(s) {
|
|
243
|
+
if (typeof s !== "string" || !s.length) {
|
|
244
|
+
throw new TypeError("seller-signup: tax_id_value must be a non-empty string");
|
|
245
|
+
}
|
|
246
|
+
var trimmed = s.trim();
|
|
247
|
+
if (!trimmed.length) {
|
|
248
|
+
throw new TypeError("seller-signup: tax_id_value must be non-empty after trim");
|
|
249
|
+
}
|
|
250
|
+
if (trimmed.length > MAX_TAX_ID_LEN) {
|
|
251
|
+
throw new TypeError("seller-signup: tax_id_value must be <= " + MAX_TAX_ID_LEN + " characters");
|
|
252
|
+
}
|
|
253
|
+
if (!TAX_ID_RE.test(trimmed)) {
|
|
254
|
+
throw new TypeError("seller-signup: tax_id_value must match /^[A-Za-z0-9][A-Za-z0-9 .\\-_/]*$/");
|
|
255
|
+
}
|
|
256
|
+
return trimmed;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function _categoryFocus(value) {
|
|
260
|
+
if (!Array.isArray(value)) {
|
|
261
|
+
throw new TypeError("seller-signup: category_focus must be a non-empty array");
|
|
262
|
+
}
|
|
263
|
+
if (value.length === 0) {
|
|
264
|
+
throw new TypeError("seller-signup: category_focus must contain at least one category");
|
|
265
|
+
}
|
|
266
|
+
for (var i = 0; i < value.length; i += 1) {
|
|
267
|
+
if (typeof value[i] !== "string" || !value[i].length) {
|
|
268
|
+
throw new TypeError("seller-signup: category_focus[" + i + "] must be a non-empty string");
|
|
269
|
+
}
|
|
270
|
+
if (CONTROL_BYTE_RE.test(value[i]) || ZERO_WIDTH_RE.test(value[i])) {
|
|
271
|
+
throw new TypeError("seller-signup: category_focus[" + i + "] contains control / zero-width bytes");
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
var encoded;
|
|
275
|
+
try { encoded = JSON.stringify(value); }
|
|
276
|
+
catch (_e) {
|
|
277
|
+
throw new TypeError("seller-signup: category_focus must be JSON-serialisable");
|
|
278
|
+
}
|
|
279
|
+
if (encoded.length > MAX_CATEGORY_FOCUS_LEN) {
|
|
280
|
+
throw new TypeError("seller-signup: category_focus JSON must be <= " + MAX_CATEGORY_FOCUS_LEN + " characters serialised");
|
|
281
|
+
}
|
|
282
|
+
return encoded;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function _volumeMinor(n) {
|
|
286
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
287
|
+
throw new TypeError("seller-signup: expected_monthly_volume_minor must be a non-negative integer");
|
|
288
|
+
}
|
|
289
|
+
if (n > MAX_VOLUME_MINOR) {
|
|
290
|
+
throw new TypeError("seller-signup: expected_monthly_volume_minor must be <= " + MAX_VOLUME_MINOR);
|
|
291
|
+
}
|
|
292
|
+
return n;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function _currency(s) {
|
|
296
|
+
if (typeof s !== "string" || !/^[A-Z]{3}$/.test(s)) {
|
|
297
|
+
throw new TypeError("seller-signup: currency must be a 3-letter uppercase ISO-4217 code");
|
|
298
|
+
}
|
|
299
|
+
return s;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function _referencesJson(value) {
|
|
303
|
+
if (value == null) return null;
|
|
304
|
+
if (typeof value !== "object") {
|
|
305
|
+
throw new TypeError("seller-signup: references_json must be an object / array or null");
|
|
306
|
+
}
|
|
307
|
+
var encoded;
|
|
308
|
+
try { encoded = JSON.stringify(value); }
|
|
309
|
+
catch (_e) {
|
|
310
|
+
throw new TypeError("seller-signup: references_json must be JSON-serialisable");
|
|
311
|
+
}
|
|
312
|
+
if (encoded.length > MAX_REFERENCES_JSON_LEN) {
|
|
313
|
+
throw new TypeError("seller-signup: references_json must be <= " + MAX_REFERENCES_JSON_LEN + " characters serialised");
|
|
314
|
+
}
|
|
315
|
+
if (CONTROL_BYTE_RE.test(encoded) || ZERO_WIDTH_RE.test(encoded)) {
|
|
316
|
+
throw new TypeError("seller-signup: references_json contains control / zero-width bytes");
|
|
317
|
+
}
|
|
318
|
+
return encoded;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function _docKind(s) {
|
|
322
|
+
if (typeof s !== "string" || DOC_KINDS.indexOf(s) === -1) {
|
|
323
|
+
throw new TypeError("seller-signup: doc_kind must be one of " + DOC_KINDS.join(", "));
|
|
324
|
+
}
|
|
325
|
+
return s;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function _instructions(s) {
|
|
329
|
+
if (typeof s !== "string" || !s.length) {
|
|
330
|
+
throw new TypeError("seller-signup: instructions must be a non-empty string");
|
|
331
|
+
}
|
|
332
|
+
if (s.length > MAX_INSTRUCTIONS_LEN) {
|
|
333
|
+
throw new TypeError("seller-signup: instructions must be <= " + MAX_INSTRUCTIONS_LEN + " characters");
|
|
334
|
+
}
|
|
335
|
+
if (CONTROL_BYTE_RE.test(s.replace(/[\t\n\r]/g, ""))) {
|
|
336
|
+
throw new TypeError("seller-signup: instructions contains control bytes");
|
|
337
|
+
}
|
|
338
|
+
if (ZERO_WIDTH_RE.test(s)) {
|
|
339
|
+
throw new TypeError("seller-signup: instructions contains zero-width / direction-override bytes");
|
|
340
|
+
}
|
|
341
|
+
return s;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function _rejectReason(s) {
|
|
345
|
+
if (typeof s !== "string" || !s.length) {
|
|
346
|
+
throw new TypeError("seller-signup: reason must be a non-empty string");
|
|
347
|
+
}
|
|
348
|
+
if (s.length > MAX_REJECT_REASON_LEN) {
|
|
349
|
+
throw new TypeError("seller-signup: reason must be <= " + MAX_REJECT_REASON_LEN + " characters");
|
|
350
|
+
}
|
|
351
|
+
if (CONTROL_BYTE_RE.test(s.replace(/[\t\n\r]/g, ""))) {
|
|
352
|
+
throw new TypeError("seller-signup: reason contains control bytes");
|
|
353
|
+
}
|
|
354
|
+
if (ZERO_WIDTH_RE.test(s)) {
|
|
355
|
+
throw new TypeError("seller-signup: reason contains zero-width / direction-override bytes");
|
|
356
|
+
}
|
|
357
|
+
return s;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function _operatorId(s, label) {
|
|
361
|
+
if (typeof s !== "string" || !s.length) {
|
|
362
|
+
throw new TypeError("seller-signup: " + label + " must be a non-empty string");
|
|
363
|
+
}
|
|
364
|
+
if (s.length > MAX_OPERATOR_ID_LEN) {
|
|
365
|
+
throw new TypeError("seller-signup: " + label + " must be <= " + MAX_OPERATOR_ID_LEN + " characters");
|
|
366
|
+
}
|
|
367
|
+
if (CONTROL_BYTE_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
|
|
368
|
+
throw new TypeError("seller-signup: " + label + " contains control / zero-width bytes");
|
|
369
|
+
}
|
|
370
|
+
return s;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function _sha3_512(s) {
|
|
374
|
+
if (typeof s !== "string") {
|
|
375
|
+
throw new TypeError("seller-signup: sha3_512 must be a string");
|
|
376
|
+
}
|
|
377
|
+
if (!SHA3_512_RE.test(s)) {
|
|
378
|
+
throw new TypeError("seller-signup: sha3_512 must be 128 lowercase hex characters");
|
|
379
|
+
}
|
|
380
|
+
return s;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function _byteSize(n) {
|
|
384
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
385
|
+
throw new TypeError("seller-signup: byte_size must be a positive integer");
|
|
386
|
+
}
|
|
387
|
+
if (n > MAX_BYTE_SIZE) {
|
|
388
|
+
throw new TypeError("seller-signup: byte_size must be <= " + MAX_BYTE_SIZE);
|
|
389
|
+
}
|
|
390
|
+
return n;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function _mimeType(s) {
|
|
394
|
+
if (typeof s !== "string" || !s.length) {
|
|
395
|
+
throw new TypeError("seller-signup: mime_type must be a non-empty string");
|
|
396
|
+
}
|
|
397
|
+
if (s.length > MAX_MIME_TYPE_LEN) {
|
|
398
|
+
throw new TypeError("seller-signup: mime_type must be <= " + MAX_MIME_TYPE_LEN + " characters");
|
|
399
|
+
}
|
|
400
|
+
if (!MIME_RE.test(s)) {
|
|
401
|
+
throw new TypeError("seller-signup: mime_type must match RFC-6838 type/subtype shape");
|
|
402
|
+
}
|
|
403
|
+
return s;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function _fieldsArray(value) {
|
|
407
|
+
if (!Array.isArray(value)) {
|
|
408
|
+
throw new TypeError("seller-signup: fields must be a non-empty array");
|
|
409
|
+
}
|
|
410
|
+
if (value.length === 0) {
|
|
411
|
+
throw new TypeError("seller-signup: fields must contain at least one entry");
|
|
412
|
+
}
|
|
413
|
+
if (value.length > MAX_FIELDS_LEN) {
|
|
414
|
+
throw new TypeError("seller-signup: fields must contain <= " + MAX_FIELDS_LEN + " entries");
|
|
415
|
+
}
|
|
416
|
+
var seen = Object.create(null);
|
|
417
|
+
for (var i = 0; i < value.length; i += 1) {
|
|
418
|
+
var f = value[i];
|
|
419
|
+
if (typeof f !== "string" || !f.length) {
|
|
420
|
+
throw new TypeError("seller-signup: fields[" + i + "] must be a non-empty string");
|
|
421
|
+
}
|
|
422
|
+
if (f.length > MAX_FIELD_NAME_LEN) {
|
|
423
|
+
throw new TypeError("seller-signup: fields[" + i + "] must be <= " + MAX_FIELD_NAME_LEN + " characters");
|
|
424
|
+
}
|
|
425
|
+
if (!FIELD_NAME_RE.test(f)) {
|
|
426
|
+
throw new TypeError("seller-signup: fields[" + i + "] must match /^[a-z][a-z0-9_]*$/");
|
|
427
|
+
}
|
|
428
|
+
if (seen[f]) {
|
|
429
|
+
throw new TypeError("seller-signup: fields contains duplicate " + JSON.stringify(f));
|
|
430
|
+
}
|
|
431
|
+
seen[f] = true;
|
|
432
|
+
}
|
|
433
|
+
return value.slice();
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function _applicationStatus(s) {
|
|
437
|
+
if (typeof s !== "string" || APPLICATION_STATUSES.indexOf(s) === -1) {
|
|
438
|
+
throw new TypeError("seller-signup: status must be one of " + APPLICATION_STATUSES.join(", "));
|
|
439
|
+
}
|
|
440
|
+
return s;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function _listLimit(n) {
|
|
444
|
+
if (!Number.isInteger(n) || n <= 0 || n > MAX_LIST_LIMIT) {
|
|
445
|
+
throw new TypeError("seller-signup: limit must be an integer in [1, " + MAX_LIST_LIMIT + "]");
|
|
446
|
+
}
|
|
447
|
+
return n;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Monotonic clock — guarantees each subsequent call returns a
|
|
451
|
+
// strictly greater integer even when the wall clock resolution
|
|
452
|
+
// can't keep up (Windows / fast CI runners). Application + document
|
|
453
|
+
// rows depend on created_at ordering for the operator inbox sort;
|
|
454
|
+
// any duplicate timestamp would force a tie-break on the secondary
|
|
455
|
+
// (id) column, which is fine for correctness but produces test
|
|
456
|
+
// flakes where a sub-millisecond write-then-list reads the rows in
|
|
457
|
+
// an unexpected order.
|
|
458
|
+
var _lastTs = 0;
|
|
459
|
+
function _now() {
|
|
460
|
+
var t = Date.now();
|
|
461
|
+
if (t <= _lastTs) { t = _lastTs + 1; }
|
|
462
|
+
_lastTs = t;
|
|
463
|
+
return t;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// ---- row hydration ------------------------------------------------------
|
|
467
|
+
|
|
468
|
+
function _hydrateApplication(row) {
|
|
469
|
+
if (!row) return null;
|
|
470
|
+
return {
|
|
471
|
+
id: row.id,
|
|
472
|
+
business_name: row.business_name,
|
|
473
|
+
contact_email_hash: row.contact_email_hash,
|
|
474
|
+
contact_email_normalised: row.contact_email_normalised,
|
|
475
|
+
contact_phone_hash: row.contact_phone_hash,
|
|
476
|
+
contact_phone_normalised: row.contact_phone_normalised,
|
|
477
|
+
business_address: JSON.parse(row.business_address_json),
|
|
478
|
+
tax_id_kind: row.tax_id_kind,
|
|
479
|
+
tax_id_hash: row.tax_id_hash,
|
|
480
|
+
tax_id_normalised_last4: row.tax_id_normalised_last4,
|
|
481
|
+
category_focus: JSON.parse(row.category_focus_json),
|
|
482
|
+
expected_monthly_volume_minor: Number(row.expected_monthly_volume_minor),
|
|
483
|
+
currency: row.currency,
|
|
484
|
+
references_json: row.references_json == null ? null : JSON.parse(row.references_json),
|
|
485
|
+
status: row.status,
|
|
486
|
+
approved_at: row.approved_at == null ? null : Number(row.approved_at),
|
|
487
|
+
approved_by: row.approved_by == null ? null : row.approved_by,
|
|
488
|
+
rejected_at: row.rejected_at == null ? null : Number(row.rejected_at),
|
|
489
|
+
rejected_by: row.rejected_by == null ? null : row.rejected_by,
|
|
490
|
+
reject_reason: row.reject_reason == null ? null : row.reject_reason,
|
|
491
|
+
vendor_slug: row.vendor_slug == null ? null : row.vendor_slug,
|
|
492
|
+
created_at: Number(row.created_at),
|
|
493
|
+
updated_at: Number(row.updated_at),
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function _hydrateDocument(row) {
|
|
498
|
+
if (!row) return null;
|
|
499
|
+
return {
|
|
500
|
+
id: row.id,
|
|
501
|
+
application_id: row.application_id,
|
|
502
|
+
doc_kind: row.doc_kind,
|
|
503
|
+
status: row.status,
|
|
504
|
+
instructions: row.instructions,
|
|
505
|
+
sha3_512: row.sha3_512 == null ? null : row.sha3_512,
|
|
506
|
+
byte_size: row.byte_size == null ? null : Number(row.byte_size),
|
|
507
|
+
mime_type: row.mime_type == null ? null : row.mime_type,
|
|
508
|
+
uploaded_at: row.uploaded_at == null ? null : Number(row.uploaded_at),
|
|
509
|
+
accepted_at: row.accepted_at == null ? null : Number(row.accepted_at),
|
|
510
|
+
rejected_at: row.rejected_at == null ? null : Number(row.rejected_at),
|
|
511
|
+
reject_reason: row.reject_reason == null ? null : row.reject_reason,
|
|
512
|
+
created_at: Number(row.created_at),
|
|
513
|
+
updated_at: Number(row.updated_at),
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// ---- factory ------------------------------------------------------------
|
|
518
|
+
|
|
519
|
+
function create(opts) {
|
|
520
|
+
opts = opts || {};
|
|
521
|
+
var query = opts.query;
|
|
522
|
+
if (!query) {
|
|
523
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
524
|
+
}
|
|
525
|
+
// The vendors handle is optional at construction. approveApplication
|
|
526
|
+
// requires it — when absent the approve verb refuses with a typed
|
|
527
|
+
// error. Submit / requestDocument / recordDocumentUploaded /
|
|
528
|
+
// rejectApplication / requestRevisions all work without it.
|
|
529
|
+
var vendorsHandle = opts.vendors || null;
|
|
530
|
+
if (vendorsHandle && typeof vendorsHandle.registerVendor !== "function") {
|
|
531
|
+
throw new TypeError("seller-signup.create: opts.vendors must expose registerVendor when provided");
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function _hashEmail(canonical) {
|
|
535
|
+
return _b().crypto.namespaceHash(EMAIL_NAMESPACE, canonical);
|
|
536
|
+
}
|
|
537
|
+
function _hashPhone(normalized) {
|
|
538
|
+
return _b().crypto.namespaceHash(PHONE_NAMESPACE, normalized);
|
|
539
|
+
}
|
|
540
|
+
function _hashTaxId(normalized) {
|
|
541
|
+
return _b().crypto.namespaceHash(TAX_ID_NAMESPACE, normalized);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
async function _getApplicationRaw(id) {
|
|
545
|
+
var r = await query("SELECT * FROM seller_applications WHERE id = ?1", [id]);
|
|
546
|
+
return r.rows[0] || null;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
async function _getDocumentRaw(id) {
|
|
550
|
+
var r = await query("SELECT * FROM seller_application_documents WHERE id = ?1", [id]);
|
|
551
|
+
return r.rows[0] || null;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Compute the next application status after a doc event. When
|
|
555
|
+
// every document is at status='uploaded' (or 'accepted'), the
|
|
556
|
+
// application advances to 'in_review'. While any doc is still
|
|
557
|
+
// 'requested', the application sits at 'docs_pending'.
|
|
558
|
+
async function _statusAfterDocChange(applicationId, priorStatus) {
|
|
559
|
+
if (priorStatus === "approved" || priorStatus === "rejected" || priorStatus === "withdrawn") {
|
|
560
|
+
return priorStatus;
|
|
561
|
+
}
|
|
562
|
+
var docs = (await query(
|
|
563
|
+
"SELECT status FROM seller_application_documents WHERE application_id = ?1",
|
|
564
|
+
[applicationId],
|
|
565
|
+
)).rows;
|
|
566
|
+
if (docs.length === 0) {
|
|
567
|
+
return "submitted";
|
|
568
|
+
}
|
|
569
|
+
var anyRequested = false;
|
|
570
|
+
for (var i = 0; i < docs.length; i += 1) {
|
|
571
|
+
if (docs[i].status === "requested") {
|
|
572
|
+
anyRequested = true;
|
|
573
|
+
break;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
if (anyRequested) return "docs_pending";
|
|
577
|
+
return "in_review";
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
return {
|
|
581
|
+
EMAIL_NAMESPACE: EMAIL_NAMESPACE,
|
|
582
|
+
PHONE_NAMESPACE: PHONE_NAMESPACE,
|
|
583
|
+
TAX_ID_NAMESPACE: TAX_ID_NAMESPACE,
|
|
584
|
+
TAX_ID_KINDS: TAX_ID_KINDS.slice(),
|
|
585
|
+
DOC_KINDS: DOC_KINDS.slice(),
|
|
586
|
+
APPLICATION_STATUSES: APPLICATION_STATUSES.slice(),
|
|
587
|
+
DOCUMENT_STATUSES: DOCUMENT_STATUSES.slice(),
|
|
588
|
+
TERMINAL_APPLICATION_STATUSES: TERMINAL_APPLICATION_STATUSES.slice(),
|
|
589
|
+
MAX_BUSINESS_NAME_LEN: MAX_BUSINESS_NAME_LEN,
|
|
590
|
+
MAX_PHONE_LEN: MAX_PHONE_LEN,
|
|
591
|
+
MAX_TAX_ID_LEN: MAX_TAX_ID_LEN,
|
|
592
|
+
MAX_ADDRESS_JSON_LEN: MAX_ADDRESS_JSON_LEN,
|
|
593
|
+
MAX_CATEGORY_FOCUS_LEN: MAX_CATEGORY_FOCUS_LEN,
|
|
594
|
+
MAX_REFERENCES_JSON_LEN: MAX_REFERENCES_JSON_LEN,
|
|
595
|
+
MAX_INSTRUCTIONS_LEN: MAX_INSTRUCTIONS_LEN,
|
|
596
|
+
MAX_REJECT_REASON_LEN: MAX_REJECT_REASON_LEN,
|
|
597
|
+
MAX_OPERATOR_ID_LEN: MAX_OPERATOR_ID_LEN,
|
|
598
|
+
MAX_MIME_TYPE_LEN: MAX_MIME_TYPE_LEN,
|
|
599
|
+
MAX_FIELDS_LEN: MAX_FIELDS_LEN,
|
|
600
|
+
MAX_FIELD_NAME_LEN: MAX_FIELD_NAME_LEN,
|
|
601
|
+
MAX_LIST_LIMIT: MAX_LIST_LIMIT,
|
|
602
|
+
MAX_VOLUME_MINOR: MAX_VOLUME_MINOR,
|
|
603
|
+
MAX_BYTE_SIZE: MAX_BYTE_SIZE,
|
|
604
|
+
|
|
605
|
+
// Capture an inbound application. Email / phone / tax_id are
|
|
606
|
+
// hashed via namespaceHash so the raw values never land on disk.
|
|
607
|
+
// The application opens at status='submitted' and waits for the
|
|
608
|
+
// operator to call requestDocument; the docs cascade then walks
|
|
609
|
+
// it through docs_pending -> in_review -> approved | rejected.
|
|
610
|
+
submitApplication: async function (input) {
|
|
611
|
+
if (!input || typeof input !== "object") {
|
|
612
|
+
throw new TypeError("seller-signup.submitApplication: input object required");
|
|
613
|
+
}
|
|
614
|
+
var businessName = _businessName(input.business_name);
|
|
615
|
+
var emailNorm = _normalizeEmail(input.contact_email);
|
|
616
|
+
var emailHash = _hashEmail(emailNorm);
|
|
617
|
+
var phoneNorm = _phone(input.contact_phone);
|
|
618
|
+
var phoneHash = _hashPhone(phoneNorm);
|
|
619
|
+
var addressJson = _businessAddress(input.business_address);
|
|
620
|
+
var taxIdKind = _taxIdKind(input.tax_id_kind);
|
|
621
|
+
var taxIdValue = _taxIdValue(input.tax_id_value);
|
|
622
|
+
var taxIdNormalized = taxIdValue.replace(/\s+/g, "").toUpperCase();
|
|
623
|
+
var taxIdHash = _hashTaxId(taxIdNormalized);
|
|
624
|
+
var taxIdLast4 = taxIdNormalized.slice(-4);
|
|
625
|
+
var categoryFocus = _categoryFocus(input.category_focus);
|
|
626
|
+
var volume = _volumeMinor(input.expected_monthly_volume_minor);
|
|
627
|
+
var currency = _currency(input.currency);
|
|
628
|
+
var referencesJson = _referencesJson(input.references_json);
|
|
629
|
+
|
|
630
|
+
var id = _b().uuid.v7();
|
|
631
|
+
var ts = _now();
|
|
632
|
+
await query(
|
|
633
|
+
"INSERT INTO seller_applications " +
|
|
634
|
+
"(id, business_name, contact_email_hash, contact_email_normalised, " +
|
|
635
|
+
" contact_phone_hash, contact_phone_normalised, business_address_json, " +
|
|
636
|
+
" tax_id_kind, tax_id_hash, tax_id_normalised_last4, category_focus_json, " +
|
|
637
|
+
" expected_monthly_volume_minor, currency, references_json, status, " +
|
|
638
|
+
" approved_at, approved_by, rejected_at, rejected_by, reject_reason, " +
|
|
639
|
+
" vendor_slug, created_at, updated_at) " +
|
|
640
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, " +
|
|
641
|
+
" 'submitted', NULL, NULL, NULL, NULL, NULL, NULL, ?15, ?15)",
|
|
642
|
+
[
|
|
643
|
+
id, businessName, emailHash, emailNorm, phoneHash, phoneNorm,
|
|
644
|
+
addressJson, taxIdKind, taxIdHash, taxIdLast4, categoryFocus,
|
|
645
|
+
volume, currency, referencesJson, ts,
|
|
646
|
+
],
|
|
647
|
+
);
|
|
648
|
+
return _hydrateApplication(await _getApplicationRaw(id));
|
|
649
|
+
},
|
|
650
|
+
|
|
651
|
+
// Operator requests a supporting document. The document row
|
|
652
|
+
// lands at status='requested'; the applicant later uploads via
|
|
653
|
+
// recordDocumentUploaded. Multiple distinct doc_kinds may be
|
|
654
|
+
// requested at once. Re-requesting the same doc_kind WHILE the
|
|
655
|
+
// prior request is still open ('requested') refuses — the
|
|
656
|
+
// operator should wait for the upload or escalate via
|
|
657
|
+
// rejectApplication; re-requesting AFTER an upload is the
|
|
658
|
+
// requestRevisions flow.
|
|
659
|
+
requestDocument: async function (input) {
|
|
660
|
+
if (!input || typeof input !== "object") {
|
|
661
|
+
throw new TypeError("seller-signup.requestDocument: input object required");
|
|
662
|
+
}
|
|
663
|
+
var applicationId = _uuid(input.application_id, "application_id");
|
|
664
|
+
var docKind = _docKind(input.doc_kind);
|
|
665
|
+
var instructions = _instructions(input.instructions);
|
|
666
|
+
|
|
667
|
+
var app = await _getApplicationRaw(applicationId);
|
|
668
|
+
if (!app) {
|
|
669
|
+
var miss = new Error("seller-signup.requestDocument: application not found");
|
|
670
|
+
miss.code = "APPLICATION_NOT_FOUND";
|
|
671
|
+
throw miss;
|
|
672
|
+
}
|
|
673
|
+
if (TERMINAL_APPLICATION_STATUSES.indexOf(app.status) !== -1) {
|
|
674
|
+
var term = new Error(
|
|
675
|
+
"seller-signup.requestDocument: refused — application is " + app.status + " (terminal)"
|
|
676
|
+
);
|
|
677
|
+
term.code = "APPLICATION_TERMINAL";
|
|
678
|
+
throw term;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
var open = await query(
|
|
682
|
+
"SELECT id FROM seller_application_documents " +
|
|
683
|
+
"WHERE application_id = ?1 AND doc_kind = ?2 AND status = 'requested'",
|
|
684
|
+
[applicationId, docKind],
|
|
685
|
+
);
|
|
686
|
+
if (open.rows.length) {
|
|
687
|
+
var dup = new Error(
|
|
688
|
+
"seller-signup.requestDocument: refused — doc_kind " + JSON.stringify(docKind) +
|
|
689
|
+
" already has an open request on this application"
|
|
690
|
+
);
|
|
691
|
+
dup.code = "DOCUMENT_REQUEST_OPEN";
|
|
692
|
+
throw dup;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
var id = _b().uuid.v7();
|
|
696
|
+
var ts = _now();
|
|
697
|
+
await query(
|
|
698
|
+
"INSERT INTO seller_application_documents " +
|
|
699
|
+
"(id, application_id, doc_kind, status, instructions, sha3_512, byte_size, " +
|
|
700
|
+
" mime_type, uploaded_at, accepted_at, rejected_at, reject_reason, " +
|
|
701
|
+
" created_at, updated_at) " +
|
|
702
|
+
"VALUES (?1, ?2, ?3, 'requested', ?4, NULL, NULL, NULL, NULL, NULL, NULL, NULL, ?5, ?5)",
|
|
703
|
+
[id, applicationId, docKind, instructions, ts],
|
|
704
|
+
);
|
|
705
|
+
|
|
706
|
+
// Advance application FSM to docs_pending if it was submitted
|
|
707
|
+
// or revisions_requested (the operator can transition into
|
|
708
|
+
// docs_pending by requesting any document).
|
|
709
|
+
var nextStatus = await _statusAfterDocChange(applicationId, app.status);
|
|
710
|
+
if (nextStatus !== app.status) {
|
|
711
|
+
await query(
|
|
712
|
+
"UPDATE seller_applications SET status = ?1, updated_at = ?2 WHERE id = ?3",
|
|
713
|
+
[nextStatus, ts, applicationId],
|
|
714
|
+
);
|
|
715
|
+
} else {
|
|
716
|
+
await query(
|
|
717
|
+
"UPDATE seller_applications SET updated_at = ?1 WHERE id = ?2",
|
|
718
|
+
[ts, applicationId],
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
return _hydrateDocument(await _getDocumentRaw(id));
|
|
723
|
+
},
|
|
724
|
+
|
|
725
|
+
// Applicant uploads the doc. Status moves from requested ->
|
|
726
|
+
// uploaded; sha3_512 + byte_size + mime_type are captured.
|
|
727
|
+
// Refuses when the doc isn't at status='requested' (idempotent
|
|
728
|
+
// hazard — a retry would clobber the original upload's
|
|
729
|
+
// sha3_512 + byte_size + mime_type). Application FSM advances
|
|
730
|
+
// to in_review when this was the last outstanding requested doc.
|
|
731
|
+
recordDocumentUploaded: async function (input) {
|
|
732
|
+
if (!input || typeof input !== "object") {
|
|
733
|
+
throw new TypeError("seller-signup.recordDocumentUploaded: input object required");
|
|
734
|
+
}
|
|
735
|
+
var applicationId = _uuid(input.application_id, "application_id");
|
|
736
|
+
var docKind = _docKind(input.doc_kind);
|
|
737
|
+
var sha3 = _sha3_512(input.sha3_512);
|
|
738
|
+
var byteSize = _byteSize(input.byte_size);
|
|
739
|
+
var mimeType = _mimeType(input.mime_type);
|
|
740
|
+
|
|
741
|
+
var app = await _getApplicationRaw(applicationId);
|
|
742
|
+
if (!app) {
|
|
743
|
+
var miss = new Error("seller-signup.recordDocumentUploaded: application not found");
|
|
744
|
+
miss.code = "APPLICATION_NOT_FOUND";
|
|
745
|
+
throw miss;
|
|
746
|
+
}
|
|
747
|
+
if (TERMINAL_APPLICATION_STATUSES.indexOf(app.status) !== -1) {
|
|
748
|
+
var term = new Error(
|
|
749
|
+
"seller-signup.recordDocumentUploaded: refused — application is " + app.status + " (terminal)"
|
|
750
|
+
);
|
|
751
|
+
term.code = "APPLICATION_TERMINAL";
|
|
752
|
+
throw term;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// Find the open request for (application, doc_kind).
|
|
756
|
+
var open = await query(
|
|
757
|
+
"SELECT * FROM seller_application_documents " +
|
|
758
|
+
"WHERE application_id = ?1 AND doc_kind = ?2 AND status = 'requested' " +
|
|
759
|
+
"ORDER BY created_at DESC LIMIT 1",
|
|
760
|
+
[applicationId, docKind],
|
|
761
|
+
);
|
|
762
|
+
if (!open.rows.length) {
|
|
763
|
+
var noOpen = new Error(
|
|
764
|
+
"seller-signup.recordDocumentUploaded: refused — no open request for doc_kind " +
|
|
765
|
+
JSON.stringify(docKind) + " on this application"
|
|
766
|
+
);
|
|
767
|
+
noOpen.code = "NO_OPEN_REQUEST";
|
|
768
|
+
throw noOpen;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
var ts = _now();
|
|
772
|
+
await query(
|
|
773
|
+
"UPDATE seller_application_documents " +
|
|
774
|
+
"SET status = 'uploaded', sha3_512 = ?1, byte_size = ?2, mime_type = ?3, " +
|
|
775
|
+
" uploaded_at = ?4, updated_at = ?4 " +
|
|
776
|
+
"WHERE id = ?5",
|
|
777
|
+
[sha3, byteSize, mimeType, ts, open.rows[0].id],
|
|
778
|
+
);
|
|
779
|
+
|
|
780
|
+
var nextStatus = await _statusAfterDocChange(applicationId, app.status);
|
|
781
|
+
if (nextStatus !== app.status) {
|
|
782
|
+
await query(
|
|
783
|
+
"UPDATE seller_applications SET status = ?1, updated_at = ?2 WHERE id = ?3",
|
|
784
|
+
[nextStatus, ts, applicationId],
|
|
785
|
+
);
|
|
786
|
+
} else {
|
|
787
|
+
await query(
|
|
788
|
+
"UPDATE seller_applications SET updated_at = ?1 WHERE id = ?2",
|
|
789
|
+
[ts, applicationId],
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
return _hydrateDocument(await _getDocumentRaw(open.rows[0].id));
|
|
793
|
+
},
|
|
794
|
+
|
|
795
|
+
// Operator approves the application. Composes
|
|
796
|
+
// vendors.registerVendor to mint the real vendor row, then
|
|
797
|
+
// transitions the application to approved with the resulting
|
|
798
|
+
// vendor_slug captured. Refuses if the application is already
|
|
799
|
+
// terminal, if any document is still 'requested', or if no
|
|
800
|
+
// vendors handle was wired at construction. The vendor's
|
|
801
|
+
// contact_email + contact_phone + address are filled from the
|
|
802
|
+
// application's normalised columns; commission_split_bps +
|
|
803
|
+
// payout_method + payout_address are operator decisions
|
|
804
|
+
// captured separately (the operator passes them via the
|
|
805
|
+
// vendor_slug call chain, but the v1 surface assumes operator-
|
|
806
|
+
// sane defaults — caller supplies vendor_slug here, the
|
|
807
|
+
// primitive registers with payout_method='bank_transfer' +
|
|
808
|
+
// commission_split_bps=7000 and the operator updates via
|
|
809
|
+
// vendors.updateVendor afterwards).
|
|
810
|
+
approveApplication: async function (input) {
|
|
811
|
+
if (!input || typeof input !== "object") {
|
|
812
|
+
throw new TypeError("seller-signup.approveApplication: input object required");
|
|
813
|
+
}
|
|
814
|
+
var applicationId = _uuid(input.application_id, "application_id");
|
|
815
|
+
var approvedBy = _operatorId(input.approved_by, "approved_by");
|
|
816
|
+
if (typeof input.vendor_slug !== "string" || !input.vendor_slug.length) {
|
|
817
|
+
throw new TypeError("seller-signup.approveApplication: vendor_slug must be a non-empty string");
|
|
818
|
+
}
|
|
819
|
+
var vendorSlug = input.vendor_slug;
|
|
820
|
+
|
|
821
|
+
if (!vendorsHandle) {
|
|
822
|
+
var noVendors = new Error(
|
|
823
|
+
"seller-signup.approveApplication: refused — opts.vendors was not wired at construction; " +
|
|
824
|
+
"the approve verb composes vendors.registerVendor and cannot run without it"
|
|
825
|
+
);
|
|
826
|
+
noVendors.code = "VENDORS_HANDLE_MISSING";
|
|
827
|
+
throw noVendors;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
var app = await _getApplicationRaw(applicationId);
|
|
831
|
+
if (!app) {
|
|
832
|
+
var miss = new Error("seller-signup.approveApplication: application not found");
|
|
833
|
+
miss.code = "APPLICATION_NOT_FOUND";
|
|
834
|
+
throw miss;
|
|
835
|
+
}
|
|
836
|
+
if (TERMINAL_APPLICATION_STATUSES.indexOf(app.status) !== -1) {
|
|
837
|
+
var term = new Error(
|
|
838
|
+
"seller-signup.approveApplication: refused — application is " + app.status + " (terminal)"
|
|
839
|
+
);
|
|
840
|
+
term.code = "APPLICATION_TERMINAL";
|
|
841
|
+
throw term;
|
|
842
|
+
}
|
|
843
|
+
if (app.status !== "in_review") {
|
|
844
|
+
var notReady = new Error(
|
|
845
|
+
"seller-signup.approveApplication: refused — application is " + app.status +
|
|
846
|
+
"; approve requires status='in_review' (all requested documents uploaded)"
|
|
847
|
+
);
|
|
848
|
+
notReady.code = "APPLICATION_NOT_READY";
|
|
849
|
+
throw notReady;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// Compose vendors.registerVendor — the operator-facing v1
|
|
853
|
+
// defaults a fresh marketplace seller to bank_transfer payout +
|
|
854
|
+
// 70% commission split; the operator tunes both via
|
|
855
|
+
// vendors.updateVendor after approval.
|
|
856
|
+
var vendor;
|
|
857
|
+
try {
|
|
858
|
+
vendor = await vendorsHandle.registerVendor({
|
|
859
|
+
slug: vendorSlug,
|
|
860
|
+
name: app.business_name,
|
|
861
|
+
contact_email: app.contact_email_normalised,
|
|
862
|
+
contact_phone: app.contact_phone_normalised,
|
|
863
|
+
address: JSON.parse(app.business_address_json),
|
|
864
|
+
payout_method: "bank_transfer",
|
|
865
|
+
payout_address: "pending-operator-update",
|
|
866
|
+
commission_split_bps: 7000,
|
|
867
|
+
status: "active",
|
|
868
|
+
});
|
|
869
|
+
} catch (e) {
|
|
870
|
+
// Surface the vendors error with our typed-code wrapper so
|
|
871
|
+
// operators can distinguish a vendors-layer refusal from an
|
|
872
|
+
// application-layer refusal.
|
|
873
|
+
var wrapped = new Error(
|
|
874
|
+
"seller-signup.approveApplication: vendors.registerVendor refused — " +
|
|
875
|
+
(e && e.message || "unknown")
|
|
876
|
+
);
|
|
877
|
+
wrapped.code = "VENDORS_REGISTER_REFUSED";
|
|
878
|
+
wrapped.cause = e;
|
|
879
|
+
throw wrapped;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
var ts = _now();
|
|
883
|
+
await query(
|
|
884
|
+
"UPDATE seller_applications " +
|
|
885
|
+
"SET status = 'approved', approved_at = ?1, approved_by = ?2, " +
|
|
886
|
+
" vendor_slug = ?3, updated_at = ?1 " +
|
|
887
|
+
"WHERE id = ?4",
|
|
888
|
+
[ts, approvedBy, vendor.slug, applicationId],
|
|
889
|
+
);
|
|
890
|
+
|
|
891
|
+
return {
|
|
892
|
+
application: _hydrateApplication(await _getApplicationRaw(applicationId)),
|
|
893
|
+
vendor: vendor,
|
|
894
|
+
};
|
|
895
|
+
},
|
|
896
|
+
|
|
897
|
+
// Operator rejects the application. Terminal — no transitions
|
|
898
|
+
// out. The reason is captured for audit. Open document requests
|
|
899
|
+
// are NOT auto-closed (they remain at status='requested') so
|
|
900
|
+
// the audit trail records what was asked for; downstream
|
|
901
|
+
// operator-side erasure flows handle physical cleanup.
|
|
902
|
+
rejectApplication: async function (input) {
|
|
903
|
+
if (!input || typeof input !== "object") {
|
|
904
|
+
throw new TypeError("seller-signup.rejectApplication: input object required");
|
|
905
|
+
}
|
|
906
|
+
var applicationId = _uuid(input.application_id, "application_id");
|
|
907
|
+
var reason = _rejectReason(input.reason);
|
|
908
|
+
var rejectedBy = _operatorId(input.rejected_by, "rejected_by");
|
|
909
|
+
|
|
910
|
+
var app = await _getApplicationRaw(applicationId);
|
|
911
|
+
if (!app) {
|
|
912
|
+
var miss = new Error("seller-signup.rejectApplication: application not found");
|
|
913
|
+
miss.code = "APPLICATION_NOT_FOUND";
|
|
914
|
+
throw miss;
|
|
915
|
+
}
|
|
916
|
+
if (TERMINAL_APPLICATION_STATUSES.indexOf(app.status) !== -1) {
|
|
917
|
+
var term = new Error(
|
|
918
|
+
"seller-signup.rejectApplication: refused — application is " + app.status + " (terminal)"
|
|
919
|
+
);
|
|
920
|
+
term.code = "APPLICATION_TERMINAL";
|
|
921
|
+
throw term;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
var ts = _now();
|
|
925
|
+
await query(
|
|
926
|
+
"UPDATE seller_applications " +
|
|
927
|
+
"SET status = 'rejected', rejected_at = ?1, rejected_by = ?2, " +
|
|
928
|
+
" reject_reason = ?3, updated_at = ?1 " +
|
|
929
|
+
"WHERE id = ?4",
|
|
930
|
+
[ts, rejectedBy, reason, applicationId],
|
|
931
|
+
);
|
|
932
|
+
return _hydrateApplication(await _getApplicationRaw(applicationId));
|
|
933
|
+
},
|
|
934
|
+
|
|
935
|
+
// Operator asks the applicant to refile something. Re-opens the
|
|
936
|
+
// application for additional doc cycles: transitions status to
|
|
937
|
+
// 'revisions_requested'; the operator follows up with one or
|
|
938
|
+
// more requestDocument calls that cascade the application back
|
|
939
|
+
// into 'docs_pending'. `fields` is the list of operator-facing
|
|
940
|
+
// field names the applicant needs to revise (rendered in the
|
|
941
|
+
// applicant's revision UI); `instructions` is a free-text
|
|
942
|
+
// explanation. Refuses on terminal statuses.
|
|
943
|
+
requestRevisions: async function (input) {
|
|
944
|
+
if (!input || typeof input !== "object") {
|
|
945
|
+
throw new TypeError("seller-signup.requestRevisions: input object required");
|
|
946
|
+
}
|
|
947
|
+
var applicationId = _uuid(input.application_id, "application_id");
|
|
948
|
+
var fields = _fieldsArray(input.fields);
|
|
949
|
+
var instructions = _instructions(input.instructions);
|
|
950
|
+
|
|
951
|
+
var app = await _getApplicationRaw(applicationId);
|
|
952
|
+
if (!app) {
|
|
953
|
+
var miss = new Error("seller-signup.requestRevisions: application not found");
|
|
954
|
+
miss.code = "APPLICATION_NOT_FOUND";
|
|
955
|
+
throw miss;
|
|
956
|
+
}
|
|
957
|
+
if (TERMINAL_APPLICATION_STATUSES.indexOf(app.status) !== -1) {
|
|
958
|
+
var term = new Error(
|
|
959
|
+
"seller-signup.requestRevisions: refused — application is " + app.status + " (terminal)"
|
|
960
|
+
);
|
|
961
|
+
term.code = "APPLICATION_TERMINAL";
|
|
962
|
+
throw term;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
var ts = _now();
|
|
966
|
+
await query(
|
|
967
|
+
"UPDATE seller_applications SET status = 'revisions_requested', updated_at = ?1 WHERE id = ?2",
|
|
968
|
+
[ts, applicationId],
|
|
969
|
+
);
|
|
970
|
+
return {
|
|
971
|
+
application: _hydrateApplication(await _getApplicationRaw(applicationId)),
|
|
972
|
+
fields: fields,
|
|
973
|
+
instructions: instructions,
|
|
974
|
+
};
|
|
975
|
+
},
|
|
976
|
+
|
|
977
|
+
getApplication: async function (id) {
|
|
978
|
+
var applicationId = _uuid(id, "id");
|
|
979
|
+
return _hydrateApplication(await _getApplicationRaw(applicationId));
|
|
980
|
+
},
|
|
981
|
+
|
|
982
|
+
// Operator inbox list. Optionally filter by status. Cursor is
|
|
983
|
+
// a millisecond epoch — list rows older than the cursor.
|
|
984
|
+
// Mirrors the shape used by loyalty-redemption / customer-notes.
|
|
985
|
+
listApplications: async function (listOpts) {
|
|
986
|
+
listOpts = listOpts || {};
|
|
987
|
+
var limit = listOpts.limit == null ? 50 : listOpts.limit;
|
|
988
|
+
_listLimit(limit);
|
|
989
|
+
|
|
990
|
+
var sql = "SELECT * FROM seller_applications";
|
|
991
|
+
var params = [];
|
|
992
|
+
var where = [];
|
|
993
|
+
if (listOpts.status != null) {
|
|
994
|
+
var status = _applicationStatus(listOpts.status);
|
|
995
|
+
where.push("status = ?" + (params.length + 1));
|
|
996
|
+
params.push(status);
|
|
997
|
+
}
|
|
998
|
+
if (listOpts.cursor != null) {
|
|
999
|
+
if (!Number.isInteger(listOpts.cursor) || listOpts.cursor < 0) {
|
|
1000
|
+
throw new TypeError("seller-signup.listApplications: cursor must be a non-negative integer (ms epoch)");
|
|
1001
|
+
}
|
|
1002
|
+
where.push("created_at < ?" + (params.length + 1));
|
|
1003
|
+
params.push(listOpts.cursor);
|
|
1004
|
+
}
|
|
1005
|
+
if (where.length) sql += " WHERE " + where.join(" AND ");
|
|
1006
|
+
sql += " ORDER BY created_at DESC, id DESC LIMIT ?" + (params.length + 1);
|
|
1007
|
+
params.push(limit);
|
|
1008
|
+
|
|
1009
|
+
var rows = (await query(sql, params)).rows;
|
|
1010
|
+
var hydrated = rows.map(_hydrateApplication);
|
|
1011
|
+
var nextCursor = hydrated.length === limit ? hydrated[hydrated.length - 1].created_at : null;
|
|
1012
|
+
return { rows: hydrated, next_cursor: nextCursor };
|
|
1013
|
+
},
|
|
1014
|
+
|
|
1015
|
+
documentsForApplication: async function (applicationId) {
|
|
1016
|
+
var id = _uuid(applicationId, "application_id");
|
|
1017
|
+
var r = await query(
|
|
1018
|
+
"SELECT * FROM seller_application_documents WHERE application_id = ?1 " +
|
|
1019
|
+
"ORDER BY created_at ASC, id ASC",
|
|
1020
|
+
[id],
|
|
1021
|
+
);
|
|
1022
|
+
return r.rows.map(_hydrateDocument);
|
|
1023
|
+
},
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
module.exports = {
|
|
1028
|
+
create: create,
|
|
1029
|
+
EMAIL_NAMESPACE: EMAIL_NAMESPACE,
|
|
1030
|
+
PHONE_NAMESPACE: PHONE_NAMESPACE,
|
|
1031
|
+
TAX_ID_NAMESPACE: TAX_ID_NAMESPACE,
|
|
1032
|
+
TAX_ID_KINDS: TAX_ID_KINDS.slice(),
|
|
1033
|
+
DOC_KINDS: DOC_KINDS.slice(),
|
|
1034
|
+
APPLICATION_STATUSES: APPLICATION_STATUSES.slice(),
|
|
1035
|
+
DOCUMENT_STATUSES: DOCUMENT_STATUSES.slice(),
|
|
1036
|
+
TERMINAL_APPLICATION_STATUSES: TERMINAL_APPLICATION_STATUSES.slice(),
|
|
1037
|
+
MAX_BUSINESS_NAME_LEN: MAX_BUSINESS_NAME_LEN,
|
|
1038
|
+
MAX_PHONE_LEN: MAX_PHONE_LEN,
|
|
1039
|
+
MAX_TAX_ID_LEN: MAX_TAX_ID_LEN,
|
|
1040
|
+
MAX_ADDRESS_JSON_LEN: MAX_ADDRESS_JSON_LEN,
|
|
1041
|
+
MAX_CATEGORY_FOCUS_LEN: MAX_CATEGORY_FOCUS_LEN,
|
|
1042
|
+
MAX_REFERENCES_JSON_LEN: MAX_REFERENCES_JSON_LEN,
|
|
1043
|
+
MAX_INSTRUCTIONS_LEN: MAX_INSTRUCTIONS_LEN,
|
|
1044
|
+
MAX_REJECT_REASON_LEN: MAX_REJECT_REASON_LEN,
|
|
1045
|
+
MAX_OPERATOR_ID_LEN: MAX_OPERATOR_ID_LEN,
|
|
1046
|
+
MAX_MIME_TYPE_LEN: MAX_MIME_TYPE_LEN,
|
|
1047
|
+
MAX_FIELDS_LEN: MAX_FIELDS_LEN,
|
|
1048
|
+
MAX_FIELD_NAME_LEN: MAX_FIELD_NAME_LEN,
|
|
1049
|
+
MAX_LIST_LIMIT: MAX_LIST_LIMIT,
|
|
1050
|
+
MAX_VOLUME_MINOR: MAX_VOLUME_MINOR,
|
|
1051
|
+
MAX_BYTE_SIZE: MAX_BYTE_SIZE,
|
|
1052
|
+
};
|