1import { withRequestOptions } from "@distilled.cloud/stripe";
2import {
3 DeleteTerminalLocation,
4 GetTerminalLocations,
5 GetTerminalLocation,
6 CreateTerminalLocation,
7 UpdateTerminalLocation,
8 type Address,
9 type DeletedTerminalLocation,
10 type LegalEntityJapanAddress,
11 type CreateAccountRequestCompanyAddressKana,
12 type CreateTerminalLocationRequestAddress,
13 type TerminalLocation as StripeTerminalLocation,
14} from "@distilled.cloud/stripe/stripe";
15import * as Data from "effect/Data";
16import * as Effect from "effect/Effect";
17import { Unowned } from "../AdoptPolicy.ts";
18import { deepEqual, isResolved } from "../Diff.ts";
19import { createPhysicalName } from "../PhysicalName.ts";
20import * as Provider from "../Provider.ts";
21import { Resource } from "../Resource.ts";
22import { tagRecord } from "../Tags.ts";
23import {
24 alchemyMetadataKeys,
25 createInternalMetadata,
26 diffMetadata,
27 hasAlchemyMetadata,
28 stripInternalMetadata,
29 toMetadata,
30} from "./Metadata.ts";
31import type { Providers } from "./Providers.ts";
32import { isMissingStripeResource } from "./missing.ts";
33
34const NAME_MAX_LENGTH = 1000;
35const LIST_PAGE_SIZE = 100;
36const LIST_MAX_PAGES = 100;
37
38export interface TerminalLocationAddress {
39 /**
40 * City, district, suburb, town, or village. Required in most countries.
41 */
42 city?: string;
43 /**
44 * Two-letter ISO 3166-1 alpha-2 country code. Create-only — changing
45 * it replaces the location.
46 */
47 country: string;
48 /**
49 * Address line 1 (street, PO Box, or company name).
50 */
51 line1?: string;
52 /**
53 * Address line 2 (apartment, suite, unit, or building).
54 */
55 line2?: string;
56 /**
57 * ZIP or postal code. Required in most countries.
58 */
59 postalCode?: string;
60 /**
61 * State, county, province, or region (ISO 3166-2). Required in the US
62 * and Canada.
63 */
64 state?: string;
65}
66
67export interface TerminalLocationJapanAddress {
68 /**
69 * City or ward.
70 */
71 city?: string;
72 /**
73 * Two-letter ISO 3166-1 alpha-2 country code.
74 */
75 country?: string;
76 /**
77 * Block or building number.
78 */
79 line1?: string;
80 /**
81 * Building details.
82 */
83 line2?: string;
84 /**
85 * Postal code.
86 */
87 postalCode?: string;
88 /**
89 * Prefecture.
90 */
91 state?: string;
92 /**
93 * Town or cho-me.
94 */
95 town?: string;
96}
97
98export interface TerminalLocationProps {
99 /**
100 * Display name of the location. If omitted, a unique name is generated
101 * from the stack, stage, and logical id. Maximum length is 1000
102 * characters. Mutable.
103 */
104 displayName?: string;
105 /**
106 * Full address of the location. `country` is required and create-only —
107 * changing it replaces the location. Other fields update in place;
108 * required fields vary by country.
109 */
110 address: TerminalLocationAddress;
111 /**
112 * Kana variation of the full address (Japan only). Mutable.
113 */
114 addressKana?: TerminalLocationJapanAddress;
115 /**
116 * Kanji variation of the full address (Japan only). Mutable.
117 */
118 addressKanji?: TerminalLocationJapanAddress;
119 /**
120 * Id of a Terminal Configuration applied to every reader at this
121 * location. Empty string unsets. Mutable.
122 */
123 configurationOverrides?: string;
124 /**
125 * Kana variation of the display name (Japan only). Mutable.
126 */
127 displayNameKana?: string;
128 /**
129 * Kanji variation of the display name (Japan only). Mutable.
130 */
131 displayNameKanji?: string;
132 /**
133 * Phone number for the location. Empty string unsets. Mutable.
134 */
135 phone?: string;
136 /**
137 * User-defined metadata. Alchemy ownership keys (`alchemy_stack` /
138 * `alchemy_stage` / `alchemy_id`) are merged in automatically. Keys may
139 * not contain `:`. Mutable.
140 */
141 metadata?: Record<string, string>;
142}
143
144export type TerminalLocation = Resource<
145 "Stripe.TerminalLocation",
146 TerminalLocationProps,
147 {
148 /** Stripe Terminal Location id (`tml_…`). */
149 id: string;
150 /** Display name of the location. */
151 displayName: string;
152 /** Full address of the location. */
153 address: TerminalLocationAddress;
154 /** Kana variation of the full address, if set. */
155 addressKana: TerminalLocationJapanAddress | undefined;
156 /** Kanji variation of the full address, if set. */
157 addressKanji: TerminalLocationJapanAddress | undefined;
158 /** Terminal Configuration id applied to readers at this location. */
159 configurationOverrides: string | undefined;
160 /** Kana variation of the display name, if set. */
161 displayNameKana: string | undefined;
162 /** Kanji variation of the display name, if set. */
163 displayNameKanji: string | undefined;
164 /** Phone number for the location, if set. */
165 phone: string | undefined;
166 /** User-defined metadata (Alchemy ownership keys stripped). */
167 metadata: Record<string, string>;
168 /** Whether the location exists in live mode. */
169 livemode: boolean;
170 },
171 never,
172 Providers
173>;
174
175/**
176 * A Stripe Terminal Location — a grouping of Terminal readers at a
177 * physical site. Display name, phone, metadata, configuration overrides,
178 * and address fields other than `country` update in place. Changing
179 * `address.country` replaces the location; re-register readers on the
180 * new one.
181 *
182 * Destroy hard-deletes the location.
183 *
184 * @see https://docs.stripe.com/api/terminal/locations
185 *
186 * ### Creating a Location
187 * **Example:** US storefront
188 * ```typescript
189 * const store = yield* Stripe.TerminalLocation("storefront", {
190 * displayName: "Market Street",
191 * address: {
192 * line1: "123 Market Street",
193 * city: "San Francisco",
194 * state: "CA",
195 * postalCode: "94105",
196 * country: "US",
197 * },
198 * });
199 * ```
200 *
201 * **Example:** Generated name with phone and metadata
202 * ```typescript
203 * const kiosk = yield* Stripe.TerminalLocation("kiosk", {
204 * address: {
205 * line1: "1 Infinite Loop",
206 * city: "Cupertino",
207 * state: "CA",
208 * postalCode: "95014",
209 * country: "US",
210 * },
211 * phone: "+14085551234",
212 * metadata: { region: "west" },
213 * });
214 * ```
215 *
216 * ### Updating a Location
217 * **Example:** Rename and retag
218 * ```typescript
219 * const store = yield* Stripe.TerminalLocation("storefront", {
220 * displayName: "Market Street Annex",
221 * address: {
222 * line1: "125 Market Street",
223 * city: "San Francisco",
224 * state: "CA",
225 * postalCode: "94105",
226 * country: "US",
227 * },
228 * metadata: { region: "west" },
229 * });
230 * ```
231 *
232 * ### Deleting a Location
233 * **Example:** Destroy deletes the location
234 * ```typescript
235 * // stack.destroy() / resource removal calls DELETE
236 * const store = yield* Stripe.TerminalLocation("storefront", {
237 * address: {
238 * line1: "123 Market Street",
239 * city: "San Francisco",
240 * state: "CA",
241 * postalCode: "94105",
242 * country: "US",
243 * },
244 * });
245 * ```
246 *
247 * @resource
248 * @product Terminal
249 */
250export const TerminalLocation = Resource<TerminalLocation>(
251 "Stripe.TerminalLocation",
252);
253
254export class TerminalLocationNotResolved extends Data.TaggedError(
255 "Stripe.TerminalLocationNotResolved",
256)<{
257 displayName: string;
258}> {}
259
260type TerminalLocationAttributes = TerminalLocation["Attributes"];
261
262const userMetadata = (
263 metadata: Record<string, string | undefined> | null | undefined,
264): Record<string, string> => stripInternalMetadata(tagRecord(metadata));
265
266const optionalString = (
267 value: string | null | undefined,
268): string | undefined => (value == null || value === "" ? undefined : value);
269
270const fromObservedAddress = (address: Address): TerminalLocationAddress => ({
271 country: address.country ?? "",
272 ...(optionalString(address.city) !== undefined
273 ? { city: optionalString(address.city) }
274 : {}),
275 ...(optionalString(address.line1) !== undefined
276 ? { line1: optionalString(address.line1) }
277 : {}),
278 ...(optionalString(address.line2) !== undefined
279 ? { line2: optionalString(address.line2) }
280 : {}),
281 ...(optionalString(address.postal_code) !== undefined
282 ? { postalCode: optionalString(address.postal_code) }
283 : {}),
284 ...(optionalString(address.state) !== undefined
285 ? { state: optionalString(address.state) }
286 : {}),
287});
288
289const fromObservedJapanAddress = (
290 address: LegalEntityJapanAddress | undefined,
291): TerminalLocationJapanAddress | undefined => {
292 if (address === undefined) return undefined;
293 const mapped: TerminalLocationJapanAddress = {
294 ...(optionalString(address.city) !== undefined
295 ? { city: optionalString(address.city) }
296 : {}),
297 ...(optionalString(address.country) !== undefined
298 ? { country: optionalString(address.country) }
299 : {}),
300 ...(optionalString(address.line1) !== undefined
301 ? { line1: optionalString(address.line1) }
302 : {}),
303 ...(optionalString(address.line2) !== undefined
304 ? { line2: optionalString(address.line2) }
305 : {}),
306 ...(optionalString(address.postal_code) !== undefined
307 ? { postalCode: optionalString(address.postal_code) }
308 : {}),
309 ...(optionalString(address.state) !== undefined
310 ? { state: optionalString(address.state) }
311 : {}),
312 ...(optionalString(address.town) !== undefined
313 ? { town: optionalString(address.town) }
314 : {}),
315 };
316 return Object.keys(mapped).length > 0 ? mapped : undefined;
317};
318
319const toWireAddress = (
320 address: TerminalLocationAddress,
321): CreateTerminalLocationRequestAddress => ({
322 country: address.country,
323 ...(address.city !== undefined ? { city: address.city } : {}),
324 ...(address.line1 !== undefined ? { line1: address.line1 } : {}),
325 ...(address.line2 !== undefined ? { line2: address.line2 } : {}),
326 ...(address.postalCode !== undefined
327 ? { postal_code: address.postalCode }
328 : {}),
329 ...(address.state !== undefined ? { state: address.state } : {}),
330});
331
332const toWireJapanAddress = (
333 address: TerminalLocationJapanAddress,
334): CreateAccountRequestCompanyAddressKana => ({
335 ...(address.city !== undefined ? { city: address.city } : {}),
336 ...(address.country !== undefined ? { country: address.country } : {}),
337 ...(address.line1 !== undefined ? { line1: address.line1 } : {}),
338 ...(address.line2 !== undefined ? { line2: address.line2 } : {}),
339 ...(address.postalCode !== undefined
340 ? { postal_code: address.postalCode }
341 : {}),
342 ...(address.state !== undefined ? { state: address.state } : {}),
343 ...(address.town !== undefined ? { town: address.town } : {}),
344});
345
346const isDeletedLocation = (
347 value: StripeTerminalLocation | DeletedTerminalLocation,
348): value is DeletedTerminalLocation =>
349 "deleted" in value && value.deleted === true;
350
351const asLocation = (
352 value: StripeTerminalLocation | DeletedTerminalLocation | undefined,
353): StripeTerminalLocation | undefined => {
354 if (value === undefined || isDeletedLocation(value)) return undefined;
355 return value;
356};
357
358const toAttrs = (
359 location: StripeTerminalLocation,
360): TerminalLocationAttributes => ({
361 id: location.id,
362 displayName: location.display_name,
363 address: fromObservedAddress(location.address),
364 addressKana: fromObservedJapanAddress(location.address_kana),
365 addressKanji: fromObservedJapanAddress(location.address_kanji),
366 configurationOverrides: optionalString(location.configuration_overrides),
367 displayNameKana: optionalString(location.display_name_kana),
368 displayNameKanji: optionalString(location.display_name_kanji),
369 phone: optionalString(location.phone),
370 metadata: userMetadata(location.metadata),
371 livemode: location.livemode,
372});
373
374const toDisplayName = (
375 id: string,
376 displayName: string | undefined,
377 existing?: string,
378) =>
379 Effect.gen(function* () {
380 return (
381 displayName ??
382 existing ??
383 (yield* createPhysicalName({ id, maxLength: NAME_MAX_LENGTH }))
384 );
385 });
386
387const isMissingLocation = isMissingStripeResource;
388
389const getById = (location: string) =>
390 GetTerminalLocation({ location }).pipe(
391 Effect.map(asLocation),
392 Effect.catchIf(isMissingLocation, () => Effect.succeed(undefined)),
393 );
394
395const listAllLocations = Effect.fn(function* () {
396 const locations: StripeTerminalLocation[] = [];
397 let startingAfter: string | undefined;
398 for (let page = 0; page < LIST_MAX_PAGES; page++) {
399 const response = yield* GetTerminalLocations({
400 limit: LIST_PAGE_SIZE,
401 ...(startingAfter !== undefined ? { starting_after: startingAfter } : {}),
402 });
403 locations.push(...response.data);
404 if (!response.has_more || response.data.length === 0) {
405 break;
406 }
407 startingAfter = response.data[response.data.length - 1]?.id;
408 if (startingAfter === undefined) {
409 break;
410 }
411 }
412 return locations;
413});
414
415const findByAlchemyId = Effect.fn(function* (id: string) {
416 const locations = yield* listAllLocations();
417 const matches: StripeTerminalLocation[] = [];
418 for (const location of locations) {
419 if (yield* hasAlchemyMetadata(id, tagRecord(location.metadata))) {
420 matches.push(location);
421 }
422 }
423 return matches[0];
424});
425
426const observe = Effect.fn(function* (input: {
427 id?: string;
428 logicalId: string;
429}) {
430 if (input.id !== undefined) {
431 const byId = yield* getById(input.id);
432 if (byId !== undefined) return byId;
433 }
434 return yield* findByAlchemyId(input.logicalId);
435});
436
437const desiredMetadata = Effect.fn(function* (
438 id: string,
439 metadata: Record<string, string> | undefined,
440) {
441 return {
442 ...toMetadata(metadata),
443 ...(yield* createInternalMetadata(id)),
444 };
445});
446
447const shouldReplace = (
448 news: TerminalLocationProps,
449 output: TerminalLocationAttributes | undefined,
450): boolean => {
451 if (output === undefined) return false;
452 return news.address.country !== output.address.country;
453};
454
455export const TerminalLocationProvider = () =>
456 Provider.succeed(TerminalLocation, {
457 stables: ["id", "livemode"],
458
459 diff: Effect.fn(function* ({ news, output }) {
460 if (!isResolved(news)) return undefined;
461 if (shouldReplace(news, output)) {
462 return { action: "replace" } as const;
463 }
464 return undefined;
465 }),
466
467 read: Effect.fn(function* ({ id, output }) {
468 const existing = yield* observe({
469 id: output?.id,
470 logicalId: id,
471 });
472 if (existing === undefined) return undefined;
473 const attrs = toAttrs(existing);
474 return (yield* hasAlchemyMetadata(id, tagRecord(existing.metadata)))
475 ? attrs
476 : Unowned(attrs);
477 }),
478
479 list: Effect.fn(function* () {
480 const locations = yield* listAllLocations();
481 return locations
482 .filter((location) => {
483 const metadata = tagRecord(location.metadata);
484 return metadata[alchemyMetadataKeys.stack] !== undefined;
485 })
486 .map(toAttrs);
487 }),
488
489 reconcile: Effect.fn(function* ({ id, news, output, instanceId }) {
490 const displayName = yield* toDisplayName(
491 id,
492 news.displayName,
493 output?.displayName,
494 );
495 const metadata = yield* desiredMetadata(id, news.metadata);
496 const desiredPhone = news.phone ?? "";
497 const desiredConfig = news.configurationOverrides ?? "";
498 const desiredDisplayNameKana = news.displayNameKana ?? "";
499 const desiredDisplayNameKanji = news.displayNameKanji ?? "";
500 const address = toWireAddress(news.address);
501
502 let current: StripeTerminalLocation | undefined = yield* observe({
503 id: output?.id,
504 logicalId: id,
505 });
506 if (current !== undefined && shouldReplace(news, toAttrs(current))) {
507 current = undefined;
508 }
509
510 if (current === undefined) {
511 current = yield* CreateTerminalLocation({
512 display_name: displayName,
513 address,
514 metadata,
515 ...(news.addressKana !== undefined
516 ? { address_kana: toWireJapanAddress(news.addressKana) }
517 : {}),
518 ...(news.addressKanji !== undefined
519 ? { address_kanji: toWireJapanAddress(news.addressKanji) }
520 : {}),
521 ...(desiredConfig.length > 0
522 ? { configuration_overrides: desiredConfig }
523 : {}),
524 ...(desiredDisplayNameKana.length > 0
525 ? { display_name_kana: desiredDisplayNameKana }
526 : {}),
527 ...(desiredDisplayNameKanji.length > 0
528 ? { display_name_kanji: desiredDisplayNameKanji }
529 : {}),
530 ...(desiredPhone.length > 0 ? { phone: desiredPhone } : {}),
531 }).pipe(
532 withRequestOptions({
533 idempotencyKey: `alchemy-terminal-location-${instanceId}`,
534 }),
535 );
536 }
537
538 if (current === undefined) {
539 return yield* new TerminalLocationNotResolved({ displayName });
540 }
541
542 const observedMetadata = tagRecord(current.metadata);
543 const { upsert, removed } = diffMetadata(observedMetadata, metadata);
544 const metadataChanged = upsert.length > 0 || removed.length > 0;
545 const displayNameChanged = current.display_name !== displayName;
546 const addressChanged = !deepEqual(
547 fromObservedAddress(current.address),
548 news.address,
549 { stripNullish: true },
550 );
551 const addressKanaChanged =
552 news.addressKana !== undefined &&
553 !deepEqual(
554 fromObservedJapanAddress(current.address_kana),
555 news.addressKana,
556 { stripNullish: true },
557 );
558 const addressKanjiChanged =
559 news.addressKanji !== undefined &&
560 !deepEqual(
561 fromObservedJapanAddress(current.address_kanji),
562 news.addressKanji,
563 { stripNullish: true },
564 );
565 const phoneChanged = (current.phone ?? "") !== desiredPhone;
566 const configChanged =
567 (current.configuration_overrides ?? "") !== desiredConfig;
568 const displayNameKanaChanged =
569 (current.display_name_kana ?? "") !== desiredDisplayNameKana;
570 const displayNameKanjiChanged =
571 (current.display_name_kanji ?? "") !== desiredDisplayNameKanji;
572
573 if (
574 !displayNameChanged &&
575 !addressChanged &&
576 !addressKanaChanged &&
577 !addressKanjiChanged &&
578 !phoneChanged &&
579 !configChanged &&
580 !displayNameKanaChanged &&
581 !displayNameKanjiChanged &&
582 !metadataChanged
583 ) {
584 return toAttrs(current);
585 }
586
587 const updated = yield* UpdateTerminalLocation({
588 location: current.id,
589 ...(displayNameChanged ? { display_name: displayName } : {}),
590 ...(addressChanged ? { address } : {}),
591 ...(addressKanaChanged && news.addressKana !== undefined
592 ? { address_kana: toWireJapanAddress(news.addressKana) }
593 : {}),
594 ...(addressKanjiChanged && news.addressKanji !== undefined
595 ? { address_kanji: toWireJapanAddress(news.addressKanji) }
596 : {}),
597 ...(configChanged ? { configuration_overrides: desiredConfig } : {}),
598 ...(displayNameKanaChanged
599 ? { display_name_kana: desiredDisplayNameKana }
600 : {}),
601 ...(displayNameKanjiChanged
602 ? { display_name_kanji: desiredDisplayNameKanji }
603 : {}),
604 ...(phoneChanged ? { phone: desiredPhone } : {}),
605 ...(metadataChanged
606 ? {
607 metadata: {
608 ...Object.fromEntries(
609 upsert.map((tag) => [tag.Key, tag.Value]),
610 ),
611 ...Object.fromEntries(removed.map((key) => [key, ""])),
612 },
613 }
614 : {}),
615 });
616 const location = asLocation(updated);
617 if (location === undefined) {
618 return yield* new TerminalLocationNotResolved({ displayName });
619 }
620 return toAttrs(location);
621 }),
622
623 delete: Effect.fn(function* ({ output }) {
624 yield* DeleteTerminalLocation({ location: output.id }).pipe(
625 Effect.catchIf(isMissingLocation, () => Effect.void),
626 );
627 }),
628 });