Setting the file. One moment.
Invoice Discovery · Rp Source Wordpress · wix/skills · Skills Docs
ContentsBack to the top of the page 39.41
PLUGIN READ PLANS
This file
Number 39.18
Position 18 of 92
Type JavaScript
Size 21 KB
Lines 426 lib/ invoice-discovery.js
JavaScript · 426 lines · 21 KB
15 // store and per plugin version -- a provider key can carry a trailing capital, as in
16 // `payplus_invoice_numberD`. So discovery matches key FAMILIES rather than authored key names, and
17 // a pattern found in a sample is then run across the full in-scope order population. A
18 // sample-only pass reports a fraction of the documents and calls it complete.
19 // 2. A DISCOVERED URL IS UNTRUSTED INPUT. It comes from source-site metadata, and something
20 // will eventually fetch it. So it is classified before anything touches it: HTTPS only, no
21 // embedded credentials, no private or loopback address, port 443 only, bounded redirects,
22 // bounded time and size. A hosted-document URL is also a bearer link whose filename can
23 // carry the customer's name and the invoice number, so it is never logged and never stored.
24
25 const INVOICE_KEY_PATTERNS = [
26 { family: 'invoice-number' , pattern: /invoice . * number | number . * invoice | invoice_ ? no \b / i },
27 { family: 'invoice-document' , pattern: /invoice . * (document | doc | pdf | file | url | link) | (document | pdf)_ ? invoice/ i },
28 { family: 'document-address' , pattern: /doc(ument) ? _ ? address | address_ ? doc(ument) ? / i },
29 { family: 'invoice-type' , pattern: /invoice . * type | type . * invoice/ i },
30 ];
31
32 // Signal A -- rows kept by a WordPress-side invoicing plugin in tables of its OWN -- has nothing
33 // to query yet, and this module deliberately does not pretend otherwise. The table names that
34 // used to sit here (`wcpdf_invoice_number` and friends) are META KEY names, not tables: the common
35 // PDF-invoice plugin stores its numbers in order meta, so it is read through Signal B. A
36 // recognized-table list will be worth having when a plugin profile establishes a real row-bearing
37 // table and something composes the row counts into strategy selection; inventing one from
38 // meta-key-shaped strings only made the gap harder to see.
39 //
40 // A caller that discovers an unrecognized invoice-shaped table should report it as a finding.
41
42 const {
43 isPrivateHostname ,
44 classifyDocumentUrl ,
45 redactUrlForLog ,
46 } = require ( '../../../lib/document-url-guard.js' );
47
48 const STRATEGIES = {
49 NONE_FOUND: 'none-found' ,
50 NUMBER_ONLY: 'number-only' ,
51 WORDPRESS_HOSTED: 'wordpress-hosted' ,
52 THIRD_PARTY_HOSTED: 'third-party-hosted' ,
53 MIXED: 'mixed' ,
54 };
55
56 function classifyMetaKey ( key ) {
57 if ( ! key) return null ;
58 for ( const entry of INVOICE_KEY_PATTERNS ) {
59 if (entry.pattern. test (key)) return entry.family;
60 }
61 return null ;
62 }
63
64 // Discovery over a sample: which key families this store actually uses, and under which concrete
65 // key names. The names are returned so the population pass can read exactly those keys rather
66 // than re-matching patterns per order.
67 function discoverInvoicePatterns ( sampleOrders = []) {
68 const keysByFamily = new Map ();
69 for ( const order of sampleOrders) {
70 for ( const key of Object. keys (order.meta || {})) {
71 const family = classifyMetaKey (key);
72 if ( ! family) continue ;
73 const value = order.meta[key];
74 if (value === null || value === undefined || String (value). trim () === '' ) continue ;
75 if ( ! keysByFamily. has (family)) keysByFamily. set (family, new Set ());
76 keysByFamily. get (family). add (key);
77 }
78 }
79 const families = [ ... keysByFamily. keys ()]. sort ();
80 const keys = [ ...new Set ([ ... keysByFamily. values ()]. flatMap (( set ) => [ ... set]))]. sort ();
81 return {
82 families,
83 keys,
84 sampled: sampleOrders. length ,
85 // A sample that found nothing is not evidence that the population has nothing; it is a
86 // reason to say so in the report rather than to conclude "no invoices".
87 conclusive: keys. length > 0 ,
88 };
89 }
90
91 function firstPopulated ( meta , keys ) {
92 for ( const key of keys) {
93 const value = meta && meta[key];
94 if (value === null || value === undefined ) continue ;
95 const text = String (value). trim ();
96 if (text. length > 0 ) return { key, value: text };
97 }
98 return null ;
99 }
100
101 // Which of several document-family keys actually holds the document.
102 //
103 // A store does not carry one document key, it carries a cluster. A PayPlus store carries
104 // four: `copyDocAddress`, `docUID`, `originalDocAddress`, `plus_docs`. They are not
105 // interchangeable -- one is a URL to the ORIGINAL tax receipt, one is a URL to a COPY of it, one
106 // is a bare identifier, and one is a blob. Picking by key-name order picked the copy, because
107 // "copy" sorts before "original"; alphabetical order was deciding which legal document got
108 // archived. On an Israeli tax receipt original-versus-copy is not a cosmetic distinction.
109 //
110 // So selection looks at the VALUE first: a key whose value is not an http(s) URL cannot be the
111 // document, however document-shaped its name is. `docUID` is excluded here rather than surviving
112 // to the URL guard, where it would be rejected as malformed and reported as "no document found"
113 // while the real URL sat unread in the next key.
114 //
115 // Among the keys that do hold URLs, name hints break the tie, and the losers are KEPT rather than
116 // dropped -- see selectDocument below, where they remain as ordered fallbacks.
117 //
118 // The hints match whole words in the key, not substrings anywhere in it. Matching `/copy/` against
119 // the raw key demoted `copyright_document_url`, and `/original/` promoted
120 // `invoice_original_preview_url` -- a thumbnail -- above the real receipt. A key is split on
121 // separators and camelCase humps and the hint has to BE one of the resulting words.
122 //
123 // These are heuristics over a naming convention nobody agreed to, so they only ever break ties
124 // between candidates that are all real URLs; none of them can exclude a candidate.
125 const DOCUMENT_WORD_RANK = new Map ([
126 [ 'original' , 0 ],
127 [ 'copy' , 2 ], [ 'duplicate' , 2 ],
128 // A preview or thumbnail is a picture OF the document, not the document. Ranked below the copy.
129 [ 'preview' , 3 ], [ 'thumb' , 3 ], [ 'thumbnail' , 3 ], [ 'sample' , 3 ],
130 // Documents a store attaches to orders that are not the invoice. Word-splitting alone was not
131 // enough for these: with no hint firing they landed on the neutral rank and won ties
132 // alphabetically, so `invoice_copyright_notice_url` still beat `invoice_document_url`. They are
133 // ranked last instead -- still eligible, because a mis-named key is better than no document,
134 // but only if nothing else is there.
135 [ 'copyright' , 4 ], [ 'notice' , 4 ], [ 'terms' , 4 ], [ 'policy' , 4 ], [ 'license' , 4 ], [ 'licence' , 4 ],
136 ]);
137 const NEUTRAL_RANK = 1 ; // Unhinted keys sit between "original" and "copy".
138
139 function looksLikeUrl ( text ) {
140 return / ^ https ? : \/\/ / i . test (text);
141 }
142
143 // `copyDocAddress` -> copy, doc, address. `invoice_original_preview_url` -> invoice, original,
144 // preview, url. `copyright` -> copyright, which is deliberately NOT the word "copy".
145 //
146 // Acronyms need their own boundary. Splitting on lower-to-upper alone left `invoicePDFPreviewURL`
147 // as one word `pdfpreviewurl`, so "preview" never matched and a thumbnail outranked the document.
148 // Plugin keys are full of these: PDF, URL, ID, HTML.
149 function wordsInKey ( key ) {
150 return String (key)
151 . replace ( /( [A-Z] + )( [A-Z][a-z] )/ g , '$1 $2' ) // PDFPreview -> PDF Preview
152 . replace ( /( [a-z0-9] )( [A-Z] )/ g , '$1 $2' ) // copyDoc -> copy Doc
153 . split ( / [ ^ A-Za-z0-9] + / )
154 . filter (Boolean)
155 . map (( word ) => word. toLowerCase ());
156 }
157
158 function rankDocumentKey ( key ) {
159 // The worst hint present wins: a key naming both "original" and "preview" is a preview OF the
160 // original, and a preview is not the document.
161 const hinted = wordsInKey (key)
162 . map (( word ) => DOCUMENT_WORD_RANK . get (word))
163 . filter (( rank ) => rank !== undefined );
164 return hinted. length === 0 ? NEUTRAL_RANK : Math. max ( ... hinted);
165 }
166
167 function selectDocument ( meta , keys ) {
168 const populated = [];
169 for ( const key of keys) {
170 const value = meta && meta[key];
171 if (value === null || value === undefined ) continue ;
172 const text = String (value). trim ();
173 if (text. length === 0 ) continue ;
174 populated. push ({ key, value: text, url: looksLikeUrl (text) });
175 }
176 const urls = populated. filter (( entry ) => entry.url);
177 if (urls. length === 0 ) {
178 return {
179 candidates: [],
180 // Document-shaped keys that hold something other than a URL. Worth reporting: it is the
181 // difference between "this store has no invoice documents" and "we could not read them".
182 nonUrlKeys: populated. map (( entry ) => entry.key),
183 };
184 }
185 urls. sort (( a , b ) => rankDocumentKey (a.key) - rankDocumentKey (b.key) || a.key. localeCompare (b.key));
186 // Every URL candidate, best first. Selection is NOT finished here: `looksLikeUrl` only knows
187 // that a string starts with http(s), and the real guard -- which checks the host, the path and
188 // the extension -- runs a stage later. Returning one winner meant a malformed `originalDocUrl`
189 // beat a perfectly good `docUrl` on its name, got refused downstream, and the order was
190 // reported as having no document while the usable URL sat unread in the alternatives.
191 return {
192 candidates: urls,
193 nonUrlKeys: populated. filter (( entry ) => ! entry.url). map (( entry ) => entry.key),
194 };
195 }
196
197 // The population pass. Applies the discovered key names to EVERY in-scope order.
198 function applyPatternsToPopulation ( orders = [], discovered = { keys: [] }) {
199 const numberKeys = discovered.keys. filter (( key ) => classifyMetaKey (key) === 'invoice-number' );
200 const documentKeys = discovered.keys. filter (( key ) => {
201 const family = classifyMetaKey (key);
202 return family === 'invoice-document' || family === 'document-address' ;
203 });
204
205 const records = [];
206 for ( const order of orders) {
207 const meta = order.meta || {};
208 const number = firstPopulated (meta, numberKeys);
209 const document = selectDocument (meta, documentKeys);
210 const best = document.candidates[ 0 ] || null ;
211 if ( ! number && ! best && document.nonUrlKeys. length === 0 ) continue ;
212 records. push ({
213 orderId: order.id,
214 number: number ? number.value : null ,
215 // The provisional pick. classifyRecord may move to a lower-ranked candidate if this one
216 // turns out not to survive the URL guard, and rewrites these two fields when it does.
217 documentUrl: best ? best.value : null ,
218 documentKey: best ? best.key : null ,
219 // Every candidate in rank order, so the choice is auditable and the fallback is possible.
220 documentCandidates: document.candidates. map (( entry ) => ({ key: entry.key, value: entry.value })),
221 documentAlternatives: document.candidates. slice ( 1 ). map (( entry ) => entry.key),
222 nonUrlDocumentKeys: document.nonUrlKeys,
223 });
224 }
225 return {
226 records,
227 scanned: orders. length ,
228 withNumber: records. filter (( r ) => r.number). length ,
229 withDocument: records. filter (( r ) => r.documentUrl). length ,
230 // Orders carrying a document-shaped key whose value is not a URL and no URL anywhere: these
231 // are unread, not absent, and the report must not round them down to "no invoices".
232 withUnreadableDocument: records. filter (( r ) => ! r.documentUrl && r.nonUrlDocumentKeys. length > 0 ). length ,
233 };
234 }
235
236 // The strategy table. Signal D alone selects the action, because parked-versus-decommissioned is
237 // unknowable at migration time: the source site is always assumed to be going away.
238 //
239 // The action is decided PER DOCUMENT, not once for the store. A store can hold some invoices on
240 // its own filesystem and others on a provider's, and those want opposite treatment -- the first
241 // group is about to be deleted, the second is somebody else's archive. An earlier version picked
242 // one winning strategy and returned only that class of document, so a single WordPress-hosted PDF
243 // silently excluded every provider-hosted one even after the merchant opted in.
244 // Named from the shared registry, so the plan and the writer that consumes its approvals cannot
245 // drift onto two vocabularies that merely happen to agree today.
246 const { PLAN_ACTIONS } = require ( '../../../lib/payments-outcome-registry.js' );
247 const approvalStamp = require ( '../../../lib/preservation-approval-stamp.js' );
248 const ACTIONS = Object. freeze ({
249 PRESERVE: PLAN_ACTIONS [ 0 ],
250 OFFER_PRESERVATION: PLAN_ACTIONS [ 1 ],
251 REFERENCE_ONLY: PLAN_ACTIONS [ 2 ],
252 REFUSED: PLAN_ACTIONS [ 3 ],
253 });
254
255 // A candidate that was not used, described without reproducing it. `redactUrlForLog` is what the
256 // unsafe report already uses, so one rule covers every place a rejected URL can surface.
257 function describeRejected ( entry ) {
258 return {
259 key: entry.candidate.key,
260 reason: entry.classification.reason,
261 redactedUrl: redactUrlForLog (entry.candidate.value),
262 };
263 }
264
265 function classifyRecord ( record , sourceSiteHost ) {
266 // Rank order first, but a name hint is not evidence: walk the candidates and take the best one
267 // that the guard actually passes. Only if none of them passes is the order refused, and then on
268 // the highest-ranked candidate's reason -- the one the merchant would look for.
269 const candidates = Array. isArray (record.documentCandidates) && record.documentCandidates. length > 0
270 ? record.documentCandidates
271 : (record.documentUrl ? [{ key: record.documentKey || null , value: record.documentUrl }] : []);
272
273 if (candidates. length === 0 ) {
274 // Nothing importable either way, but the two cases are not the same thing and the report must
275 // not merge them: "this order has only a number" versus "this order has document keys whose
276 // values we could not read". The second is a gap in our reading, not in the store's data.
277 const unreadable = Array. isArray (record.nonUrlDocumentKeys) && record.nonUrlDocumentKeys. length > 0 ;
278 return {
279 ... record,
280 classification: null ,
281 action: ACTIONS . REFERENCE_ONLY ,
282 reason: unreadable ? 'document-unreadable' : 'number-only' ,
283 };
284 }
285
286 const tried = candidates. map (( candidate ) => ({
287 candidate,
288 classification: classifyDocumentUrl (candidate.value, { sourceSiteHost }),
289 }));
290 const usable = tried. find (( entry ) => entry.classification.safe);
291
292 if ( ! usable) {
293 const first = tried[ 0 ];
294 return {
295 ... record,
296 documentCandidates: undefined ,
297 documentUrl: first.candidate.value,
298 documentKey: first.candidate.key,
299 classification: first.classification,
300 action: ACTIONS . REFUSED ,
301 reason: first.classification.reason,
302 // Named so the report can say "all four candidates were refused", not just the one.
303 refusedCandidates: tried. map (( entry ) => describeRejected (entry)),
304 };
305 }
306
307 const chosen = {
308 ... record,
309 // The candidate VALUES are an input to classification, not an output of it. Carrying them
310 // through put every rejected URL -- tokens, customer names and all -- inside the approval that
311 // the plan hands on and anything downstream may log. The one URL that survives is the one
312 // deliberately chosen for this order; the rest are named and redacted.
313 documentCandidates: undefined ,
314 documentUrl: usable.candidate.value,
315 documentKey: usable.candidate.key,
316 documentAlternatives: candidates. filter (( c ) => c !== usable.candidate). map (( c ) => c.key),
317 // Better-named candidates that the guard rejected. A merchant seeing the copy preserved when
318 // an "original" key existed can find out here why the original was not the one used.
319 skippedCandidates: tried. slice ( 0 , tried. indexOf (usable)). map (( entry ) => describeRejected (entry)),
320 classification: usable.classification,
321 };
322 if (usable.classification.location === 'wordpress' ) {
323 // These bytes die with the source site, so they are re-hosted without asking.
324 return { ... chosen, action: ACTIONS . PRESERVE , reason: 'wordpress-hosted' };
325 }
326 // A provider or accounting archive survives the migration but not the merchant leaving that
327 // provider, so it is referenced by default and copied only on an explicit opt-in.
328 return { ... chosen, action: ACTIONS . OFFER_PRESERVATION , reason: 'third-party-hosted' };
329 }
330
331 function selectInvoiceStrategy ({ signalA = 0 , records = [], sourceSiteHost = null } = {}) {
332 const documents = records. map (( record ) => classifyRecord (record, sourceSiteHost));
333
334 const byAction = ( action ) => documents. filter (( document ) => document.action === action);
335 const wordpressHosted = byAction ( ACTIONS . PRESERVE );
336 const thirdPartyHosted = byAction ( ACTIONS . OFFER_PRESERVATION );
337 const numbersOnly = byAction ( ACTIONS . REFERENCE_ONLY );
338 const refused = byAction ( ACTIONS . REFUSED );
339
340 // The report needs one label. A store carrying both kinds is reported as mixed rather than as
341 // whichever kind happened to win.
342 let strategy = STRATEGIES . NONE_FOUND ;
343 if (wordpressHosted. length > 0 && thirdPartyHosted. length > 0 ) strategy = STRATEGIES . MIXED ;
344 else if (wordpressHosted. length > 0 ) strategy = STRATEGIES . WORDPRESS_HOSTED ;
345 else if (thirdPartyHosted. length > 0 ) strategy = STRATEGIES . THIRD_PARTY_HOSTED ;
346 else if (numbersOnly. length > 0 || signalA > 0 ) strategy = STRATEGIES . NUMBER_ONLY ;
347
348 return {
349 strategy,
350 requiresMerchantOptIn: thirdPartyHosted. length > 0 ,
351 documents,
352 counts: {
353 documentsFound: wordpressHosted. length + thirdPartyHosted. length ,
354 wordpressHosted: wordpressHosted. length ,
355 thirdPartyHosted: thirdPartyHosted. length ,
356 numberOnly: numbersOnly. filter (( document ) => document.reason === 'number-only' ). length ,
357 // Orders carrying document-shaped keys we could not read. Counted apart from numberOnly so a
358 // merchant is never told "no invoice documents" when the truth is "we could not read them".
359 unreadableDocuments: numbersOnly. filter (( document ) => document.reason === 'document-unreadable' ). length ,
360 unsafeUrls: refused. length ,
361 wordpressIssuedRows: signalA,
362 },
363 unsafe: refused. map (( document ) => ({
364 orderId: document.orderId,
365 reason: document.reason,
366 redactedUrl: redactUrlForLog (document.documentUrl),
367 // Every candidate that was tried, not only the one that happens to be reported.
368 candidates: document.refusedCandidates || [],
369 })),
370 // The keys we found but could not read, so this is fixable rather than merely counted.
371 unreadable: numbersOnly
372 . filter (( document ) => document.reason === 'document-unreadable' )
373 . map (( document ) => ({ orderId: document.orderId, keys: document.nonUrlDocumentKeys })),
374 };
375 }
376
377 // What may actually be imported. Every document is accounted for: importable, awaiting an opt-in
378 // the merchant declined, kept as a reference, or refused as unsafe.
379 function resolvePreservationPlan ({ selection , merchantOptedIn = false } = {}) {
380 // The writer requires an approval naming the exact URL, with an action that permits an import
381 // and -- for a third-party archive -- the merchant's opt-in recorded ON the approval. Returning
382 // the bare discovery record meant every opted-in document was refused at the writer, because
383 // the consumer's precondition was never part of what the producer emitted.
384 // Stamped, so the writer can tell an approval this planner issued from one assembled by hand
385 // (see lib/preservation-approval-stamp.js). Every approval is stamped, not only opted-in ones,
386 // so a stamp's absence is always meaningful.
387 const approve = ( document ) => approvalStamp. stampApproval ({ ... document, merchantOptedIn: merchantOptedIn === true });
388 const importable = selection.documents
389 . filter (( document ) => document.action === ACTIONS . PRESERVE
390 || (document.action === ACTIONS . OFFER_PRESERVATION && merchantOptedIn === true ))
391 . map (approve);
392 const refused = selection.documents. filter (( document ) => document.action === ACTIONS . OFFER_PRESERVATION
393 && merchantOptedIn !== true );
394 // A number-only record has no document to import, but its NUMBER must still be retained, so it
395 // needs an approval the writer accepts as well.
396 const referenceOnly = selection.documents. filter (( document ) => document.action === ACTIONS . REFERENCE_ONLY ). map (approve);
397 const unsafe = selection.documents. filter (( document ) => document.action === ACTIONS . REFUSED );
398
399 const accounted = importable. length + refused. length + referenceOnly. length + unsafe. length ;
400 if (accounted !== selection.documents. length ) {
401 throw new Error ( `invoice preservation plan accounts for ${ accounted } of ${ selection . documents . length } documents` );
402 }
403
404 return {
405 importable,
406 refused,
407 referenceOnly,
408 unsafe,
409 reason: refused. length > 0 ? 'invoice-preservation-declined' : null ,
410 };
411 }
412
413 module . exports = {
414 INVOICE_KEY_PATTERNS,
415 STRATEGIES,
416 ACTIONS,
417 classifyMetaKey,
418 discoverInvoicePatterns,
419 applyPatternsToPopulation,
420 isPrivateHostname,
421 classifyDocumentUrl,
422 redactUrlForLog,
423 classifyRecord,
424 selectInvoiceStrategy,
425 resolvePreservationPlan,
426 };