1import { withRequestOptions } from "@distilled.cloud/stripe";
2import {
3 DeleteApplePayDomain,
4 GetApplePayDomains,
5 GetApplePayDomain,
6 CreateApplePayDomain,
7 type ApplePayDomain as StripeApplePayDomain,
8} from "@distilled.cloud/stripe/stripe";
9import * as Data from "effect/Data";
10import * as Effect from "effect/Effect";
11import { isResolved } from "../Diff.ts";
12import * as Provider from "../Provider.ts";
13import { Resource } from "../Resource.ts";
14import type { Providers } from "./Providers.ts";
15import { isMissingStripeResource } from "./missing.ts";
16
17const LIST_PAGE_SIZE = 100;
18const LIST_MAX_PAGES = 100;
19
20export interface ApplePayDomainProps {
21 /**
22 * Fully-qualified hostname to register with Apple Pay (e.g.
23 * `"pay.example.com"`). Changing it replaces the domain. Stripe does
24 * not support metadata on Apple Pay domains.
25 */
26 domainName: string;
27}
28
29export type ApplePayDomain = Resource<
30 "Stripe.ApplePayDomain",
31 ApplePayDomainProps,
32 {
33 /** Stripe Apple Pay domain id (`apwc_…`). */
34 id: string;
35 /** Fully-qualified hostname registered with Apple Pay. */
36 domainName: string;
37 /** Unix timestamp when the domain was registered. */
38 created: number;
39 /** Whether the domain exists in live mode. */
40 livemode: boolean;
41 },
42 never,
43 Providers
44>;
45
46/**
47 * A Stripe Apple Pay Domain — a hostname registered so Apple Pay can be
48 * offered on that origin. Existence-only: there is nothing to update in
49 * place; changing `domainName` replaces the registration. Destroy deletes
50 * it.
51 *
52 * Apple Pay domains have no metadata. Account-wide `list()` (nuke)
53 * enumerates every domain on the account.
54 *
55 * In test mode, Stripe does not require the Apple domain-association
56 * file. Live mode requires
57 * `/.well-known/apple-developer-merchantid-domain-association` on the
58 * hostname before registration succeeds.
59 *
60 * @see https://docs.stripe.com/apple-pay
61 *
62 * ### Registering a Domain
63 * **Example:** Basic domain
64 * ```typescript
65 * const pay = yield* Stripe.ApplePayDomain("checkout", {
66 * domainName: "pay.example.com",
67 * });
68 * ```
69 *
70 * ### Replacing a Domain
71 * **Example:** Point at a different hostname
72 * ```typescript
73 * const pay = yield* Stripe.ApplePayDomain("checkout", {
74 * domainName: "checkout.example.com",
75 * });
76 * ```
77 *
78 * ### Deleting a Domain
79 * **Example:** Destroy deletes the registration
80 * ```typescript
81 * // stack.destroy() / resource removal deletes the Apple Pay domain
82 * const pay = yield* Stripe.ApplePayDomain("checkout", {
83 * domainName: "pay.example.com",
84 * });
85 * ```
86 *
87 * @resource
88 * @product Payment Methods
89 */
90export const ApplePayDomain = Resource<ApplePayDomain>("Stripe.ApplePayDomain");
91
92export class ApplePayDomainNotResolved extends Data.TaggedError(
93 "Stripe.ApplePayDomainNotResolved",
94)<{
95 domainName: string;
96}> {}
97
98type ApplePayDomainAttributes = ApplePayDomain["Attributes"];
99
100const toAttrs = (domain: StripeApplePayDomain): ApplePayDomainAttributes => ({
101 id: domain.id,
102 domainName: domain.domain_name,
103 created: domain.created,
104 livemode: domain.livemode,
105});
106
107const isMissing = isMissingStripeResource;
108
109const getById = (domain: string) =>
110 GetApplePayDomain({ domain }).pipe(
111 Effect.catchIf(isMissing, () => Effect.succeed(undefined)),
112 );
113
114const listAllApplePayDomains = Effect.fn(function* () {
115 const domains: StripeApplePayDomain[] = [];
116 let startingAfter: string | undefined;
117 for (let page = 0; page < LIST_MAX_PAGES; page++) {
118 const response = yield* GetApplePayDomains({
119 limit: LIST_PAGE_SIZE,
120 ...(startingAfter !== undefined ? { starting_after: startingAfter } : {}),
121 });
122 domains.push(...response.data);
123 if (!response.has_more || response.data.length === 0) {
124 break;
125 }
126 startingAfter = response.data[response.data.length - 1]?.id;
127 if (startingAfter === undefined) {
128 break;
129 }
130 }
131 return domains;
132});
133
134const findByDomainName = Effect.fn(function* (domainName: string) {
135 const matches: StripeApplePayDomain[] = [];
136 let startingAfter: string | undefined;
137 for (let page = 0; page < LIST_MAX_PAGES; page++) {
138 const response = yield* GetApplePayDomains({
139 domain_name: domainName,
140 limit: LIST_PAGE_SIZE,
141 ...(startingAfter !== undefined ? { starting_after: startingAfter } : {}),
142 });
143 for (const domain of response.data) {
144 if (domain.domain_name === domainName) {
145 matches.push(domain);
146 }
147 }
148 if (!response.has_more || response.data.length === 0) {
149 break;
150 }
151 startingAfter = response.data[response.data.length - 1]?.id;
152 if (startingAfter === undefined) {
153 break;
154 }
155 }
156 matches.sort((a, b) => b.created - a.created);
157 return matches[0];
158});
159
160const observe = Effect.fn(function* (input: {
161 id?: string;
162 domainName?: string;
163}) {
164 if (input.id !== undefined) {
165 const byId = yield* getById(input.id);
166 if (byId !== undefined) return byId;
167 }
168 if (input.domainName !== undefined) {
169 return yield* findByDomainName(input.domainName);
170 }
171 return undefined;
172});
173
174const shouldReplace = (
175 news: ApplePayDomainProps,
176 output: ApplePayDomainAttributes | undefined,
177): boolean => {
178 if (output === undefined) return false;
179 return news.domainName !== output.domainName;
180};
181
182export const ApplePayDomainProvider = () =>
183 Provider.succeed(ApplePayDomain, {
184 stables: ["id", "domainName", "created", "livemode"],
185
186 diff: Effect.fn(function* ({ news, output }) {
187 if (!isResolved(news)) return undefined;
188 if (shouldReplace(news, output)) {
189 return { action: "replace" } as const;
190 }
191 return undefined;
192 }),
193
194 read: Effect.fn(function* ({ output, olds }) {
195 const domainName =
196 output?.domainName ??
197 (typeof olds?.domainName === "string" ? olds.domainName : undefined);
198 const existing = yield* observe({
199 id: output?.id,
200 domainName,
201 });
202 if (existing === undefined) return undefined;
203 return toAttrs(existing);
204 }),
205
206 list: Effect.fn(function* () {
207 // Apple Pay domains have no metadata. Account-wide list so nuke can
208 // tear down leaked registrations.
209 const domains = yield* listAllApplePayDomains();
210 return domains.map(toAttrs);
211 }),
212
213 reconcile: Effect.fn(function* ({ news, output, instanceId }) {
214 let current = yield* observe({
215 id: output?.id,
216 domainName: news.domainName,
217 });
218 if (current !== undefined && shouldReplace(news, toAttrs(current))) {
219 current = undefined;
220 }
221
222 if (current === undefined) {
223 current = yield* CreateApplePayDomain({
224 domain_name: news.domainName,
225 }).pipe(
226 withRequestOptions({
227 idempotencyKey: `alchemy-apple-pay-domain-${instanceId}`,
228 }),
229 Effect.catchIf(
230 (e) => e._tag === "InvalidRequestError" || e._tag === "Conflict",
231 (e) =>
232 observe({ domainName: news.domainName }).pipe(
233 Effect.flatMap((found) =>
234 found !== undefined ? Effect.succeed(found) : Effect.fail(e),
235 ),
236 ),
237 ),
238 );
239 }
240
241 if (current === undefined) {
242 return yield* new ApplePayDomainNotResolved({
243 domainName: news.domainName,
244 });
245 }
246
247 return toAttrs(current);
248 }),
249
250 delete: Effect.fn(function* ({ output }) {
251 yield* DeleteApplePayDomain({ domain: output.id }).pipe(
252 Effect.catchIf(isMissing, () => Effect.void),
253 );
254 }),
255 });