1import { withRequestOptions } from "@distilled.cloud/stripe";
2import {
3 GetPaymentMethodDomains,
4 GetPaymentMethodDomain,
5 CreatePaymentMethodDomain,
6 UpdatePaymentMethodDomain,
7 type PaymentMethodDomain as StripePaymentMethodDomain,
8 type PaymentMethodDomainResourcePaymentMethodStatus,
9} from "@distilled.cloud/stripe/stripe";
10import * as Data from "effect/Data";
11import * as Effect from "effect/Effect";
12import { isResolved } from "../Diff.ts";
13import { createPhysicalName } from "../PhysicalName.ts";
14import * as Provider from "../Provider.ts";
15import { Resource } from "../Resource.ts";
16import type { Providers } from "./Providers.ts";
17import { isMissingStripeResource } from "./missing.ts";
18
19const DOMAIN_SUFFIX = ".example.com";
20const DOMAIN_LABEL_MAX_LENGTH = 63;
21const LIST_PAGE_SIZE = 100;
22const LIST_MAX_PAGES = 100;
23
24/** The status of a payment method on a registered domain. */
25export type PaymentMethodDomainStatus = "active" | "inactive";
26
27export interface PaymentMethodDomainPaymentMethodStatus {
28 /**
29 * Whether the payment method is active on this domain.
30 */
31 status: PaymentMethodDomainStatus;
32 /**
33 * Extra details when the payment method is inactive, including the
34 * error message from Stripe.
35 */
36 statusDetails?: {
37 /** Error message associated with the inactive status. */
38 errorMessage: string;
39 };
40}
41
42export interface PaymentMethodDomainProps {
43 /**
44 * Domain name this payment method domain represents (e.g.
45 * `"checkout.example.com"`). If omitted, a unique hostname is generated
46 * from the stack, stage, and logical id under `.example.com`. Create-only
47 * — changing it replaces the domain. Stripe domain names are unique per
48 * account, including disabled domains.
49 */
50 domainName?: string;
51 /**
52 * Whether this payment method domain is enabled. If disabled, payment
53 * methods that require a registered domain do not appear in Elements or
54 * Embedded Checkout. Destroy sets this to `false`.
55 * @default true
56 */
57 enabled?: boolean;
58}
59
60export type PaymentMethodDomain = Resource<
61 "Stripe.PaymentMethodDomain",
62 PaymentMethodDomainProps,
63 {
64 /** Stripe payment method domain id (`pmd_…`). */
65 id: string;
66 /** Domain name this object represents. */
67 domainName: string;
68 /** Whether this payment method domain is enabled. */
69 enabled: boolean;
70 /** Amazon Pay eligibility on this domain. */
71 amazonPay: PaymentMethodDomainPaymentMethodStatus;
72 /** Apple Pay eligibility on this domain. */
73 applePay: PaymentMethodDomainPaymentMethodStatus;
74 /** Google Pay eligibility on this domain. */
75 googlePay: PaymentMethodDomainPaymentMethodStatus;
76 /** Klarna eligibility on this domain. */
77 klarna: PaymentMethodDomainPaymentMethodStatus;
78 /** Link eligibility on this domain. */
79 link: PaymentMethodDomainPaymentMethodStatus;
80 /** PayPal eligibility on this domain. */
81 paypal: PaymentMethodDomainPaymentMethodStatus;
82 /** Unix timestamp when the domain was created. */
83 created: number;
84 /** Whether the domain exists in live mode. */
85 livemode: boolean;
86 },
87 never,
88 Providers
89>;
90
91/**
92 * A Stripe Payment Method Domain — a web domain registered so Stripe
93 * Elements and Embedded Checkout can show domain-gated payment methods
94 * (Apple Pay, Google Pay, Link, PayPal, Klarna, Amazon Pay). `enabled`
95 * updates in place. `domainName` is create-only and changing it replaces
96 * the domain.
97 *
98 * Payment method domains have no metadata field and cannot be
99 * hard-deleted. Destroying this resource disables it (`enabled: false`).
100 * Disabled domains still occupy their `domainName`, so a later deploy
101 * with the same name re-enables the existing domain.
102 *
103 * @see https://docs.stripe.com/api/payment_method_domains
104 *
105 * ### Creating a Domain
106 * **Example:** Generated domain name
107 * ```typescript
108 * const domain = yield* Stripe.PaymentMethodDomain("checkout");
109 * ```
110 *
111 * **Example:** Named domain
112 * ```typescript
113 * const domain = yield* Stripe.PaymentMethodDomain("checkout", {
114 * domainName: "checkout.example.com",
115 * });
116 * ```
117 *
118 * ### Updating a Domain
119 * **Example:** Disable without replacing
120 * ```typescript
121 * const domain = yield* Stripe.PaymentMethodDomain("checkout", {
122 * domainName: "checkout.example.com",
123 * enabled: false,
124 * });
125 * ```
126 *
127 * ### Deactivating a Domain
128 * **Example:** Destroy disables rather than deleting
129 * ```typescript
130 * // stack.destroy() / resource removal sets enabled: false
131 * const domain = yield* Stripe.PaymentMethodDomain("checkout", {
132 * domainName: "checkout.example.com",
133 * });
134 * ```
135 *
136 * @resource
137 * @product Payment Methods
138 */
139export const PaymentMethodDomain = Resource<PaymentMethodDomain>(
140 "Stripe.PaymentMethodDomain",
141);
142
143export class PaymentMethodDomainNotResolved extends Data.TaggedError(
144 "Stripe.PaymentMethodDomainNotResolved",
145)<{
146 domainName: string;
147}> {}
148
149type PaymentMethodDomainAttributes = PaymentMethodDomain["Attributes"];
150
151const toDomainName = (
152 id: string,
153 domainName: string | undefined,
154 existing?: string,
155) =>
156 Effect.gen(function* () {
157 return (
158 domainName ??
159 existing ??
160 `${yield* createPhysicalName({
161 id,
162 maxLength: DOMAIN_LABEL_MAX_LENGTH,
163 lowercase: true,
164 })}${DOMAIN_SUFFIX}`
165 );
166 });
167
168const toPaymentMethodStatus = (
169 value: PaymentMethodDomainResourcePaymentMethodStatus,
170): PaymentMethodDomainPaymentMethodStatus => ({
171 status: value.status,
172 statusDetails:
173 value.status_details === undefined
174 ? undefined
175 : { errorMessage: value.status_details.error_message },
176});
177
178const toAttrs = (
179 domain: StripePaymentMethodDomain,
180): PaymentMethodDomainAttributes => ({
181 id: domain.id,
182 domainName: domain.domain_name,
183 enabled: domain.enabled,
184 amazonPay: toPaymentMethodStatus(domain.amazon_pay),
185 applePay: toPaymentMethodStatus(domain.apple_pay),
186 googlePay: toPaymentMethodStatus(domain.google_pay),
187 klarna: toPaymentMethodStatus(domain.klarna),
188 link: toPaymentMethodStatus(domain.link),
189 paypal: toPaymentMethodStatus(domain.paypal),
190 created: domain.created,
191 livemode: domain.livemode,
192});
193
194const isMissingDomain = isMissingStripeResource;
195
196const getById = (paymentMethodDomain: string) =>
197 GetPaymentMethodDomain({
198 payment_method_domain: paymentMethodDomain,
199 }).pipe(Effect.catchIf(isMissingDomain, () => Effect.succeed(undefined)));
200
201const listByEnabled = Effect.fn(function* (
202 enabled: boolean,
203 domainName?: string,
204) {
205 const domains: StripePaymentMethodDomain[] = [];
206 let startingAfter: string | undefined;
207 for (let page = 0; page < LIST_MAX_PAGES; page++) {
208 const response = yield* GetPaymentMethodDomains({
209 enabled,
210 limit: LIST_PAGE_SIZE,
211 ...(domainName !== undefined ? { domain_name: domainName } : {}),
212 ...(startingAfter !== undefined ? { starting_after: startingAfter } : {}),
213 });
214 domains.push(...response.data);
215 if (!response.has_more || response.data.length === 0) {
216 break;
217 }
218 startingAfter = response.data[response.data.length - 1]?.id;
219 if (startingAfter === undefined) {
220 break;
221 }
222 }
223 return domains;
224});
225
226const findByDomainName = Effect.fn(function* (domainName: string) {
227 const [enabled, disabled] = yield* Effect.all(
228 [listByEnabled(true, domainName), listByEnabled(false, domainName)],
229 { concurrency: 2 },
230 );
231 const matches = [...enabled, ...disabled].filter(
232 (domain) => domain.domain_name === domainName,
233 );
234 return matches.find((domain) => domain.enabled) ?? matches[0];
235});
236
237const observe = Effect.fn(function* (input: {
238 id?: string;
239 domainName?: string;
240}) {
241 if (input.id !== undefined) {
242 const byId = yield* getById(input.id);
243 if (byId !== undefined) return byId;
244 }
245 if (input.domainName !== undefined) {
246 return yield* findByDomainName(input.domainName);
247 }
248 return undefined;
249});
250
251const shouldReplace = (
252 news: PaymentMethodDomainProps,
253 output: PaymentMethodDomainAttributes | undefined,
254): boolean => {
255 if (output === undefined) return false;
256 if (news.domainName !== undefined && news.domainName !== output.domainName) {
257 return true;
258 }
259 return false;
260};
261
262export const PaymentMethodDomainProvider = () =>
263 Provider.succeed(PaymentMethodDomain, {
264 stables: ["id", "domainName", "created", "livemode"],
265
266 diff: Effect.fn(function* ({ news, output }) {
267 if (!isResolved(news)) return undefined;
268 if (shouldReplace(news, output)) {
269 return { action: "replace" } as const;
270 }
271 return undefined;
272 }),
273
274 read: Effect.fn(function* ({ output }) {
275 const existing = yield* observe({
276 id: output?.id,
277 domainName: output?.domainName,
278 });
279 if (existing === undefined) return undefined;
280 // Payment method domains have no metadata. Identity is the Stripe
281 // id and the unique domain_name; a match is treated as owned.
282 return toAttrs(existing);
283 }),
284
285 list: Effect.fn(function* () {
286 // No metadata on this resource. Default list is enabled domains;
287 // disabled rows stay in Stripe (domain_name remains reserved) but
288 // must not re-enter nuke.
289 const domains = yield* listByEnabled(true);
290 return domains.map(toAttrs);
291 }),
292
293 reconcile: Effect.fn(function* ({ id, news, output, instanceId }) {
294 const domainName = yield* toDomainName(
295 id,
296 news.domainName,
297 output?.domainName,
298 );
299 const desiredEnabled = news.enabled ?? true;
300
301 let current = yield* observe({
302 id: output?.id,
303 domainName,
304 });
305 if (current !== undefined && shouldReplace(news, toAttrs(current))) {
306 current = undefined;
307 }
308
309 if (current === undefined) {
310 current = yield* CreatePaymentMethodDomain({
311 domain_name: domainName,
312 enabled: desiredEnabled,
313 }).pipe(
314 withRequestOptions({
315 idempotencyKey: `alchemy-payment-method-domain-${instanceId}`,
316 }),
317 );
318 }
319
320 if (current === undefined) {
321 return yield* new PaymentMethodDomainNotResolved({ domainName });
322 }
323
324 if (current.enabled === desiredEnabled) {
325 return toAttrs(current);
326 }
327
328 const updated = yield* UpdatePaymentMethodDomain({
329 payment_method_domain: current.id,
330 enabled: desiredEnabled,
331 });
332 return toAttrs(updated);
333 }),
334
335 delete: Effect.fn(function* ({ output }) {
336 const existing = yield* getById(output.id);
337 if (existing === undefined || !existing.enabled) return;
338 yield* UpdatePaymentMethodDomain({
339 payment_method_domain: existing.id,
340 enabled: false,
341 }).pipe(Effect.catchIf(isMissingDomain, () => Effect.void));
342 }),
343 });