1import { withRequestOptions } from "@distilled.cloud/stripe";
2import {
3 DeleteTerminalReader,
4 GetTerminalReaders,
5 GetTerminalReader,
6 CreateTerminalReader,
7 UpdateTerminalReader,
8 type DeletedTerminalReader,
9 type TerminalReader as StripeTerminalReader,
10 type TerminalReaderDeviceType,
11 type TerminalReaderLocation,
12 type TerminalReaderStatus,
13} from "@distilled.cloud/stripe/stripe";
14import * as Data from "effect/Data";
15import * as Effect from "effect/Effect";
16import { Unowned } from "../AdoptPolicy.ts";
17import { isResolved } from "../Diff.ts";
18import * as Provider from "../Provider.ts";
19import { Resource } from "../Resource.ts";
20import { tagRecord } from "../Tags.ts";
21import {
22 alchemyMetadataKeys,
23 createInternalMetadata,
24 diffMetadata,
25 hasAlchemyMetadata,
26 stripInternalMetadata,
27 toMetadata,
28} from "./Metadata.ts";
29import type { Providers } from "./Providers.ts";
30import { isMissingStripeResource } from "./missing.ts";
31
32export type { TerminalReaderDeviceType, TerminalReaderStatus };
33
34const LIST_PAGE_SIZE = 100;
35const LIST_MAX_PAGES = 100;
36
37export interface TerminalReaderProps {
38 /**
39 * Pairing code generated by the reader (or a simulated-reader code in
40 * test mode such as `"simulated-wpe"`, `"simulated-s700"`, or
41 * `"simulated-s710"`). Create-only — used when the reader is missing;
42 * changing it on an existing reader is ignored.
43 */
44 registrationCode: string;
45 /**
46 * Custom label for easier identification. If omitted on create, Stripe
47 * uses the registration code. Mutable.
48 */
49 label?: string;
50 /**
51 * Id of the Terminal Location to assign the reader to (`tml_…`).
52 * Create-only — changing it replaces the reader. Simulated readers
53 * typically require a location.
54 */
55 location?: string;
56 /**
57 * User-defined metadata. Alchemy ownership keys (`alchemy_stack` /
58 * `alchemy_stage` / `alchemy_id`) are merged in automatically. Keys may
59 * not contain `:`. Mutable.
60 */
61 metadata?: Record<string, string>;
62}
63
64export type TerminalReader = Resource<
65 "Stripe.TerminalReader",
66 TerminalReaderProps,
67 {
68 /** Stripe Terminal reader id (`tmr_…`). */
69 id: string;
70 /** Custom label for easier identification. */
71 label: string;
72 /** Assigned Terminal Location id (`tml_…`), if any. */
73 location: string | undefined;
74 /** Device type of the reader (e.g. `simulated_wisepos_e`). */
75 deviceType: TerminalReaderDeviceType;
76 /** Serial number of the reader. */
77 serialNumber: string;
78 /** Networking status, if reported. */
79 status: TerminalReaderStatus | undefined;
80 /** Local IP address of the reader, if known. */
81 ipAddress: string | undefined;
82 /** Current software version of the reader, if known. */
83 deviceSwVersion: string | undefined;
84 /**
85 * Last time this reader reported to Stripe, in milliseconds since the
86 * Unix epoch.
87 */
88 lastSeenAt: number | undefined;
89 /** User-defined metadata (Alchemy ownership keys stripped). */
90 metadata: Record<string, string>;
91 /** Whether the reader exists in live mode. */
92 livemode: boolean;
93 },
94 never,
95 Providers
96>;
97
98/**
99 * A Stripe Terminal Reader — a physical or simulated card reader that
100 * accepts in-person payments. `registrationCode` and `location` are
101 * create-only; changing `location` replaces the reader. `label` and
102 * `metadata` update in place. Destroy deletes the reader.
103 *
104 * In test mode, register a simulated WisePOS E with
105 * `registrationCode: "simulated-wpe"` (or `"simulated-s700"` /
106 * `"simulated-s710"`). Simulated readers typically need a Terminal
107 * Location.
108 *
109 * @see https://docs.stripe.com/api/terminal/readers
110 *
111 * ### Creating a Reader
112 * **Example:** Simulated WisePOS E
113 * ```typescript
114 * const reader = yield* Stripe.TerminalReader("front-counter", {
115 * registrationCode: "simulated-wpe",
116 * label: "Front counter",
117 * location: locationId,
118 * });
119 * ```
120 *
121 * **Example:** Physical reader with metadata
122 * ```typescript
123 * const reader = yield* Stripe.TerminalReader("pos-1", {
124 * registrationCode: pairingCode,
125 * label: "POS 1",
126 * location: location.id,
127 * metadata: { station: "1" },
128 * });
129 * ```
130 *
131 * ### Updating a Reader
132 * **Example:** Relabel and retag
133 * ```typescript
134 * const reader = yield* Stripe.TerminalReader("front-counter", {
135 * registrationCode: "simulated-wpe",
136 * label: "Front counter (updated)",
137 * location: locationId,
138 * metadata: { station: "1", floor: "lobby" },
139 * });
140 * ```
141 *
142 * ### Deleting a Reader
143 * **Example:** Destroy deletes the reader
144 * ```typescript
145 * const reader = yield* Stripe.TerminalReader("front-counter", {
146 * registrationCode: "simulated-wpe",
147 * location: locationId,
148 * });
149 * // stack.destroy() deletes the reader
150 * ```
151 *
152 * @resource
153 * @product Terminal
154 */
155export const TerminalReader = Resource<TerminalReader>("Stripe.TerminalReader");
156
157export class TerminalReaderNotResolved extends Data.TaggedError(
158 "Stripe.TerminalReaderNotResolved",
159)<{
160 reader: string;
161}> {}
162
163type TerminalReaderAttributes = TerminalReader["Attributes"];
164
165const userMetadata = (
166 metadata: Record<string, string | undefined> | null | undefined,
167): Record<string, string> => stripInternalMetadata(tagRecord(metadata));
168
169const isDeletedReader = (
170 value: StripeTerminalReader | DeletedTerminalReader,
171): value is DeletedTerminalReader =>
172 "deleted" in value && value.deleted === true;
173
174const asReader = (
175 value: StripeTerminalReader | DeletedTerminalReader | undefined,
176): StripeTerminalReader | undefined => {
177 if (value === undefined || isDeletedReader(value)) return undefined;
178 return value;
179};
180
181const locationIdOf = (
182 location: TerminalReaderLocation | null | undefined,
183): string | undefined => {
184 if (location == null) return undefined;
185 if (typeof location === "string") return location;
186 return location.id;
187};
188
189const toAttrs = (reader: StripeTerminalReader): TerminalReaderAttributes => ({
190 id: reader.id,
191 label: reader.label,
192 location: locationIdOf(reader.location),
193 deviceType: reader.device_type,
194 serialNumber: reader.serial_number,
195 status: reader.status ?? undefined,
196 ipAddress: reader.ip_address ?? undefined,
197 deviceSwVersion: reader.device_sw_version ?? undefined,
198 lastSeenAt: reader.last_seen_at ?? undefined,
199 metadata: userMetadata(reader.metadata),
200 livemode: reader.livemode,
201});
202
203const isMissingReader = isMissingStripeResource;
204
205const getById = (reader: string) =>
206 GetTerminalReader({ reader }).pipe(
207 Effect.map(asReader),
208 Effect.catchIf(isMissingReader, () => Effect.succeed(undefined)),
209 );
210
211const listAllReaders = Effect.fn(function* () {
212 const readers: StripeTerminalReader[] = [];
213 let startingAfter: string | undefined;
214 for (let page = 0; page < LIST_MAX_PAGES; page++) {
215 const response = yield* GetTerminalReaders({
216 limit: LIST_PAGE_SIZE,
217 ...(startingAfter !== undefined ? { starting_after: startingAfter } : {}),
218 });
219 readers.push(...response.data);
220 if (!response.has_more || response.data.length === 0) {
221 break;
222 }
223 startingAfter = response.data[response.data.length - 1]?.id;
224 if (startingAfter === undefined) {
225 break;
226 }
227 }
228 return readers;
229});
230
231const findByAlchemyId = Effect.fn(function* (id: string) {
232 const readers = yield* listAllReaders();
233 const matches: StripeTerminalReader[] = [];
234 for (const reader of readers) {
235 if (yield* hasAlchemyMetadata(id, tagRecord(reader.metadata))) {
236 matches.push(reader);
237 }
238 }
239 matches.sort((a, b) => (b.last_seen_at ?? 0) - (a.last_seen_at ?? 0));
240 return matches[0];
241});
242
243const observe = Effect.fn(function* (input: {
244 id?: string;
245 logicalId: string;
246}) {
247 if (input.id !== undefined) {
248 const byId = yield* getById(input.id);
249 if (byId !== undefined) return byId;
250 }
251 return yield* findByAlchemyId(input.logicalId);
252});
253
254const desiredMetadata = Effect.fn(function* (
255 id: string,
256 metadata: Record<string, string> | undefined,
257) {
258 return {
259 ...toMetadata(metadata),
260 ...(yield* createInternalMetadata(id)),
261 };
262});
263
264const shouldReplace = (
265 news: TerminalReaderProps,
266 output: TerminalReaderAttributes | undefined,
267): boolean => {
268 if (output === undefined) return false;
269 if (
270 news.location !== undefined &&
271 news.location !== (output.location ?? undefined)
272 ) {
273 return true;
274 }
275 return false;
276};
277
278export const TerminalReaderProvider = () =>
279 Provider.succeed(TerminalReader, {
280 stables: ["id", "deviceType", "serialNumber", "location", "livemode"],
281
282 diff: Effect.fn(function* ({ news, output }) {
283 if (!isResolved(news)) return undefined;
284 if (shouldReplace(news, output)) {
285 return { action: "replace" } as const;
286 }
287 return undefined;
288 }),
289
290 read: Effect.fn(function* ({ id, output }) {
291 const existing = yield* observe({
292 id: output?.id,
293 logicalId: id,
294 });
295 if (existing === undefined) return undefined;
296 const attrs = toAttrs(existing);
297 return (yield* hasAlchemyMetadata(id, tagRecord(existing.metadata)))
298 ? attrs
299 : Unowned(attrs);
300 }),
301
302 list: Effect.fn(function* () {
303 const readers = yield* listAllReaders();
304 return readers
305 .filter((reader) => {
306 const metadata = tagRecord(reader.metadata);
307 return metadata[alchemyMetadataKeys.stack] !== undefined;
308 })
309 .map(toAttrs);
310 }),
311
312 reconcile: Effect.fn(function* ({ id, news, output, instanceId }) {
313 const metadata = yield* desiredMetadata(id, news.metadata);
314
315 let current: StripeTerminalReader | undefined = yield* observe({
316 id: output?.id,
317 logicalId: id,
318 });
319 if (current !== undefined && shouldReplace(news, toAttrs(current))) {
320 current = undefined;
321 }
322
323 if (current === undefined) {
324 current = yield* CreateTerminalReader({
325 registration_code: news.registrationCode,
326 metadata,
327 ...(news.label !== undefined ? { label: news.label } : {}),
328 ...(news.location !== undefined ? { location: news.location } : {}),
329 }).pipe(
330 withRequestOptions({
331 idempotencyKey: `alchemy-terminal-reader-${instanceId}`,
332 }),
333 );
334 }
335
336 if (current === undefined) {
337 return yield* new TerminalReaderNotResolved({
338 reader: output?.id ?? news.registrationCode,
339 });
340 }
341
342 const observedMetadata = tagRecord(current.metadata);
343 const { upsert, removed } = diffMetadata(observedMetadata, metadata);
344 const metadataChanged = upsert.length > 0 || removed.length > 0;
345 const labelChanged =
346 news.label !== undefined && current.label !== news.label;
347
348 if (!labelChanged && !metadataChanged) {
349 return toAttrs(current);
350 }
351
352 const updated = yield* UpdateTerminalReader({
353 reader: current.id,
354 ...(labelChanged ? { label: news.label } : {}),
355 ...(metadataChanged
356 ? {
357 metadata: {
358 ...Object.fromEntries(
359 upsert.map((tag) => [tag.Key, tag.Value]),
360 ),
361 ...Object.fromEntries(removed.map((key) => [key, ""])),
362 },
363 }
364 : {}),
365 });
366 const reader = asReader(updated);
367 if (reader === undefined) {
368 return yield* new TerminalReaderNotResolved({ reader: current.id });
369 }
370 return toAttrs(reader);
371 }),
372
373 delete: Effect.fn(function* ({ output }) {
374 yield* DeleteTerminalReader({ reader: output.id }).pipe(
375 Effect.catchIf(isMissingReader, () => Effect.void),
376 );
377 }),
378 });