1import { withRequestOptions } from "@distilled.cloud/stripe";
2import {
3 DeleteTerminalConfiguration,
4 GetTerminalConfigurations,
5 GetTerminalConfiguration,
6 CreateTerminalConfiguration,
7 UpdateTerminalConfiguration,
8 type DeletedTerminalConfiguration,
9 type CreateTerminalConfigurationRequest,
10 type CreateTerminalConfigurationRequestTippingCase0,
11 type CreateTerminalConfigurationRequestWifiCase0,
12 type TerminalConfiguration as StripeTerminalConfiguration,
13 type TerminalConfigurationConfigurationResourceCurrencySpecificConfig,
14 type TerminalConfigurationConfigurationResourceDeviceTypeSpecificConfig,
15 type TerminalConfigurationConfigurationResourceTipping,
16 type TerminalConfigurationConfigurationResourceWifiConfig,
17} from "@distilled.cloud/stripe/stripe";
18import * as Data from "effect/Data";
19import * as Effect from "effect/Effect";
20import { Unowned } from "../AdoptPolicy.ts";
21import { deepEqual, isResolved } from "../Diff.ts";
22import { createPhysicalName } from "../PhysicalName.ts";
23import * as Provider from "../Provider.ts";
24import { Resource } from "../Resource.ts";
25import type { Providers } from "./Providers.ts";
26import { isMissingStripeResource } from "./missing.ts";
27
28const NAME_MAX_LENGTH = 250;
29const LIST_PAGE_SIZE = 100;
30const LIST_MAX_PAGES = 100;
31
32/** Fixed amounts / percentages shown when collecting an on-reader tip. */
33export interface TerminalTippingCurrency {
34 /**
35 * Fixed amounts (in the currency's minor units) displayed when
36 * collecting a tip.
37 */
38 fixedAmounts?: number[];
39 /**
40 * Percentages displayed when collecting a tip.
41 */
42 percentages?: number[];
43 /**
44 * Below this amount (minor units), fixed amounts are shown; above it,
45 * percentages are shown. Requires both `fixedAmounts` and `percentages`.
46 */
47 smartTipThreshold?: number;
48}
49
50/** Per-currency on-reader tipping. Keys are lowercase ISO 4217 codes. */
51export interface TerminalTipping {
52 /** Tipping configuration for AED. */
53 aed?: TerminalTippingCurrency;
54 /** Tipping configuration for AUD. */
55 aud?: TerminalTippingCurrency;
56 /** Tipping configuration for CAD. */
57 cad?: TerminalTippingCurrency;
58 /** Tipping configuration for CHF. */
59 chf?: TerminalTippingCurrency;
60 /** Tipping configuration for CZK. */
61 czk?: TerminalTippingCurrency;
62 /** Tipping configuration for DKK. */
63 dkk?: TerminalTippingCurrency;
64 /** Tipping configuration for EUR. */
65 eur?: TerminalTippingCurrency;
66 /** Tipping configuration for GBP. */
67 gbp?: TerminalTippingCurrency;
68 /** Tipping configuration for GIP. */
69 gip?: TerminalTippingCurrency;
70 /** Tipping configuration for HKD. */
71 hkd?: TerminalTippingCurrency;
72 /** Tipping configuration for HUF. */
73 huf?: TerminalTippingCurrency;
74 /** Tipping configuration for JPY. */
75 jpy?: TerminalTippingCurrency;
76 /** Tipping configuration for MXN. */
77 mxn?: TerminalTippingCurrency;
78 /** Tipping configuration for MYR. */
79 myr?: TerminalTippingCurrency;
80 /** Tipping configuration for NOK. */
81 nok?: TerminalTippingCurrency;
82 /** Tipping configuration for NZD. */
83 nzd?: TerminalTippingCurrency;
84 /** Tipping configuration for PLN. */
85 pln?: TerminalTippingCurrency;
86 /** Tipping configuration for RON. */
87 ron?: TerminalTippingCurrency;
88 /** Tipping configuration for SEK. */
89 sek?: TerminalTippingCurrency;
90 /** Tipping configuration for SGD. */
91 sgd?: TerminalTippingCurrency;
92 /** Tipping configuration for USD. */
93 usd?: TerminalTippingCurrency;
94}
95
96/** Device-specific splash screen. */
97export interface TerminalDeviceConfig {
98 /**
99 * Stripe File id of an image to display on the reader. Empty string
100 * clears it on update.
101 */
102 splashscreen?: string;
103}
104
105/** Offline-mode collection. */
106export interface TerminalOffline {
107 /**
108 * Whether the reader may collect transactions while offline.
109 */
110 enabled: boolean;
111}
112
113/** Cellular connectivity. */
114export interface TerminalCellular {
115 /**
116 * Whether a cellular-capable reader may connect over cellular.
117 */
118 enabled: boolean;
119}
120
121/** Daily reboot window (hours in 0–23). */
122export interface TerminalRebootWindow {
123 /**
124 * Start hour of the reboot window (0–23).
125 */
126 startHour: number;
127 /**
128 * End hour of the reboot window (0–23). Must differ from `startHour`.
129 */
130 endHour: number;
131}
132
133/** Wi-Fi security type. */
134export type TerminalWifiType =
135 | "enterprise_eap_peap"
136 | "enterprise_eap_tls"
137 | "personal_psk";
138
139/** WPA-Enterprise EAP-PEAP credentials. */
140export interface TerminalWifiEnterprisePeap {
141 /**
142 * Wi-Fi network name.
143 */
144 ssid: string;
145 /**
146 * Username for the network.
147 */
148 username: string;
149 /**
150 * Password for the network. Never returned on read.
151 */
152 password: string;
153 /**
154 * Stripe File id of a PEM containing the server certificate.
155 */
156 caCertificateFile?: string;
157}
158
159/** WPA-Enterprise EAP-TLS credentials. */
160export interface TerminalWifiEnterpriseTls {
161 /**
162 * Wi-Fi network name.
163 */
164 ssid: string;
165 /**
166 * Stripe File id of a PEM containing the client certificate.
167 */
168 clientCertificateFile: string;
169 /**
170 * Stripe File id of a PEM containing the client RSA private key.
171 */
172 privateKeyFile: string;
173 /**
174 * Stripe File id of a PEM containing the server certificate.
175 */
176 caCertificateFile?: string;
177 /**
178 * Password for the private key file. Never returned on read.
179 */
180 privateKeyFilePassword?: string;
181}
182
183/** WPA-Personal PSK credentials. */
184export interface TerminalWifiPersonalPsk {
185 /**
186 * Wi-Fi network name.
187 */
188 ssid: string;
189 /**
190 * Password for the network. Never returned on read.
191 */
192 password: string;
193}
194
195/** Reader Wi-Fi configuration. */
196export interface TerminalWifi {
197 /**
198 * Security type. The matching nested object holds the credentials.
199 */
200 type: TerminalWifiType;
201 /**
202 * EAP-PEAP credentials. Required when `type` is `enterprise_eap_peap`.
203 */
204 enterpriseEapPeap?: TerminalWifiEnterprisePeap;
205 /**
206 * EAP-TLS credentials. Required when `type` is `enterprise_eap_tls`.
207 */
208 enterpriseEapTls?: TerminalWifiEnterpriseTls;
209 /**
210 * Personal PSK credentials. Required when `type` is `personal_psk`.
211 */
212 personalPsk?: TerminalWifiPersonalPsk;
213}
214
215/** Observed Wi-Fi state with secrets stripped. */
216export interface TerminalWifiState {
217 /**
218 * Security type currently configured.
219 */
220 type: TerminalWifiType;
221 /**
222 * Observed EAP-PEAP settings (password omitted).
223 */
224 enterpriseEapPeap?: {
225 ssid: string;
226 username: string;
227 caCertificateFile: string | undefined;
228 };
229 /**
230 * Observed EAP-TLS settings (private-key password omitted).
231 */
232 enterpriseEapTls?: {
233 ssid: string;
234 clientCertificateFile: string;
235 privateKeyFile: string;
236 caCertificateFile: string | undefined;
237 };
238 /**
239 * Observed personal PSK settings (password omitted).
240 */
241 personalPsk?: {
242 ssid: string;
243 };
244}
245
246export interface TerminalConfigurationProps {
247 /**
248 * Display name of the configuration. If omitted, a unique name is
249 * generated from the stack, stage, and logical id. Mutable. Used as the
250 * lookup key when the Stripe id is missing — this object has no
251 * metadata.
252 */
253 name?: string;
254 /**
255 * Offline transaction collection. `null` clears it on update.
256 */
257 offline?: TerminalOffline | null;
258 /**
259 * On-reader tipping by currency. `null` clears it on update.
260 */
261 tipping?: TerminalTipping | null;
262 /**
263 * Daily reboot window. `null` clears it on update.
264 */
265 rebootWindow?: TerminalRebootWindow | null;
266 /**
267 * Cellular connectivity. `null` clears it on update.
268 */
269 cellular?: TerminalCellular | null;
270 /**
271 * Reader Wi-Fi. `null` clears it on update. Passwords are write-only.
272 */
273 wifi?: TerminalWifi | null;
274 /**
275 * BBPOS WisePad 3 splash screen. `null` clears it on update.
276 */
277 bbposWisepad3?: TerminalDeviceConfig | null;
278 /**
279 * BBPOS WisePOS E splash screen. `null` clears it on update.
280 */
281 bbposWiseposE?: TerminalDeviceConfig | null;
282 /**
283 * Stripe S700 splash screen. `null` clears it on update.
284 */
285 stripeS700?: TerminalDeviceConfig | null;
286 /**
287 * Stripe S710 splash screen. `null` clears it on update.
288 */
289 stripeS710?: TerminalDeviceConfig | null;
290 /**
291 * Verifone M425 splash screen. `null` clears it on update.
292 */
293 verifoneM425?: TerminalDeviceConfig | null;
294 /**
295 * Verifone P400 splash screen. `null` clears it on update.
296 */
297 verifoneP400?: TerminalDeviceConfig | null;
298 /**
299 * Verifone P630 splash screen. `null` clears it on update.
300 */
301 verifoneP630?: TerminalDeviceConfig | null;
302 /**
303 * Verifone UX700 splash screen. `null` clears it on update.
304 */
305 verifoneUx700?: TerminalDeviceConfig | null;
306 /**
307 * Verifone V660p splash screen. `null` clears it on update.
308 */
309 verifoneV660p?: TerminalDeviceConfig | null;
310}
311
312type DeviceKey = (typeof DEVICE_MAP)[number][0];
313type SnakeDeviceKey = (typeof DEVICE_MAP)[number][1];
314type TippingCurrency = (typeof TIPPING_CURRENCIES)[number];
315
316const DEVICE_MAP = [
317 ["bbposWisepad3", "bbpos_wisepad3"],
318 ["bbposWiseposE", "bbpos_wisepos_e"],
319 ["stripeS700", "stripe_s700"],
320 ["stripeS710", "stripe_s710"],
321 ["verifoneM425", "verifone_m425"],
322 ["verifoneP400", "verifone_p400"],
323 ["verifoneP630", "verifone_p630"],
324 ["verifoneUx700", "verifone_ux700"],
325 ["verifoneV660p", "verifone_v660p"],
326] as const satisfies ReadonlyArray<readonly [string, string]>;
327
328const TIPPING_CURRENCIES = [
329 "aed",
330 "aud",
331 "cad",
332 "chf",
333 "czk",
334 "dkk",
335 "eur",
336 "gbp",
337 "gip",
338 "hkd",
339 "huf",
340 "jpy",
341 "mxn",
342 "myr",
343 "nok",
344 "nzd",
345 "pln",
346 "ron",
347 "sek",
348 "sgd",
349 "usd",
350] as const satisfies ReadonlyArray<keyof TerminalTipping>;
351
352type DeviceAttributes = {
353 [K in DeviceKey]: TerminalDeviceConfig | undefined;
354};
355
356export type TerminalConfiguration = Resource<
357 "Stripe.TerminalConfiguration",
358 TerminalConfigurationProps,
359 {
360 /** Stripe Terminal configuration id (`tmc_…`). */
361 id: string;
362 /** Display name of the configuration, if set. */
363 name: string | undefined;
364 /** Whether this is the account's default configuration. */
365 isAccountDefault: boolean;
366 /** Offline transaction collection, if configured. */
367 offline: TerminalOffline | undefined;
368 /** On-reader tipping by currency, if configured. */
369 tipping: TerminalTipping | undefined;
370 /** Daily reboot window, if configured. */
371 rebootWindow: TerminalRebootWindow | undefined;
372 /** Cellular connectivity, if configured. */
373 cellular: TerminalCellular | undefined;
374 /** Observed Wi-Fi (passwords omitted). */
375 wifi: TerminalWifiState | undefined;
376 /** Whether the configuration exists in live mode. */
377 livemode: boolean;
378 } & DeviceAttributes,
379 never,
380 Providers
381>;
382
383/**
384 * A Stripe Terminal Configuration — splash screens, tipping, offline
385 * mode, reboot window, cellular, and Wi-Fi for smart readers. Name and
386 * nested settings update in place. Destroy hard-deletes the
387 * configuration. The account default configuration is never adopted or
388 * listed.
389 *
390 * Terminal configurations have no metadata field. Identity is the Stripe
391 * id plus the unique name. Account-wide `list()` (nuke) enumerates every
392 * non-default configuration on the account.
393 *
394 * @see https://docs.stripe.com/api/terminal/configuration
395 *
396 * ### Creating a Configuration
397 * **Example:** Named configuration with USD tipping
398 * ```typescript
399 * const config = yield* Stripe.TerminalConfiguration("storefront", {
400 * name: "Storefront",
401 * offline: { enabled: true },
402 * tipping: {
403 * usd: {
404 * fixedAmounts: [100, 200, 300],
405 * percentages: [15, 20, 25],
406 * smartTipThreshold: 1000,
407 * },
408 * },
409 * });
410 * ```
411 *
412 * **Example:** Reboot window
413 * ```typescript
414 * const config = yield* Stripe.TerminalConfiguration("storefront", {
415 * name: "Storefront",
416 * rebootWindow: { startHour: 2, endHour: 4 },
417 * });
418 * ```
419 *
420 * ### Updating a Configuration
421 * **Example:** Rename and change tipping
422 * ```typescript
423 * const config = yield* Stripe.TerminalConfiguration("storefront", {
424 * name: "Storefront (updated)",
425 * offline: { enabled: false },
426 * tipping: {
427 * usd: {
428 * fixedAmounts: [200, 300, 400],
429 * percentages: [10, 15, 20],
430 * smartTipThreshold: 2000,
431 * },
432 * },
433 * });
434 * ```
435 *
436 * ### Deleting a Configuration
437 * **Example:** Destroy deletes the configuration
438 * ```typescript
439 * // stack.destroy() / resource removal hard-deletes the configuration
440 * const config = yield* Stripe.TerminalConfiguration("storefront", {
441 * name: "Storefront",
442 * });
443 * ```
444 *
445 * @resource
446 * @product Terminal
447 */
448export const TerminalConfiguration = Resource<TerminalConfiguration>(
449 "Stripe.TerminalConfiguration",
450);
451
452export class TerminalConfigurationNotResolved extends Data.TaggedError(
453 "Stripe.TerminalConfigurationNotResolved",
454)<{
455 configurationId: string | undefined;
456 name: string;
457}> {}
458
459type ConfigurationAttributes = TerminalConfiguration["Attributes"];
460
461const toName = (id: string, name: string | undefined, existing?: string) =>
462 Effect.gen(function* () {
463 return (
464 name ??
465 existing ??
466 (yield* createPhysicalName({ id, maxLength: NAME_MAX_LENGTH }))
467 );
468 });
469
470const isDeletedConfiguration = (
471 value: StripeTerminalConfiguration | DeletedTerminalConfiguration,
472): value is DeletedTerminalConfiguration =>
473 "deleted" in value && value.deleted === true;
474
475const asConfiguration = (
476 value: StripeTerminalConfiguration | DeletedTerminalConfiguration | undefined,
477): StripeTerminalConfiguration | undefined => {
478 if (value === undefined || isDeletedConfiguration(value)) return undefined;
479 return value;
480};
481
482const isAccountDefault = (configuration: StripeTerminalConfiguration) =>
483 configuration.is_account_default === true;
484
485const toFileId = (value: unknown): string | undefined => {
486 if (value === undefined || value === null) return undefined;
487 if (typeof value === "string") return value === "" ? undefined : value;
488 if (
489 typeof value === "object" &&
490 "id" in value &&
491 typeof (value as { id: unknown }).id === "string"
492 ) {
493 return (value as { id: string }).id;
494 }
495 return undefined;
496};
497
498const toDeviceState = (
499 value:
500 | TerminalConfigurationConfigurationResourceDeviceTypeSpecificConfig
501 | undefined,
502): TerminalDeviceConfig | undefined => {
503 if (value === undefined) return undefined;
504 const splashscreen = toFileId(value.splashscreen);
505 return splashscreen === undefined ? {} : { splashscreen };
506};
507
508const toTippingCurrency = (
509 value:
510 | TerminalConfigurationConfigurationResourceCurrencySpecificConfig
511 | undefined,
512): TerminalTippingCurrency | undefined => {
513 if (value === undefined) return undefined;
514 return {
515 ...(value.fixed_amounts != null
516 ? { fixedAmounts: value.fixed_amounts }
517 : {}),
518 ...(value.percentages != null ? { percentages: value.percentages } : {}),
519 ...(value.smart_tip_threshold !== undefined
520 ? { smartTipThreshold: value.smart_tip_threshold }
521 : {}),
522 };
523};
524
525const toTipping = (
526 tipping: TerminalConfigurationConfigurationResourceTipping | undefined,
527): TerminalTipping | undefined => {
528 if (tipping === undefined) return undefined;
529 const out: TerminalTipping = {};
530 for (const currency of TIPPING_CURRENCIES) {
531 const mapped = toTippingCurrency(tipping[currency]);
532 if (mapped !== undefined) out[currency] = mapped;
533 }
534 return Object.keys(out).length > 0 ? out : undefined;
535};
536
537const toWifiState = (
538 wifi: TerminalConfigurationConfigurationResourceWifiConfig | undefined,
539): TerminalWifiState | undefined => {
540 if (wifi === undefined) return undefined;
541 return {
542 type: wifi.type,
543 ...(wifi.enterprise_eap_peap !== undefined
544 ? {
545 enterpriseEapPeap: {
546 ssid: wifi.enterprise_eap_peap.ssid,
547 username: wifi.enterprise_eap_peap.username,
548 caCertificateFile: wifi.enterprise_eap_peap.ca_certificate_file,
549 },
550 }
551 : {}),
552 ...(wifi.enterprise_eap_tls !== undefined
553 ? {
554 enterpriseEapTls: {
555 ssid: wifi.enterprise_eap_tls.ssid,
556 clientCertificateFile:
557 wifi.enterprise_eap_tls.client_certificate_file,
558 privateKeyFile: wifi.enterprise_eap_tls.private_key_file,
559 caCertificateFile: wifi.enterprise_eap_tls.ca_certificate_file,
560 },
561 }
562 : {}),
563 ...(wifi.personal_psk !== undefined
564 ? { personalPsk: { ssid: wifi.personal_psk.ssid } }
565 : {}),
566 };
567};
568
569const toAttrs = (
570 configuration: StripeTerminalConfiguration,
571): ConfigurationAttributes => {
572 const devices = {} as DeviceAttributes;
573 for (const [camel, snake] of DEVICE_MAP) {
574 devices[camel] = toDeviceState(configuration[snake]);
575 }
576 return {
577 id: configuration.id,
578 name: configuration.name ?? undefined,
579 isAccountDefault: isAccountDefault(configuration),
580 offline:
581 configuration.offline?.enabled == null
582 ? undefined
583 : { enabled: configuration.offline.enabled },
584 tipping: toTipping(configuration.tipping),
585 rebootWindow:
586 configuration.reboot_window === undefined
587 ? undefined
588 : {
589 startHour: configuration.reboot_window.start_hour,
590 endHour: configuration.reboot_window.end_hour,
591 },
592 cellular:
593 configuration.cellular === undefined
594 ? undefined
595 : { enabled: configuration.cellular.enabled },
596 wifi: toWifiState(configuration.wifi),
597 livemode: configuration.livemode,
598 ...devices,
599 };
600};
601
602const toWireTippingCurrency = (value: TerminalTippingCurrency) => ({
603 ...(value.fixedAmounts !== undefined
604 ? { fixed_amounts: value.fixedAmounts }
605 : {}),
606 ...(value.percentages !== undefined
607 ? { percentages: value.percentages }
608 : {}),
609 ...(value.smartTipThreshold !== undefined
610 ? { smart_tip_threshold: value.smartTipThreshold }
611 : {}),
612});
613
614const toWireTipping = (
615 tipping: TerminalTipping,
616): CreateTerminalConfigurationRequestTippingCase0 => {
617 const out: CreateTerminalConfigurationRequestTippingCase0 = {};
618 for (const currency of TIPPING_CURRENCIES) {
619 const value = tipping[currency];
620 if (value !== undefined) {
621 out[currency] = toWireTippingCurrency(value);
622 }
623 }
624 return out;
625};
626
627const toWireWifi = (
628 wifi: TerminalWifi,
629): CreateTerminalConfigurationRequestWifiCase0 => ({
630 type: wifi.type,
631 ...(wifi.enterpriseEapPeap !== undefined
632 ? {
633 enterprise_eap_peap: {
634 ssid: wifi.enterpriseEapPeap.ssid,
635 username: wifi.enterpriseEapPeap.username,
636 password: wifi.enterpriseEapPeap.password,
637 ...(wifi.enterpriseEapPeap.caCertificateFile !== undefined
638 ? { ca_certificate_file: wifi.enterpriseEapPeap.caCertificateFile }
639 : {}),
640 },
641 }
642 : {}),
643 ...(wifi.enterpriseEapTls !== undefined
644 ? {
645 enterprise_eap_tls: {
646 ssid: wifi.enterpriseEapTls.ssid,
647 client_certificate_file: wifi.enterpriseEapTls.clientCertificateFile,
648 private_key_file: wifi.enterpriseEapTls.privateKeyFile,
649 ...(wifi.enterpriseEapTls.caCertificateFile !== undefined
650 ? { ca_certificate_file: wifi.enterpriseEapTls.caCertificateFile }
651 : {}),
652 ...(wifi.enterpriseEapTls.privateKeyFilePassword !== undefined
653 ? {
654 private_key_file_password:
655 wifi.enterpriseEapTls.privateKeyFilePassword,
656 }
657 : {}),
658 },
659 }
660 : {}),
661 ...(wifi.personalPsk !== undefined
662 ? {
663 personal_psk: {
664 ssid: wifi.personalPsk.ssid,
665 password: wifi.personalPsk.password,
666 },
667 }
668 : {}),
669});
670
671const toWireDevice = (device: TerminalDeviceConfig) =>
672 device.splashscreen !== undefined
673 ? { splashscreen: device.splashscreen }
674 : {};
675
676const wifiComparable = (
677 wifi: TerminalWifi | TerminalWifiState | undefined,
678): unknown => {
679 if (wifi === undefined) return undefined;
680 return {
681 type: wifi.type,
682 enterpriseEapPeap:
683 wifi.enterpriseEapPeap === undefined
684 ? undefined
685 : {
686 ssid: wifi.enterpriseEapPeap.ssid,
687 username:
688 "username" in wifi.enterpriseEapPeap
689 ? wifi.enterpriseEapPeap.username
690 : undefined,
691 caCertificateFile: wifi.enterpriseEapPeap.caCertificateFile,
692 },
693 enterpriseEapTls:
694 wifi.enterpriseEapTls === undefined
695 ? undefined
696 : {
697 ssid: wifi.enterpriseEapTls.ssid,
698 clientCertificateFile: wifi.enterpriseEapTls.clientCertificateFile,
699 privateKeyFile: wifi.enterpriseEapTls.privateKeyFile,
700 caCertificateFile: wifi.enterpriseEapTls.caCertificateFile,
701 },
702 personalPsk:
703 wifi.personalPsk === undefined
704 ? undefined
705 : { ssid: wifi.personalPsk.ssid },
706 };
707};
708
709const pickSpecified = (desired: unknown, observed: unknown): unknown => {
710 if (desired === undefined) return undefined;
711 if (Array.isArray(desired)) {
712 if (!Array.isArray(observed)) return observed;
713 return desired.map((item, index) => pickSpecified(item, observed[index]));
714 }
715 if (
716 desired !== null &&
717 typeof desired === "object" &&
718 observed !== null &&
719 typeof observed === "object"
720 ) {
721 const out: Record<string, unknown> = {};
722 for (const key of Object.keys(desired as Record<string, unknown>)) {
723 out[key] = pickSpecified(
724 (desired as Record<string, unknown>)[key],
725 (observed as Record<string, unknown>)[key],
726 );
727 }
728 return out;
729 }
730 return observed;
731};
732
733const nestedNeedSync = (desired: unknown, observed: unknown): boolean => {
734 if (desired === undefined) return false;
735 if (desired === null) return observed !== undefined;
736 return !deepEqual(desired, pickSpecified(desired, observed), {
737 stripNullish: true,
738 });
739};
740
741const isMissingConfiguration = isMissingStripeResource;
742
743const getById = (configuration: string) =>
744 GetTerminalConfiguration({ configuration }).pipe(
745 Effect.map(asConfiguration),
746 Effect.catchIf(isMissingConfiguration, () => Effect.succeed(undefined)),
747 );
748
749const listNonDefault = Effect.fn(function* () {
750 const configurations: StripeTerminalConfiguration[] = [];
751 let startingAfter: string | undefined;
752 for (let page = 0; page < LIST_MAX_PAGES; page++) {
753 const response = yield* GetTerminalConfigurations({
754 is_account_default: false,
755 limit: LIST_PAGE_SIZE,
756 ...(startingAfter !== undefined ? { starting_after: startingAfter } : {}),
757 });
758 configurations.push(...response.data);
759 if (!response.has_more || response.data.length === 0) {
760 break;
761 }
762 startingAfter = response.data[response.data.length - 1]?.id;
763 if (startingAfter === undefined) {
764 break;
765 }
766 }
767 return configurations.filter(
768 (configuration) => !isAccountDefault(configuration),
769 );
770});
771
772const findByName = Effect.fn(function* (name: string) {
773 const configurations = yield* listNonDefault();
774 return configurations.find(
775 (configuration) => (configuration.name ?? "") === name,
776 );
777});
778
779const observe = Effect.fn(function* (input: { id?: string; name?: string }) {
780 if (input.id !== undefined) {
781 const byId = yield* getById(input.id);
782 if (byId !== undefined) return byId;
783 }
784 if (input.name !== undefined) {
785 return yield* findByName(input.name);
786 }
787 return undefined;
788});
789
790type DeviceWire = { splashscreen?: string };
791
792const specifiedDevices = (
793 news: TerminalConfigurationProps,
794): Partial<Record<SnakeDeviceKey, DeviceWire | "">> => {
795 const payload: Partial<Record<SnakeDeviceKey, DeviceWire | "">> = {};
796 for (const [camel, snake] of DEVICE_MAP) {
797 const value = news[camel];
798 if (value === undefined) continue;
799 payload[snake] = value === null ? "" : toWireDevice(value);
800 }
801 return payload;
802};
803
804const toCreatePayload = (
805 name: string,
806 news: TerminalConfigurationProps,
807): CreateTerminalConfigurationRequest => {
808 const devices = specifiedDevices(news);
809 const createDevices: Partial<Record<SnakeDeviceKey, DeviceWire>> = {};
810 for (const [snake, value] of Object.entries(devices) as Array<
811 [SnakeDeviceKey, DeviceWire | ""]
812 >) {
813 if (value !== "") createDevices[snake] = value;
814 }
815 return {
816 name,
817 ...(news.offline != null ? { offline: news.offline } : {}),
818 ...(news.tipping != null ? { tipping: toWireTipping(news.tipping) } : {}),
819 ...(news.rebootWindow != null
820 ? {
821 reboot_window: {
822 start_hour: news.rebootWindow.startHour,
823 end_hour: news.rebootWindow.endHour,
824 },
825 }
826 : {}),
827 ...(news.cellular != null ? { cellular: news.cellular } : {}),
828 ...(news.wifi != null ? { wifi: toWireWifi(news.wifi) } : {}),
829 ...createDevices,
830 };
831};
832
833export const TerminalConfigurationProvider = () =>
834 Provider.succeed(TerminalConfiguration, {
835 stables: ["id", "livemode", "isAccountDefault"],
836
837 diff: Effect.fn(function* ({ news }) {
838 if (!isResolved(news)) return undefined;
839 return undefined;
840 }),
841
842 read: Effect.fn(function* ({ output }) {
843 const existing = yield* observe({
844 id: output?.id,
845 name: output?.name,
846 });
847 if (existing === undefined) return undefined;
848 const attrs = toAttrs(existing);
849 // No metadata. Identity is the Stripe id and unique name. Never
850 // take over the account default configuration.
851 return isAccountDefault(existing) ? Unowned(attrs) : attrs;
852 }),
853
854 list: Effect.fn(function* () {
855 // No metadata on this resource. Account-wide list is every
856 // non-default configuration so nuke can drain the account.
857 const configurations = yield* listNonDefault();
858 return configurations.map(toAttrs);
859 }),
860
861 reconcile: Effect.fn(function* ({ id, news, output, instanceId }) {
862 const name = yield* toName(id, news.name, output?.name);
863
864 let current = yield* observe({
865 id: output?.id,
866 name,
867 });
868 if (current !== undefined && isAccountDefault(current)) {
869 current = undefined;
870 }
871
872 if (current === undefined) {
873 current = yield* CreateTerminalConfiguration(
874 toCreatePayload(name, news),
875 ).pipe(
876 withRequestOptions({
877 idempotencyKey: `alchemy-terminal-configuration-${instanceId}`,
878 }),
879 );
880 }
881
882 if (current === undefined) {
883 return yield* new TerminalConfigurationNotResolved({
884 configurationId: output?.id,
885 name,
886 });
887 }
888
889 const attrs = toAttrs(current);
890 const nameChanged = (current.name ?? "") !== name;
891 const offlineChanged = nestedNeedSync(news.offline, attrs.offline);
892 const tippingChanged = nestedNeedSync(news.tipping, attrs.tipping);
893 const rebootWindowChanged = nestedNeedSync(
894 news.rebootWindow,
895 attrs.rebootWindow,
896 );
897 const cellularChanged = nestedNeedSync(news.cellular, attrs.cellular);
898 const wifiChanged =
899 news.wifi === null
900 ? attrs.wifi !== undefined
901 : news.wifi !== undefined &&
902 nestedNeedSync(
903 wifiComparable(news.wifi),
904 wifiComparable(attrs.wifi),
905 );
906
907 const changedDevices: Partial<Record<SnakeDeviceKey, DeviceWire | "">> =
908 {};
909 for (const [camel, snake] of DEVICE_MAP) {
910 const desired = news[camel];
911 if (desired === undefined) continue;
912 if (nestedNeedSync(desired, attrs[camel])) {
913 changedDevices[snake] = desired === null ? "" : toWireDevice(desired);
914 }
915 }
916 const devicesChanged = Object.keys(changedDevices).length > 0;
917
918 if (
919 !nameChanged &&
920 !offlineChanged &&
921 !tippingChanged &&
922 !rebootWindowChanged &&
923 !cellularChanged &&
924 !wifiChanged &&
925 !devicesChanged
926 ) {
927 return attrs;
928 }
929
930 const updated = yield* UpdateTerminalConfiguration({
931 configuration: current.id,
932 ...(nameChanged ? { name } : {}),
933 ...(offlineChanged
934 ? { offline: news.offline === null ? "" : news.offline! }
935 : {}),
936 ...(tippingChanged
937 ? {
938 tipping:
939 news.tipping === null ? "" : toWireTipping(news.tipping!),
940 }
941 : {}),
942 ...(rebootWindowChanged
943 ? {
944 reboot_window:
945 news.rebootWindow === null
946 ? ""
947 : {
948 start_hour: news.rebootWindow!.startHour,
949 end_hour: news.rebootWindow!.endHour,
950 },
951 }
952 : {}),
953 ...(cellularChanged
954 ? { cellular: news.cellular === null ? "" : news.cellular! }
955 : {}),
956 ...(wifiChanged
957 ? { wifi: news.wifi === null ? "" : toWireWifi(news.wifi!) }
958 : {}),
959 ...changedDevices,
960 });
961 const next = asConfiguration(updated);
962 if (next === undefined) {
963 return yield* new TerminalConfigurationNotResolved({
964 configurationId: current.id,
965 name,
966 });
967 }
968 return toAttrs(next);
969 }),
970
971 delete: Effect.fn(function* ({ output }) {
972 const existing = yield* getById(output.id);
973 if (existing === undefined || isAccountDefault(existing)) return;
974 yield* DeleteTerminalConfiguration({
975 configuration: existing.id,
976 }).pipe(Effect.catchIf(isMissingConfiguration, () => Effect.void));
977 }),
978 });