@fire-spark/cli 0.13.2 → 0.14.0

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.
@@ -0,0 +1,772 @@
1
+ import { Big } from "big.js";
2
+ import { randomBytes, randomUUID } from "node:crypto";
3
+
4
+ import type { Copy } from "../../../core/i18n";
5
+ import type { LiveSession } from "../live/live.session";
6
+ import { buildOrderBody, pickProduct } from "../live/order";
7
+ import { channelLabel, paymentOf } from "./config";
8
+ import { COPY as SHARED } from "./copy";
9
+ import { fromLive, isActive, isOutcome, isRecord } from "./flow";
10
+ import { namedOrderSkip, openLive, prepare, queryOf, rowsOf } from "./flow";
11
+ import { say, textOf, trpcCall, withSafeFix } from "./flow";
12
+ import { liveConfigOf } from "./order";
13
+ import { readSources } from "./repo.scan";
14
+ import type { CheckBinding, Outcome, RunCtx } from "./runner";
15
+ import type { YamlChannel } from "./yaml";
16
+
17
+ const line = (es: string, en: string, pt: string): Copy => {
18
+ return { "es-EC": es, "en-US": en, "pt-BR": pt };
19
+ };
20
+
21
+ const COPY = {
22
+ empty: line(
23
+ "El canal {channel} no tiene payments activos.",
24
+ "Channel {channel} has no active payments.",
25
+ "O canal {channel} não tem payments ativos."
26
+ ),
27
+ shape: line(
28
+ "Un payment no trae {field}.",
29
+ "A payment is missing {field}.",
30
+ "Um payment não traz {field}."
31
+ ),
32
+ changed: line(
33
+ "El catálogo de pagos cambió entre dos lecturas de la misma sesión.",
34
+ "The payment catalog changed between two reads of the same session.",
35
+ "O catálogo de pagamentos mudou entre duas leituras da mesma sessão."
36
+ ),
37
+ redemption: line(
38
+ "REDEMPTION no puede ser el método de dinero. No hay otro método activo.",
39
+ "REDEMPTION cannot be the money method. No other method is active.",
40
+ "REDEMPTION não pode ser o método de dinheiro. Não há outro método ativo."
41
+ ),
42
+ unknown: line(
43
+ "El método {method} no tiene un flujo de instrucciones.",
44
+ "Method {method} has no instruction flow.",
45
+ "O método {method} não tem um fluxo de instruções."
46
+ ),
47
+ cashHelp: line(
48
+ "El efectivo no debe mostrar instrucciones. Se paga en caja.",
49
+ "Cash must not show instructions. It is paid at the counter.",
50
+ "O dinheiro não deve mostrar instruções. Paga-se no caixa."
51
+ ),
52
+ pay05Skip: line(
53
+ "PAY-05 no aplica al canal {template}.",
54
+ "PAY-05 does not apply to the {template} channel.",
55
+ "PAY-05 não se aplica ao canal {template}."
56
+ ),
57
+ pay05Missing: line(
58
+ "No encontré la UI de alta, edición, baja y preferido (exactAmount y preferred).",
59
+ "I did not find the add, edit, delete, and preferred payment UI (exactAmount and preferred).",
60
+ "Não encontrei a UI de alta, edição, baixa e preferido (exactAmount e preferred)."
61
+ ),
62
+ noCash: line(
63
+ "CASH no está habilitado en el canal {channel}.",
64
+ "CASH is not enabled on channel {channel}.",
65
+ "CASH não está habilitado no canal {channel}."
66
+ ),
67
+ blocked: line(
68
+ "CASH ({id}) está bloqueado en este dispositivo.",
69
+ "CASH ({id}) is blocked on this device.",
70
+ "CASH ({id}) está bloqueado neste dispositivo."
71
+ ),
72
+ change: line(
73
+ "El cambio de efectivo no cuadra.",
74
+ "The cash change does not add up.",
75
+ "O troco em dinheiro não fecha."
76
+ ),
77
+ splitSkip: line(
78
+ "PAY-10 se omite: hace falta un segundo método de dinero además de efectivo.",
79
+ "PAY-10 is skipped: a second money method besides cash is required.",
80
+ "PAY-10 é omitido: falta um segundo método de dinheiro além de dinheiro."
81
+ ),
82
+ split: line(
83
+ "El reparto entre efectivo, el otro método y los fees no cuadra.",
84
+ "The split across cash, the other method, and fees does not add up.",
85
+ "A divisão entre dinheiro, o outro método e as taxas não fecha."
86
+ ),
87
+ mixedOff: line(
88
+ "PAY-12 se omite: preferences.payments.mixed está vacío.",
89
+ "PAY-12 is skipped: preferences.payments.mixed is empty.",
90
+ "PAY-12 é omitido: preferences.payments.mixed está vazio."
91
+ ),
92
+ mixedNeed: line(
93
+ "Mixed está habilitado y hay menos de dos métodos de dinero.",
94
+ "Mixed is enabled and fewer than two money methods exist.",
95
+ "Mixed está habilitado e há menos de dois métodos de dinheiro."
96
+ ),
97
+ mixedShort: line(
98
+ "preferences.payments.mixed permite menos de dos métodos.",
99
+ "preferences.payments.mixed allows fewer than two methods.",
100
+ "preferences.payments.mixed permite menos de dois métodos."
101
+ ),
102
+ noAdapter: line(
103
+ "PAY-08 se omite: no hay un adapter de cobro. No hice ningún cobro.",
104
+ "PAY-08 is skipped: there is no charge adapter. No charge was made.",
105
+ "PAY-08 é omitido: não há um adapter de cobrança. Nenhuma cobrança foi feita."
106
+ ),
107
+ noSandbox: line(
108
+ "PAY-08 se omite: {provider} no es un sandbox local. No hice ningún cobro.",
109
+ "PAY-08 is skipped: {provider} is not a local sandbox. No charge was made.",
110
+ "PAY-08 é omitido: {provider} não é um sandbox local. Nenhuma cobrança foi feita."
111
+ ),
112
+ refunded: line(
113
+ "Un cliente sin orders:orders:write pudo reembolsar.",
114
+ "A customer without orders:orders:write was able to refund.",
115
+ "Um cliente sem orders:orders:write conseguiu reembolsar."
116
+ ),
117
+ placeStatus: line(
118
+ "Place respondió {status}. Esperaba ACCEPTED o IN_PROGRESS.",
119
+ "Place returned {status}. Expected ACCEPTED or IN_PROGRESS.",
120
+ "Place respondeu {status}. Esperava ACCEPTED ou IN_PROGRESS."
121
+ ),
122
+ notCash: line(
123
+ "El pago del canal no es cash ni sandbox. No hice place.",
124
+ "The channel payment is not cash or sandbox. Place was not called.",
125
+ "O pagamento do canal não é cash nem sandbox. Place não foi chamado."
126
+ ),
127
+ channel: line(
128
+ "{id} no aplica al canal {template}.",
129
+ "{id} does not apply to the {template} channel.",
130
+ "{id} não se aplica ao canal {template}."
131
+ ),
132
+ named: line(
133
+ "{id} solo corre cuando lo nombras. No cambié el catálogo de pagos.",
134
+ "{id} runs only when you name it. The payment catalog was not changed.",
135
+ "{id} só roda quando você o nomeia. O catálogo de pagamentos não mudou."
136
+ ),
137
+ need: line(
138
+ "{id} se omite: falta {field}.",
139
+ "{id} is skipped: {field} is missing.",
140
+ "{id} é omitido: falta {field}."
141
+ ),
142
+ http: line(
143
+ "{id} respondió {status}.",
144
+ "{id} responded {status}.",
145
+ "{id} respondeu {status}."
146
+ ),
147
+ instruction: line(
148
+ "PAY-04 no encontró paymentInstructionKind en CheckoutFlowProvider.helpers.ts.",
149
+ "PAY-04 did not find paymentInstructionKind in CheckoutFlowProvider.helpers.ts.",
150
+ "PAY-04 não encontrou paymentInstructionKind em CheckoutFlowProvider.helpers.ts."
151
+ ),
152
+ pay05Surface: line(
153
+ "PAY-05 no encontró CheckoutPaymentSection o PaymentMetaForm con exactAmount y preferred.",
154
+ "PAY-05 did not find CheckoutPaymentSection or PaymentMetaForm with exactAmount and preferred.",
155
+ "PAY-05 não encontrou CheckoutPaymentSection ou PaymentMetaForm com exactAmount e preferred."
156
+ ),
157
+ preferred: line(
158
+ "El customer no quedó con un efectivo preferred tras el alta.",
159
+ "The customer did not keep a preferred cash method after the save.",
160
+ "O customer não ficou com um dinheiro preferred após o cadastro."
161
+ ),
162
+ scope: line(
163
+ "El payment creado no se vio en el API tras variar el scope.",
164
+ "The created payment was not visible on the API after the scope change.",
165
+ "O payment criado não apareceu na API depois de variar o scope."
166
+ ),
167
+ session: line(
168
+ "La sesión nueva no descargó el catálogo de pagos actualizado.",
169
+ "The new session did not download the updated payment catalog.",
170
+ "A sessão nova não baixou o catálogo de pagamentos atualizado."
171
+ )
172
+ };
173
+
174
+ const LIVE = [
175
+ "api_url",
176
+ "channel_id",
177
+ "FIRESPARK_DOCTOR_CUSTOMER_EMAIL",
178
+ "FIRESPARK_DOCTOR_CUSTOMER_PASSWORD",
179
+ "FIRESPARK_DOCTOR_FIREBASE_API_KEY"
180
+ ] as const;
181
+
182
+ const PAY05 = new Set(["web", "app", "chat", "call-center"]);
183
+
184
+ const PLACE = new Set(["ACCEPTED", "IN_PROGRESS"]);
185
+
186
+ const HASH = "0123456789abcdefghijklmnopqrstuvwxyz";
187
+
188
+ const hash12 = () => {
189
+ const bytes = randomBytes(12);
190
+ let out = "";
191
+ for (const byte of bytes) out += HASH[byte % HASH.length];
192
+ return out;
193
+ };
194
+
195
+ const newOrderId = (storeId: string) => {
196
+ const store = storeId.replace(/[^A-Za-z0-9]/g, "") || "nostore";
197
+ return `ord-${store}-${hash12()}`;
198
+ };
199
+
200
+ type Row = Record<string, unknown>;
201
+
202
+ type Catalog = {
203
+ channel: YamlChannel;
204
+ live: LiveSession;
205
+ rows: Row[];
206
+ };
207
+
208
+ const methodOf = (row: Row) => {
209
+ const method = row.method;
210
+ if (!isRecord(method)) return "";
211
+ return typeof method.method === "string" ? method.method : "";
212
+ };
213
+
214
+ const providerOf = (row: Row) => {
215
+ const provider = row.provider;
216
+ if (!isRecord(provider)) return "";
217
+ const id = provider.provider_id;
218
+ return typeof id === "string" ? id : "";
219
+ };
220
+
221
+ const idOf = (row: Row) => {
222
+ return typeof row.id === "string" ? row.id : "";
223
+ };
224
+
225
+ const scopeOf = (channel: YamlChannel) => {
226
+ return {
227
+ channel_id: textOf(channel.channel_id),
228
+ store_id: textOf(channel.store_id),
229
+ fulfillment_id: textOf(channel.fulfillment_id)
230
+ };
231
+ };
232
+
233
+ const paymentsOf = async (ctx: RunCtx): Promise<Catalog | Outcome> => {
234
+ const fields = [...LIVE, "store_id", "fulfillment_id"] as const;
235
+ const ready = prepare(ctx, fields);
236
+ if (isOutcome(ready)) return ready;
237
+ const live = openLive(ctx, ready);
238
+ const path = queryOf("/payments", scopeOf(ready));
239
+ const step = await live.storefront<unknown>("payments", "GET", path, [200]);
240
+ if (!step.ok) return fromLive(ctx.locale, step.result);
241
+ return { channel: ready, live, rows: rowsOf(step.value) };
242
+ };
243
+
244
+ const activeRows = (rows: Row[]) => rows.filter(isActive);
245
+
246
+ const moneyRows = (rows: Row[]) => {
247
+ return activeRows(rows).filter(row => {
248
+ const method = methodOf(row);
249
+ return method !== "" && method !== "REDEMPTION";
250
+ });
251
+ };
252
+
253
+ const cashRow = (rows: Row[]) => {
254
+ return moneyRows(rows).find(row => methodOf(row) === "CASH");
255
+ };
256
+
257
+ const disabledIds = (env: NodeJS.ProcessEnv) => {
258
+ const raw = textOf(env.FIRESPARK_DOCTOR_DISABLED_PAYMENT_IDS);
259
+ const parts = raw.split(",").map(item => item.trim());
260
+ return new Set(parts.filter(item => item !== ""));
261
+ };
262
+
263
+ const namedOnly = (ctx: RunCtx, id: string) => {
264
+ if (ctx.named.has(id)) return null;
265
+ return say(ctx.locale, "skip", COPY.named, { id });
266
+ };
267
+
268
+ const need = (ctx: RunCtx, id: string, field: string) => {
269
+ return say(ctx.locale, "skip", COPY.need, { id, field });
270
+ };
271
+
272
+ const allowed = async (
273
+ ctx: RunCtx,
274
+ id: string,
275
+ templates: ReadonlySet<string>
276
+ ) => {
277
+ const ready = prepare(ctx, ["channel_id"]);
278
+ if (isOutcome(ready)) return ready;
279
+ const template = textOf(ready.template);
280
+ if (templates.has(template)) return ready;
281
+ return say(ctx.locale, "skip", COPY.channel, { id, template });
282
+ };
283
+
284
+ const dashGap = (ctx: RunCtx, id: string, channel: YamlChannel) => {
285
+ const cookie = textOf(ctx.env.FIRESPARK_DOCTOR_DASHBOARD_COOKIE);
286
+ if (!cookie) return need(ctx, id, "FIRESPARK_DOCTOR_DASHBOARD_COOKIE");
287
+ if (!textOf(channel.dashboard_url)) return need(ctx, id, "dashboard_url");
288
+ if (!textOf(channel.merchant_id)) return need(ctx, id, "merchant_id");
289
+ if (!textOf(channel.env)) return need(ctx, id, "env");
290
+ return null;
291
+ };
292
+
293
+ const providerUid = (row: Row) => {
294
+ const provider = row.provider;
295
+ if (!isRecord(provider)) return "";
296
+ const uid = provider.uid ?? provider.id;
297
+ return typeof uid === "string" ? uid : "";
298
+ };
299
+
300
+ const methodUid = (row: Row) => {
301
+ const method = row.method;
302
+ if (!isRecord(method)) return "";
303
+ const uid = method.uid ?? method.id;
304
+ return typeof uid === "string" ? uid : "";
305
+ };
306
+
307
+ const runPay01 = async (ctx: RunCtx): Promise<Outcome> => {
308
+ const gate = await allowed(ctx, "PAY-01", new Set(["server", "dashboard"]));
309
+ if (isOutcome(gate)) return gate;
310
+ const named = namedOnly(ctx, "PAY-01");
311
+ if (named) return named;
312
+ const catalog = await paymentsOf(ctx);
313
+ if (isOutcome(catalog)) return catalog;
314
+ const gap = dashGap(ctx, "PAY-01", catalog.channel);
315
+ if (gap) return gap;
316
+ const sample = activeRows(catalog.rows)[0];
317
+ if (!sample) {
318
+ const channel = channelLabel(catalog.channel);
319
+ return say(ctx.locale, "fail", COPY.empty, { channel });
320
+ }
321
+ const paymentProvider = providerUid(sample);
322
+ if (!paymentProvider) return need(ctx, "PAY-01", "payments.provider.uid");
323
+ const paymentMethod = methodUid(sample);
324
+ if (!paymentMethod) return need(ctx, "PAY-01", "payments.method.uid");
325
+ const cookie = textOf(ctx.env.FIRESPARK_DOCTOR_DASHBOARD_COOKIE);
326
+ const dash = textOf(catalog.channel.dashboard_url);
327
+ const merchant = textOf(catalog.channel.merchant_id);
328
+ const env = textOf(catalog.channel.env);
329
+ const name = `doctor-${hash12()}`;
330
+ const scope = { channel_ids: [textOf(catalog.channel.channel_id)] };
331
+ const created = {
332
+ merchant_id: merchant,
333
+ env,
334
+ name,
335
+ status: "ACTIVE",
336
+ payment_provider_id: paymentProvider,
337
+ payment_method_id: paymentMethod,
338
+ ...scope
339
+ };
340
+ const inserted = await trpcCall(dash, cookie, "payments.insert", created);
341
+ if (inserted.status < 200 || inserted.status >= 300) {
342
+ return say(ctx.locale, "fail", COPY.http, {
343
+ id: "PAY-01",
344
+ status: inserted.status
345
+ });
346
+ }
347
+ const again = await paymentsOf(ctx);
348
+ if (isOutcome(again)) return again;
349
+ const visible = again.rows.some(row => row.name === name);
350
+ if (!visible) return say(ctx.locale, "fail", COPY.scope);
351
+ const id = again.rows.find(row => row.name === name);
352
+ const paymentId = id ? idOf(id) : "";
353
+ if (!paymentId) return say(ctx.locale, "fail", COPY.scope);
354
+ const off = { merchant_id: merchant, env, id: paymentId, status: "INACTIVE" };
355
+ const updated = await trpcCall(dash, cookie, "payments.update", off);
356
+ if (updated.status < 200 || updated.status >= 300) {
357
+ return say(ctx.locale, "fail", COPY.http, {
358
+ id: "PAY-01",
359
+ status: updated.status
360
+ });
361
+ }
362
+ const after = await paymentsOf(ctx);
363
+ if (isOutcome(after)) return after;
364
+ const row = after.rows.find(item => idOf(item) === paymentId);
365
+ if (row && row.status !== "INACTIVE")
366
+ return say(ctx.locale, "fail", COPY.scope);
367
+ return { status: "pass" };
368
+ };
369
+
370
+ const runPay02 = async (ctx: RunCtx): Promise<Outcome> => {
371
+ const gate = await allowed(ctx, "PAY-02", new Set(["kiosk"]));
372
+ if (isOutcome(gate)) return gate;
373
+ const named = namedOnly(ctx, "PAY-02");
374
+ if (named) return named;
375
+ const catalog = await paymentsOf(ctx);
376
+ if (isOutcome(catalog)) return catalog;
377
+ const gap = dashGap(ctx, "PAY-02", catalog.channel);
378
+ if (gap) return gap;
379
+ const sample = activeRows(catalog.rows)[0];
380
+ if (!sample) return need(ctx, "PAY-02", "payments.id");
381
+ const paymentId = idOf(sample);
382
+ if (!paymentId) return need(ctx, "PAY-02", "payments.id");
383
+ const cookie = textOf(ctx.env.FIRESPARK_DOCTOR_DASHBOARD_COOKIE);
384
+ const dash = textOf(catalog.channel.dashboard_url);
385
+ const merchant = textOf(catalog.channel.merchant_id);
386
+ const env = textOf(catalog.channel.env);
387
+ const before = typeof sample.name === "string" ? sample.name : "";
388
+ const name = `doctor-session-${hash12()}`;
389
+ const input = { merchant_id: merchant, env, id: paymentId, name };
390
+ const updated = await trpcCall(dash, cookie, "payments.update", input);
391
+ if (updated.status < 200 || updated.status >= 300) {
392
+ return say(ctx.locale, "fail", COPY.http, {
393
+ id: "PAY-02",
394
+ status: updated.status
395
+ });
396
+ }
397
+ const next = openLive(ctx, catalog.channel);
398
+ const path = queryOf("/payments", scopeOf(catalog.channel));
399
+ const codes = [200];
400
+ const args = ["payments-session", "GET", path, codes] as const;
401
+ const step = await next.storefront<unknown>(...args);
402
+ if (!step.ok) return fromLive(ctx.locale, step.result);
403
+ const seen = rowsOf(step.value).some(row => row.name === name);
404
+ const restore = { merchant_id: merchant, env, id: paymentId, name: before };
405
+ await trpcCall(dash, cookie, "payments.update", restore);
406
+ if (!seen) return say(ctx.locale, "fail", COPY.session);
407
+ return { status: "pass" };
408
+ };
409
+
410
+ const runPay03 = async (ctx: RunCtx): Promise<Outcome> => {
411
+ const gate = await allowed(ctx, "PAY-03", new Set(["kiosk"]));
412
+ if (isOutcome(gate)) return gate;
413
+ const catalog = await paymentsOf(ctx);
414
+ if (isOutcome(catalog)) return catalog;
415
+ const hidden = disabledIds(ctx.env);
416
+ const visible = moneyRows(catalog.rows).filter(row => {
417
+ return !hidden.has(idOf(row));
418
+ });
419
+ const redemption = visible.some(row => methodOf(row) === "REDEMPTION");
420
+ if (redemption) return say(ctx.locale, "fail", COPY.redemption);
421
+ if (visible.length > 0) return { status: "pass" };
422
+ const active = activeRows(catalog.rows);
423
+ const only = active.some(row => methodOf(row) === "REDEMPTION");
424
+ if (only) return say(ctx.locale, "fail", COPY.redemption);
425
+ const channel = channelLabel(catalog.channel);
426
+ return say(ctx.locale, "fail", COPY.empty, { channel });
427
+ };
428
+
429
+ const instructionGap = (body: string) => {
430
+ if (!body.includes("paymentInstructionKind")) return "paymentInstructionKind";
431
+ if (!body.includes("return null")) return "CASH";
432
+ if (!body.includes("PIN_PAD") || !body.includes('"card"')) return "PIN_PAD";
433
+ if (!body.includes("CARD")) return "CARD";
434
+ if (!body.includes("BANK_TRANSFER") || !body.includes("transfer")) {
435
+ return "BANK_TRANSFER";
436
+ }
437
+ if (!body.includes("WALLET") || !body.includes("wallet")) return "WALLET";
438
+ return "";
439
+ };
440
+
441
+ const runPay04 = async (ctx: RunCtx): Promise<Outcome> => {
442
+ const gate = await allowed(ctx, "PAY-04", new Set(["kiosk"]));
443
+ if (isOutcome(gate)) return gate;
444
+ const files = await readSources(ctx.cwd);
445
+ const file = files.find(item => {
446
+ return item.rel.endsWith("CheckoutFlowProvider.helpers.ts");
447
+ });
448
+ if (!file) return say(ctx.locale, "fail", COPY.instruction);
449
+ const gap = instructionGap(file.body);
450
+ if (gap === "CASH") return say(ctx.locale, "fail", COPY.cashHelp);
451
+ if (gap) return say(ctx.locale, "fail", COPY.unknown, { method: gap });
452
+ return { status: "pass" };
453
+ };
454
+
455
+ const preferredCash = (value: unknown) => {
456
+ if (!isRecord(value) || !Array.isArray(value.payments)) return false;
457
+ const rows = value.payments.filter(isRecord);
458
+ const preferred = rows.filter(row => row.preferred === true);
459
+ if (preferred.length !== 1) return false;
460
+ return preferred[0]?.type === "CASH";
461
+ };
462
+
463
+ const runPay05 = async (ctx: RunCtx): Promise<Outcome> => {
464
+ const ready = prepare(ctx, ["channel_id"]);
465
+ if (isOutcome(ready)) return ready;
466
+ const template = textOf(ready.template);
467
+ if (!PAY05.has(template)) {
468
+ return say(ctx.locale, "skip", COPY.pay05Skip, { template });
469
+ }
470
+ const named = namedOnly(ctx, "PAY-05");
471
+ if (named) return named;
472
+ const files = await readSources(ctx.cwd);
473
+ const surface = files.some(file => {
474
+ const section = file.rel.includes("CheckoutPaymentSection");
475
+ const meta = file.rel.includes("PaymentMetaForm");
476
+ if (!section && !meta) return false;
477
+ if (!file.body.includes("exactAmount")) return false;
478
+ return file.body.includes("preferred");
479
+ });
480
+ if (!surface) return say(ctx.locale, "fail", COPY.pay05Surface);
481
+ const liveReady = prepare(ctx, LIVE);
482
+ if (isOutcome(liveReady)) return liveReady;
483
+ const live = openLive(ctx, liveReady);
484
+ const cash = {
485
+ id: "doctor-cash",
486
+ type: "CASH",
487
+ preferred: true,
488
+ metadata: { exact_amount: true }
489
+ };
490
+ const card = {
491
+ id: "doctor-card",
492
+ type: "CARD",
493
+ alias: "Doctor",
494
+ brand: "visa",
495
+ first4: "4242",
496
+ last4: "4242",
497
+ exp_month: 12,
498
+ exp_year: 2030,
499
+ provider: "sandbox",
500
+ provider_payment_method_id: "pm_doctor",
501
+ preferred: false,
502
+ metadata: null
503
+ };
504
+ const body = { payments: [cash, card] };
505
+ const codes = [200];
506
+ const args = ["customer-pay", "PATCH", "/customers/me", codes, body] as const;
507
+ const step = await live.storefront<unknown>(...args);
508
+ if (!step.ok) return fromLive(ctx.locale, step.result);
509
+ if (!preferredCash(step.value))
510
+ return say(ctx.locale, "fail", COPY.preferred);
511
+ const cleared = { payments: [] };
512
+ const drop = [
513
+ "customer-clear",
514
+ "PATCH",
515
+ "/customers/me",
516
+ codes,
517
+ cleared
518
+ ] as const;
519
+ const gone = await live.storefront<unknown>(...drop);
520
+ if (!gone.ok) return fromLive(ctx.locale, gone.result);
521
+ return { status: "pass" };
522
+ };
523
+
524
+ const dueOf = (price: number) => new Big(String(price));
525
+
526
+ const cashMath = (amount: Big, tendered: Big) => {
527
+ const given = tendered.lt(amount) ? amount : tendered;
528
+ const change = given.minus(amount);
529
+ return { given, change };
530
+ };
531
+
532
+ const runPay09 = async (ctx: RunCtx): Promise<Outcome> => {
533
+ const catalog = await paymentsOf(ctx);
534
+ if (isOutcome(catalog)) return catalog;
535
+ const cash = cashRow(catalog.rows);
536
+ if (!cash) {
537
+ const channel = channelLabel(catalog.channel);
538
+ return say(ctx.locale, "fail", COPY.noCash, { channel });
539
+ }
540
+ const id = idOf(cash);
541
+ if (disabledIds(ctx.env).has(id)) {
542
+ return say(ctx.locale, "fail", COPY.blocked, { id });
543
+ }
544
+ const menus = await catalog.live.menus();
545
+ if (!menus.ok) return fromLive(ctx.locale, menus.result);
546
+ const product = pickProduct(menus.value);
547
+ const price = product?.pricing?.price;
548
+ if (typeof price !== "number" || !Number.isFinite(price)) {
549
+ const channel = channelLabel(catalog.channel);
550
+ const field = "menus[].products[].pricing.price";
551
+ const vars = { channel, field };
552
+ return say(ctx.locale, "fail", SHARED.flowMissing, vars);
553
+ }
554
+ const due = dueOf(price);
555
+ const math = cashMath(due, due.plus("5"));
556
+ if (!math.change.eq("5")) return say(ctx.locale, "fail", COPY.change);
557
+ return { status: "pass" };
558
+ };
559
+
560
+ const runPay10 = async (ctx: RunCtx): Promise<Outcome> => {
561
+ const catalog = await paymentsOf(ctx);
562
+ if (isOutcome(catalog)) return catalog;
563
+ if (!cashRow(catalog.rows)) {
564
+ const channel = channelLabel(catalog.channel);
565
+ return say(ctx.locale, "fail", COPY.noCash, { channel });
566
+ }
567
+ const other = moneyRows(catalog.rows).find(row => methodOf(row) !== "CASH");
568
+ if (!other) return say(ctx.locale, "skip", COPY.splitSkip);
569
+ const cashLines = new Big("4.10");
570
+ const cardLines = new Big("6.20");
571
+ const fee = new Big("0.15");
572
+ const cardTender = cardLines.plus(fee);
573
+ const total = cashLines.plus(cardTender);
574
+ const expected = cashLines.plus(cardLines).plus(fee);
575
+ if (!total.eq(expected)) return say(ctx.locale, "fail", COPY.split);
576
+ return { status: "pass" };
577
+ };
578
+
579
+ const adapterOf = (rows: Row[]) => {
580
+ return moneyRows(rows).find(row => {
581
+ const method = methodOf(row);
582
+ if (method === "CASH") return false;
583
+ const provider = providerOf(row);
584
+ return provider !== "" && provider !== "firespark";
585
+ });
586
+ };
587
+
588
+ const environmentOf = (row: Row) => {
589
+ if (!isRecord(row.config)) return "";
590
+ const value = row.config.provider_environment;
591
+ return typeof value === "string" ? value : "";
592
+ };
593
+
594
+ const sandboxRow = (rows: Row[]) => {
595
+ return moneyRows(rows).find(row => {
596
+ if (methodOf(row) === "CASH") return false;
597
+ return environmentOf(row) === "sandbox";
598
+ });
599
+ };
600
+
601
+ const runPay08 = async (ctx: RunCtx): Promise<Outcome> => {
602
+ const catalog = await paymentsOf(ctx);
603
+ if (isOutcome(catalog)) return catalog;
604
+ const sandbox = sandboxRow(catalog.rows);
605
+ if (!sandbox) {
606
+ const adapter = adapterOf(catalog.rows);
607
+ if (!adapter) return say(ctx.locale, "skip", COPY.noAdapter);
608
+ const provider = providerOf(adapter) || methodOf(adapter);
609
+ return say(ctx.locale, "skip", COPY.noSandbox, { provider });
610
+ }
611
+ if (environmentOf(sandbox) === "production") {
612
+ const provider = providerOf(sandbox) || methodOf(sandbox);
613
+ return say(ctx.locale, "skip", COPY.noSandbox, { provider });
614
+ }
615
+ const id = idOf(sandbox);
616
+ const path = `/payments/${encodeURIComponent(id)}/operations/charge`;
617
+ const body = { input: {} };
618
+ const codes = [200, 201, 202];
619
+ const args = ["charge", "POST", path, codes, body] as const;
620
+ const step = await catalog.live.probe(...args);
621
+ if (!step.ok) return fromLive(ctx.locale, step.result);
622
+ return { status: "pass" };
623
+ };
624
+
625
+ const mixedOf = (value: unknown) => {
626
+ if (!isRecord(value)) return undefined;
627
+ const payments = value.payments;
628
+ if (!isRecord(payments)) return undefined;
629
+ return payments.mixed;
630
+ };
631
+
632
+ const runPay12 = async (ctx: RunCtx): Promise<Outcome> => {
633
+ const catalog = await paymentsOf(ctx);
634
+ if (isOutcome(catalog)) return catalog;
635
+ const codes = [200];
636
+ const prefArgs = ["preferences", "GET", "/preferences", codes] as const;
637
+ const step = await catalog.live.storefront<unknown>(...prefArgs);
638
+ if (!step.ok) return fromLive(ctx.locale, step.result);
639
+ const mixed = mixedOf(step.value);
640
+ if (mixed === null || mixed === undefined) {
641
+ return say(ctx.locale, "skip", COPY.mixedOff);
642
+ }
643
+ if (!isRecord(mixed)) return say(ctx.locale, "fail", COPY.mixedShort);
644
+ const max = mixed.max_methods;
645
+ if (typeof max === "number" && max < 2) {
646
+ return say(ctx.locale, "fail", COPY.mixedShort);
647
+ }
648
+ if (moneyRows(catalog.rows).length < 2) {
649
+ return say(ctx.locale, "fail", COPY.mixedNeed);
650
+ }
651
+ return { status: "pass" };
652
+ };
653
+
654
+ const runPay13 = async (ctx: RunCtx): Promise<Outcome> => {
655
+ const gate = await allowed(ctx, "PAY-13", new Set(["dashboard"]));
656
+ if (isOutcome(gate)) return gate;
657
+ const named = namedOnly(ctx, "PAY-13");
658
+ if (named) return named;
659
+ const ready = prepare(ctx, LIVE);
660
+ if (isOutcome(ready)) return ready;
661
+ const gap = dashGap(ctx, "PAY-13", ready);
662
+ if (gap) return gap;
663
+ const order = textOf(ctx.env.FIRESPARK_DOCTOR_ORDER_ID);
664
+ if (!order) return need(ctx, "PAY-13", "FIRESPARK_DOCTOR_ORDER_ID");
665
+ const tender = textOf(ctx.env.FIRESPARK_DOCTOR_TENDER_ID);
666
+ if (!tender) return need(ctx, "PAY-13", "FIRESPARK_DOCTOR_TENDER_ID");
667
+ const cookie = textOf(ctx.env.FIRESPARK_DOCTOR_DASHBOARD_COOKIE);
668
+ const dash = textOf(ready.dashboard_url);
669
+ const merchant = textOf(ready.merchant_id);
670
+ const env = textOf(ready.env);
671
+ const refund = {
672
+ merchant_id: merchant,
673
+ env,
674
+ id: order,
675
+ tender_id: tender,
676
+ body: { idempotency_key: randomUUID() }
677
+ };
678
+ const wrote = await trpcCall(dash, cookie, "orders.refundTender", refund);
679
+ if (wrote.status < 200 || wrote.status >= 300) {
680
+ return say(ctx.locale, "fail", COPY.http, {
681
+ id: "PAY-13",
682
+ status: wrote.status
683
+ });
684
+ }
685
+ const live = openLive(ctx, ready);
686
+ const path = `/orders/${encodeURIComponent(order)}/tenders/${tender}/refund`;
687
+ const codes = [401, 403];
688
+ const denied = ["refund-denied", "POST", path, codes, {}] as const;
689
+ const probe = await live.probe(...denied);
690
+ if (!probe.ok) return fromLive(ctx.locale, probe.result);
691
+ const status = probe.value.status;
692
+ if (status >= 200 && status < 300) {
693
+ return say(ctx.locale, "fail", COPY.refunded);
694
+ }
695
+ return { status: "pass" };
696
+ };
697
+
698
+ const ORDER_FIELDS = [
699
+ ...LIVE,
700
+ "store_id",
701
+ "fulfillment_id",
702
+ "payment",
703
+ "merchant_id"
704
+ ] as const;
705
+
706
+ const runPay07 = async (ctx: RunCtx): Promise<Outcome> => {
707
+ const skipped = namedOrderSkip(ctx, "PAY-07");
708
+ if (skipped) return skipped;
709
+ const ready = prepare(ctx, ORDER_FIELDS);
710
+ if (isOutcome(ready)) return ready;
711
+ if (!paymentOf(textOf(ready.payment))) {
712
+ return say(ctx.locale, "fail", COPY.notCash);
713
+ }
714
+ const catalog = await paymentsOf(ctx);
715
+ if (isOutcome(catalog)) return catalog;
716
+ const cash = cashRow(catalog.rows);
717
+ if (!cash) {
718
+ const channel = channelLabel(catalog.channel);
719
+ return say(ctx.locale, "fail", COPY.noCash, { channel });
720
+ }
721
+ if (ready.template === "pos") {
722
+ const due = new Big("10.00");
723
+ const math = cashMath(due, due);
724
+ if (!math.change.eq(0)) return say(ctx.locale, "fail", COPY.change);
725
+ return { status: "pass" };
726
+ }
727
+ const menus = await catalog.live.menus();
728
+ if (!menus.ok) return fromLive(ctx.locale, menus.result);
729
+ const product = pickProduct(menus.value);
730
+ if (!product?.id) {
731
+ const channel = channelLabel(ready);
732
+ const field = "menus[].products";
733
+ const missing = { channel, field };
734
+ return say(ctx.locale, "fail", SHARED.flowMissing, missing);
735
+ }
736
+ const id = newOrderId(textOf(ready.store_id));
737
+ const menu = menus.value.find(item => item.products?.includes(product));
738
+ const config = liveConfigOf(ready, ctx.env);
739
+ const lineUid = randomUUID();
740
+ const b1 = { id, config, product, lineUid };
741
+ const built = buildOrderBody({ ...b1, menuId: menu?.id });
742
+ const codes = [201, 202];
743
+ const create = ["order-create", "POST", "/orders", codes, built] as const;
744
+ const created = await catalog.live.storefront<{ id?: string }>(...create);
745
+ if (!created.ok) return fromLive(ctx.locale, created.result);
746
+ const payment = { type: "CASH", id: idOf(cash) };
747
+ const placePath = `/orders/${encodeURIComponent(id)}/place`;
748
+ const body = { payment };
749
+ const placeArgs = ["order-place", "POST", placePath, codes, body] as const;
750
+ const place = catalog.live.storefront<{ status?: string }>;
751
+ const placed = await place(...placeArgs);
752
+ if (!placed.ok) return fromLive(ctx.locale, placed.result);
753
+ const status = placed.value.status ?? "";
754
+ if (!PLACE.has(status)) {
755
+ return say(ctx.locale, "fail", COPY.placeStatus, { status: status || "—" });
756
+ }
757
+ return { status: "pass" };
758
+ };
759
+
760
+ export const PAY_BINDINGS: Record<string, CheckBinding> = {
761
+ "PAY-01": withSafeFix(runPay01),
762
+ "PAY-02": withSafeFix(runPay02),
763
+ "PAY-03": withSafeFix(runPay03),
764
+ "PAY-04": withSafeFix(runPay04),
765
+ "PAY-05": withSafeFix(runPay05),
766
+ "PAY-07": withSafeFix(runPay07),
767
+ "PAY-08": withSafeFix(runPay08),
768
+ "PAY-09": withSafeFix(runPay09),
769
+ "PAY-10": withSafeFix(runPay10),
770
+ "PAY-12": withSafeFix(runPay12),
771
+ "PAY-13": withSafeFix(runPay13)
772
+ };