Setting the file. One moment.
Shopify Gift Cards · Rp Source Shopify · wix/skills · Skills Docs
ContentsBack to the top of the page function transientError
— line 149
This file
Number 38.11
Position 11 of 51
Type JavaScript
Size 21 KB
Lines 474 lib/ shopify-gift-cards.js
JavaScript · 474 lines · 21 KB
(
'../../../lib/source-coverage.js'
);
10 const { ShopifyCliError } = require ( './shopify-cli.js' );
11
12 const GIFT_QUERY = 'gift-cards' ;
13 const GIFT_CODES_COVERAGE_ID = 'shopify:gift-card-codes' ;
14 const GIFT_READ_COVERAGE_ID = 'shopify:gift-cards' ;
15 const GIFT_ACTION_CODE = 'shopify-gift-card-codes-not-migrated' ;
16 const RAW_DISCOVERY_DIR = 'data/shopify-discovery' ;
17 const EXTRACT_DIR = 'data/source-extract' ;
18 const CUTOVER_DIR = 'data/shopify-cutover' ;
19 const CENSUS_FILE = `${ RAW_DISCOVERY_DIR }/gift-card-census.json` ;
20 const ACTIVITY_FILE = `${ CUTOVER_DIR }/gift-card-activity.json` ;
21 const REMEDY_FILE = `${ CUTOVER_DIR }/gift-card-remedy.json` ;
22 const OWED_CSV_FILE = `${ CUTOVER_DIR }/gift-cards-owed.csv` ;
23 const CLOSED_TEXT = 'Every owed gift card was replaced and its holder told, against the final read' ;
24 const COUNT_WAITS_MS = Object. freeze ([ 2000 , 4000 ]);
25
26 function decimal ( value ) {
27 if ( typeof value !== 'string' || ! / ^ \d + (?: \. \d + ) ?$ / . test (value)) {
28 const error = new Error ( `invalid plain decimal ${ JSON . stringify ( value ) }` );
29 error.code = 'shopify_gift_card_amount_invalid' ;
30 throw error;
31 }
32 const [ whole , fraction = '' ] = value. split ( '.' );
33 return { units: BigInt ( `${ whole }${ fraction }` ), scale: fraction. length };
34 }
35
36 function scaleTo ( value , scale ) {
37 return value.units * ( 10 n ** BigInt (scale - value.scale));
38 }
39
40 function compareDecimals ( left , right ) {
41 const scale = Math. max (left.scale, right.scale);
42 const a = scaleTo (left, scale);
43 const b = scaleTo (right, scale);
44 return a === b ? 0 : (a < b ? - 1 : 1 );
45 }
46
47 function formatDecimal ( units , scale ) {
48 const negative = units < 0 n ;
49 let digits = String (negative ? - units : units);
50 if (scale) digits = digits. padStart (scale + 1 , '0' );
51 const value = scale ? `${ digits . slice ( 0 , - scale ) }.${ digits . slice ( - scale ) }` : digits;
52 return negative ? `-${ value }` : value;
53 }
54
55 function sumMoney ( rows ) {
56 const totals = new Map ();
57 for ( const money of rows) {
58 if ( ! money || typeof money.currencyCode !== 'string' || ! money.currencyCode) {
59 const error = new Error ( 'gift-card money has no currencyCode' );
60 error.code = 'shopify_gift_card_amount_invalid' ;
61 throw error;
62 }
63 const amount = decimal (money.amount);
64 const prior = totals. get (money.currencyCode);
65 if ( ! prior) totals. set (money.currencyCode, amount);
66 else {
67 const scale = Math. max (prior.scale, amount.scale);
68 totals. set (money.currencyCode, { units: scaleTo (prior, scale) + scaleTo (amount, scale), scale });
69 }
70 }
71 return Object. fromEntries ([ ... totals. entries ()]. sort (([ a ], [ b ]) => a. localeCompare (b)). map (([ currency , total ]) => [currency, formatDecimal (total.units, total.scale)]));
72 }
73
74 function isPositive ( amount ) {
75 return decimal (amount).units > 0 n ;
76 }
77
78 function sanitizeCard ( card ) {
79 if ( ! card || typeof card !== 'object' || typeof card.id !== 'string' ) {
80 const error = new Error ( 'gift-card record has no id' );
81 error.code = 'shopify_gift_card_amount_invalid' ;
82 throw error;
83 }
84 // Validate both money fields even though only balance decides what is owed.
85 decimal (card.balance && card.balance.amount);
86 decimal (card.initialValue && card.initialValue.amount);
87 if ( ! card.balance || typeof card.balance.currencyCode !== 'string' || ! card.initialValue || typeof card.initialValue.currencyCode !== 'string' ) {
88 const error = new Error ( 'gift-card money has no currencyCode' );
89 error.code = 'shopify_gift_card_amount_invalid' ;
90 throw error;
91 }
92 return {
93 id: card.id,
94 lastCharacters: card.lastCharacters || null ,
95 balance: { amount: card.balance.amount, currencyCode: card.balance.currencyCode },
96 initialValue: { amount: card.initialValue.amount, currencyCode: card.initialValue.currencyCode },
97 enabled: card.enabled === true ,
98 expiresOn: card.expiresOn || null ,
99 createdAt: card.createdAt || null ,
100 };
101 }
102
103 function summarizeCards ( cards ) {
104 const sanitized = cards. map (sanitizeCard);
105 const owed = sanitized. filter (( card ) => isPositive (card.balance.amount));
106 return {
107 cards: sanitized,
108 owed,
109 cardCount: sanitized. length ,
110 owedCount: owed. length ,
111 owedBalance: sumMoney (owed. map (( card ) => card.balance)),
112 owedDisabledOrExpiredCount: owed. filter (( card ) => card.enabled === false || Boolean (card.expiresOn)). length ,
113 };
114 }
115
116 function giftCodesCoverageRow ( evidence = CENSUS_FILE ) {
117 return {
118 coverageId: GIFT_CODES_COVERAGE_ID ,
119 platform: 'shopify' ,
120 capability: 'commerce.gift-cards' ,
121 sourceEntity: 'gift-card-codes' ,
122 routes: [ 'gift-cards' ],
123 reason: 'route-unsupported' ,
124 evidence: { artifact: evidence, pointer: 'every card is code-less; see owed counts and balances' },
125 recoverability: 'not-recoverable' ,
126 disposition: null ,
127 };
128 }
129
130 function failedReadCoverageRow ({ artifact , pointer , recoveryAction }) {
131 return {
132 coverageId: GIFT_READ_COVERAGE_ID ,
133 platform: 'shopify' ,
134 capability: 'commerce.gift-cards' ,
135 sourceEntity: 'gift-cards' ,
136 routes: [ 'gift-cards' ],
137 reason: 'capture-failed' ,
138 evidence: { artifact, pointer },
139 recoverability: 'import-operator' ,
140 recoveryAction,
141 disposition: null ,
142 };
143 }
144
145 function errorsText ( errors ) {
146 return (errors || []). map (( error ) => String ((error && error.message) || 'GraphQL error' )). join ( '; ' ). slice ( 0 , 500 );
147 }
148
149 function transientError ( error ) {
150 if ( ! error) return false ;
151 if (error instanceof ShopifyCliError && error.code === 'shopify_throttled' ) return true ;
152 if ([ 'ECONNRESET' , 'ECONNREFUSED' , 'ETIMEDOUT' , 'EAI_AGAIN' ]. includes (error.code)) return true ;
153 return / \b (?:HTTP ) ? 5 \d\d \b| network | socket | timed ? ? out | temporar/ i . test ( `${ String ( error . message || '' ) } \n ${ String ( error . stderrTail || '' ) }` );
154 }
155
156 function transientGraphql ( errors ) {
157 return (errors || []). some (( error ) => {
158 const code = error && error.extensions && error.extensions.code;
159 return code === 'THROTTLED' || code === 'INTERNAL_SERVER_ERROR' || /temporar | throttl/ i . test ( String (error && error.message));
160 });
161 }
162
163 function countQuery ( createdAt = null ) {
164 const filter = createdAt ? `query: "created_at:<='${ createdAt }'", ` : '' ;
165 return `query RpGiftCardsCount { giftCardsCount(${ filter }limit: null) { count precision } }` ;
166 }
167
168 async function readGiftCardCount ( execute , { createdAt = null , sleep = ( ms ) => new Promise (( resolve ) => setTimeout (resolve, ms)) } = {}) {
169 const call = typeof execute.once === 'function' ? execute.once. bind (execute) : execute;
170 let last;
171 for ( let attempt = 0 ; attempt < 3 ; attempt += 1 ) {
172 try {
173 const result = await call ( countQuery (createdAt));
174 if (result && Array. isArray (result.errors) && result.errors. length ) {
175 last = { errors: result.errors };
176 if ( transientGraphql (result.errors) && attempt < 2 ) { await sleep ( COUNT_WAITS_MS [attempt]); continue ; }
177 return { error: errorsText (result.errors), errors: result.errors };
178 }
179 const counted = result && result.data && result.data.giftCardsCount;
180 if ( ! counted || ! Number. isInteger (counted.count)) return { error: 'the count read returned no integer count' };
181 return { count: counted.count, precision: counted.precision || null };
182 } catch (error) {
183 if (error instanceof ShopifyCliError && error.code === 'source_auth_expired' ) throw error;
184 last = { thrown: error };
185 if ( transientError (error) && attempt < 2 ) { await sleep ( COUNT_WAITS_MS [attempt]); continue ; }
186 return { error: String (error.message || error), thrown: error };
187 }
188 }
189 return { error: last && last.thrown ? String (last.thrown.message || last.thrown) : errorsText (last && last.errors) };
190 }
191
192 async function writeNdjsonAtomic ( file , rows ) {
193 await fsp. mkdir (path. dirname (file), { recursive: true });
194 const temp = `${ file }.${ process . pid }.${ Date . now () }.tmp` ;
195 await fsp. writeFile (temp, rows. map (( row ) => `${ JSON . stringify ( row ) } \n ` ). join ( '' ), 'utf8' );
196 await fsp. rename (temp, file);
197 }
198
199 function safeOperationId ( operationId ) {
200 return String (operationId || 'unknown' ). split ( '/' ). filter (Boolean). pop (). replace ( / [ ^ A-Za-z0-9._-] / g , '_' );
201 }
202
203 async function readJson ( file , fallback = null ) {
204 try { return JSON . parse ( await fsp. readFile (file, 'utf8' )); }
205 catch (error) { if (error.code === 'ENOENT' ) return fallback; throw error; }
206 }
207
208 async function coverageHasGiftCodes ( projectDir ) {
209 for ( const rel of [ 'discovery/source-coverage.json' , `${ RAW_DISCOVERY_DIR }/source-coverage.json` ]) {
210 const file = await readJson (path. join (projectDir, rel));
211 if (file && Array. isArray (file.rows) && file.rows. some (( row ) => row.coverageId === GIFT_CODES_COVERAGE_ID )) return true ;
212 }
213 return false ;
214 }
215
216 async function giftCardReassemble ( context ) {
217 let summary;
218 try {
219 const cards = [];
220 for await ( const card of context.records) cards. push (card);
221 summary = summarizeCards (cards);
222 } catch (error) {
223 if (error.code === 'shopify_gift_card_amount_invalid' ) return { ok: false , errorCode: error.code, message: error.message, evidence: { valueClass: 'invalid decimal amount' } };
224 throw error;
225 }
226 const readCreatedAt = context.entry.createdAt;
227 const counted = await readGiftCardCount (context.execute, { createdAt: readCreatedAt, sleep: context.sleep });
228 const atOrBefore = summary.cards. filter (( card ) => Number. isFinite (Date. parse (card.createdAt)) && Date. parse (card.createdAt) <= Date. parse (readCreatedAt)). length ;
229 if (counted.error || counted.precision !== 'EXACT' ) {
230 return { ok: false , errorCode: 'shopify_gift_card_count_inexact' , message: counted.error || `Shopify returned count precision ${ counted . precision || 'missing'}` , evidence: { precision: counted.precision || null , error: counted.error || null , received: atOrBefore } };
231 }
232 if (atOrBefore < counted.count) {
233 return { ok: false , errorCode: 'shopify_gift_card_count_short' , message: `the gift-card read contains ${ atOrBefore } card(s) created by ${ readCreatedAt }, below Shopify's exact count ${ counted . count }` , evidence: { expected: counted.count, received: atOrBefore, precision: counted.precision, readCreatedAt } };
234 }
235
236 const read = {
237 operationId: context.entry.operationId || null ,
238 createdAt: readCreatedAt || null ,
239 completedAt: context.entry.completedAt || null ,
240 lineCount: context.lines,
241 exactCount: counted.count,
242 countPrecision: counted.precision,
243 };
244 const entryFields = {
245 giftCardCount: summary.cardCount,
246 owedGiftCardCount: summary.owedCount,
247 owedGiftCardBalance: summary.owedBalance,
248 giftCardCountCheck: { expected: counted.count, received: atOrBefore, precision: counted.precision, queryCreatedAt: readCreatedAt },
249 };
250
251 if (context.caller === 'census' ) {
252 const census = {
253 cardCount: summary.cardCount,
254 owedCount: summary.owedCount,
255 owedBalance: summary.owedBalance,
256 owedDisabledOrExpiredCount: summary.owedDisabledOrExpiredCount,
257 read,
258 };
259 await writeJsonAtomic (path. join (context.projectDir, CENSUS_FILE ), census);
260 return { ok: true , records: [], result: census, entry: entryFields };
261 }
262
263 const cardsFile = `${ EXTRACT_DIR }/gift-card-reads/${ safeOperationId ( context . entry . operationId ) }.ndjson` ;
264 await writeNdjsonAtomic (path. join (context.projectDir, cardsFile), summary.cards);
265 entryFields.cardsFile = cardsFile;
266 const response = { ok: true , records: summary.cards, outputFile: 'gift-cards-codeless.ndjson' , entry: entryFields };
267 if (summary.cardCount > 0 && ! ( await coverageHasGiftCodes (context.projectDir))) {
268 const row = giftCodesCoverageRow ( `${ EXTRACT_DIR }/extract-manifest.json` );
269 const update = await upsertAdapterCoverageRows (context.projectDir, { adapter: 'shopify' , rows: [row] });
270 response.stop = {
271 code: 'shopify_gift_card_codes_unreadable' ,
272 message: 'Shopify gift cards first appeared during the extract. Their reusable codes are unreadable; the coverage row was added and the run must return to mapping.' ,
273 details: { coverageId: row.coverageId, ... update },
274 };
275 }
276 return response;
277 }
278
279 async function giftCardFailedHandler ( context ) {
280 await fsp. rm (path. join (context.stagingDir, 'gift-cards.jsonl' ), { force: true });
281 const artifact = context.caller === 'extract' ? `${ EXTRACT_DIR }/extract-manifest.json` : `${ RAW_DISCOVERY_DIR }/bulk-manifest.json` ;
282 const evidence = context.entry.error && context.entry.error.evidence;
283 const pointer = `queries.gift-cards; ${ context . entry . errorCode || 'unknown'}${ evidence ? `; ${ JSON . stringify ( evidence ) }` : ''}` ;
284 const row = failedReadCoverageRow ({
285 artifact,
286 pointer,
287 recoveryAction: context.caller === 'extract'
288 ? 'Fix the count or amount read, then run shopify-extract.js --retry-failed.'
289 : 'The extract reads gift cards again with the same exact-count gate; fix source access before import.' ,
290 });
291 if (context.caller === 'extract' ) await upsertAdapterCoverageRows (context.projectDir, { adapter: 'shopify' , rows: [row] });
292 return { handlerOutcome: { coverageId: row.coverageId, ... (context.caller === 'extract' ? {} : { row }) } };
293 }
294
295 function registerGiftCardHandlers ( bulk ) {
296 bulk. registerHandlers ({
297 failed: { byQuery: { [ GIFT_QUERY ]: giftCardFailedHandler } },
298 reassemble: { [ GIFT_QUERY ]: giftCardReassemble },
299 });
300 }
301
302 async function giftCardCensusStep ( ctx , bulk ) {
303 const manifestPath = path. join (ctx.rawDir, 'bulk-manifest.json' );
304 const existing = await readJson (manifestPath);
305 if ( ! existing) {
306 const initial = await readGiftCardCount (ctx.execute);
307 if (initial.error || initial.precision !== 'EXACT' ) {
308 return { rows: [ failedReadCoverageRow ({ artifact: `${ RAW_DISCOVERY_DIR }/discovery-steps.json` , pointer: initial.error || `giftCardsCount precision ${ initial . precision || 'missing'}` , recoveryAction: 'Fix the Shopify gift-card count read; the extract will read the cards again through its exact-count gate.' })] };
309 }
310 if (initial.count === 0 ) return { rows: [], count: 0 , precision: 'EXACT' };
311 }
312 registerGiftCardHandlers (bulk);
313 const runner = bulk. createBulkRunner ({
314 projectDir: ctx.projectDir,
315 store: ctx.store,
316 caller: 'census' ,
317 manifestPath,
318 stagingDir: path. join (ctx.rawDir, 'bulk-staging' ),
319 queries: existing ? null : [{ name: GIFT_QUERY , entity: GIFT_QUERY , text: bulk. generateQueryText ( GIFT_QUERY ) }],
320 execute: ctx.execute,
321 now : () => Date. parse (ctx. now ()),
322 progress: ctx.progress,
323 });
324 const result = await runner. run ();
325 const entry = result.manifest && result.manifest.queries && result.manifest.queries[ GIFT_QUERY ];
326 if (entry && entry.state === 'done' ) {
327 return {
328 rows: entry.result && entry.result.cardCount > 0 ? [ giftCodesCoverageRow ()] : [],
329 census: entry.result || null ,
330 };
331 }
332 const row = entry && entry.handlerOutcome && entry.handlerOutcome.row;
333 return { rows: [row || failedReadCoverageRow ({ artifact: `${ RAW_DISCOVERY_DIR }/bulk-manifest.json` , pointer: `queries.gift-cards state ${ ( entry && entry . state ) || 'unknown'}` , recoveryAction: 'Fix source access before import; the extract reads gift cards again.' })] };
334 }
335
336 async function readNdjson ( file ) {
337 const text = await fsp. readFile (file, 'utf8' );
338 return text. split ( ' \n ' ). filter (Boolean). map (( line ) => JSON . parse (line));
339 }
340
341 async function manifestFiles ( projectDir ) {
342 const dir = path. join (projectDir, EXTRACT_DIR );
343 let names = [];
344 try { names = await fsp. readdir (dir); } catch (error) { if (error.code !== 'ENOENT' ) throw error; }
345 return names. filter (( name ) => name === 'extract-manifest.json' || / ^ extract-manifest \. . + \. json $ / . test (name)). map (( name ) => path. join (dir, name));
346 }
347
348 async function giftCardReads ( projectDir ) {
349 const currentPath = path. join (projectDir, EXTRACT_DIR , 'extract-manifest.json' );
350 const reads = [];
351 let currentManifest = null ;
352 let currentRead = null ;
353 let currentCardsFileMissing = false ;
354 for ( const file of await manifestFiles (projectDir)) {
355 const manifest = await readJson (file);
356 if (file === currentPath) currentManifest = manifest;
357 const entry = manifest && manifest.queries && manifest.queries[ GIFT_QUERY ];
358 if ( ! entry || entry.state !== 'done' || ! entry.cardsFile || ! Number. isFinite (Date. parse (entry.createdAt))) continue ;
359 try {
360 const read = { entry, cards: await readNdjson (path. join (projectDir, entry.cardsFile)), manifestPath: file, current: file === currentPath };
361 reads. push (read);
362 if (read.current) currentRead = read;
363 } catch (error) {
364 if (error.code !== 'ENOENT' ) throw error;
365 if (file === currentPath) currentCardsFileMissing = true ;
366 }
367 }
368 reads. sort (( a , b ) => Date. parse (a.entry.createdAt) - Date. parse (b.entry.createdAt));
369 return { latest: reads[reads. length - 1 ] || null , currentManifest, currentRead, currentCardsFileMissing, reads };
370 }
371
372 function replacementClears ( card , replacement ) {
373 if ( ! replacement || replacement.currency !== card.balance.currencyCode) return false ;
374 try { return compareDecimals ( decimal (replacement.replacementAmount), decimal (card.balance.amount)) >= 0 ; }
375 catch { return false ; }
376 }
377
378 function dateInTimezone ( now , timezone ) {
379 const parts = new Intl. DateTimeFormat ( 'en-CA' , { timeZone: timezone || 'UTC' , year: 'numeric' , month: '2-digit' , day: '2-digit' }). formatToParts (now instanceof Date ? now : new Date (now));
380 const get = ( type ) => parts. find (( part ) => part.type === type).value;
381 return `${ get ( 'year' ) }-${ get ( 'month' ) }-${ get ( 'day' ) }` ;
382 }
383
384 function balanceText ( balance ) {
385 const entries = Object. entries (balance);
386 return entries. length ? entries. map (([ currency , amount ]) => `${ currency } ${ amount }` ). join ( ', ' ) : 'no positive balance' ;
387 }
388
389 async function hasGiftCodesRow ( projectDir ) {
390 return coverageHasGiftCodes (projectDir);
391 }
392
393 async function evaluateGiftCardCutover ( projectDir , { now = new Date () } = {}) {
394 if ( ! ( await hasGiftCodesRow (projectDir))) return { present: false , action: null , cutoverAction: null };
395 const { latest , currentManifest , currentRead , currentCardsFileMissing } = await giftCardReads (projectDir);
396 const activity = await readJson (path. join (projectDir, ACTIVITY_FILE ));
397 const remedy = await readJson (path. join (projectDir, REMEDY_FILE ), { replacements: [] });
398 const replacements = new Map (((remedy && remedy.replacements) || []). map (( row ) => [row.id, row]));
399 const latestSummary = latest ? summarizeCards (latest.cards) : { owed: [], owedCount: 0 , owedBalance: {} };
400 const current = currentManifest && currentManifest.queries && currentManifest.queries[ GIFT_QUERY ];
401 const finalExists = Boolean (activity && Number. isFinite (Date. parse (activity.activityStoppedAt)) && currentRead && Date. parse (currentRead.entry.createdAt) > Date. parse (activity.activityStoppedAt));
402 let finalSummary = null ;
403 if (finalExists) finalSummary = summarizeCards (currentRead.cards);
404 const owed = finalSummary ? finalSummary.owed : latestSummary.owed;
405 const uncleared = finalSummary ? owed. filter (( card ) => ! replacementClears (card, replacements. get (card.id))) : owed;
406 if (finalSummary && uncleared. length === 0 ) return { present: true , closed: true , action: null , cutoverAction: CLOSED_TEXT , finalRead: current };
407
408 const balances = sumMoney (uncleared. map (( card ) => card.balance));
409 let actionText;
410 if ( ! latest) actionText = 'Gift-card read failed — see shopify:gift-cards. No balance can be treated as cleared.' ;
411 else if ( ! finalSummary) {
412 const missing = currentCardsFileMissing ? ' The current gift-card read file is missing, so it cannot be final.' : '' ;
413 actionText = `Awaiting the final gift-card read after activity stops; ${ latestSummary . owedCount } card(s), ${ balanceText ( latestSummary . owedBalance ) }, remain potentially owed.${ missing }` ;
414 }
415 else actionText = `Replace ${ uncleared . length } Shopify gift card(s) and notify their holders; ${ balanceText ( balances ) } remains owed.` ;
416 if (remedy && remedy.keepRedeemingUntil) {
417 const today = dateInTimezone (now, remedy.ianaTimezone || 'UTC' );
418 actionText += today > remedy.keepRedeemingUntil
419 ? ` The keep-redeeming date ${ remedy . keepRedeemingUntil } has passed; balances are still owed.`
420 : ` Shopify keeps redeeming until ${ remedy . keepRedeemingUntil }; this does not clear the balances.` ;
421 }
422 return {
423 present: true ,
424 closed: false ,
425 finalRead: finalSummary ? current : null ,
426 latestRead: latest && latest.entry,
427 uncleared,
428 balances: finalSummary ? balances : latestSummary.owedBalance,
429 cutoverAction: actionText,
430 action: {
431 code: GIFT_ACTION_CODE ,
432 severity: 'high' ,
433 action: actionText,
434 deadlineReason: 'outstanding gift-card balances are customer money' ,
435 entity: 'commerce.gift-cards' ,
436 affectedCount: finalSummary ? uncleared. length : latestSummary.owedCount,
437 },
438 };
439 }
440
441 module . exports = {
442 GIFT_QUERY,
443 GIFT_CODES_COVERAGE_ID,
444 GIFT_READ_COVERAGE_ID,
445 GIFT_ACTION_CODE,
446 CENSUS_FILE,
447 ACTIVITY_FILE,
448 REMEDY_FILE,
449 OWED_CSV_FILE,
450 CLOSED_TEXT,
451 COUNT_WAITS_MS,
452 decimal,
453 compareDecimals,
454 formatDecimal,
455 sumMoney,
456 isPositive,
457 sanitizeCard,
458 summarizeCards,
459 giftCodesCoverageRow,
460 failedReadCoverageRow,
461 countQuery,
462 readGiftCardCount,
463 giftCardReassemble,
464 giftCardFailedHandler,
465 registerGiftCardHandlers,
466 giftCardCensusStep,
467 readJson,
468 writeNdjsonAtomic,
469 readNdjson,
470 giftCardReads,
471 replacementClears,
472 dateInTimezone,
473 evaluateGiftCardCutover,
474 };