1import { withRequestOptions } from "@distilled.cloud/stripe";
2import {
3 DeleteCustomer,
4 GetCustomers,
5 GetCustomer,
6 CreateCustomer,
7 UpdateCustomer,
8 type Customer as StripeCustomer,
9 type DeletedCustomer,
10} from "@distilled.cloud/stripe/stripe";
11import * as Effect from "effect/Effect";
12import { Unowned } from "../AdoptPolicy.ts";
13import { isResolved } from "../Diff.ts";
14import * as Provider from "../Provider.ts";
15import { Resource } from "../Resource.ts";
16import { tagRecord } from "../Tags.ts";
17import {
18 alchemyMetadataKeys,
19 createInternalMetadata,
20 diffMetadata,
21 hasAlchemyMetadata,
22 stripInternalMetadata,
23 toMetadata,
24} from "./Metadata.ts";
25import type { Providers } from "./Providers.ts";
26import { isMissingStripeResource } from "./missing.ts";
27
28const LIST_PAGE_SIZE = 100;
29const LIST_MAX_PAGES = 100;
30
31export interface CustomerProps {
32 /**
33 * Customer's email address. Displayed in the Stripe dashboard and
34 * useful for searching. At most 512 characters.
35 */
36 email?: string;
37 /**
38 * The customer's full name or business name.
39 */
40 name?: string;
41 /**
42 * An arbitrary string attached to the customer. Displayed alongside
43 * the customer in the dashboard.
44 */
45 description?: string;
46 /**
47 * The customer's phone number.
48 */
49 phone?: string;
50 /**
51 * User-defined metadata. Alchemy ownership keys (`alchemy_stack` /
52 * `alchemy_stage` / `alchemy_id`) are merged in automatically. Keys may
53 * not contain `:`.
54 */
55 metadata?: Record<string, string>;
56}
57
58export type Customer = Resource<
59 "Stripe.Customer",
60 CustomerProps,
61 {
62 /** Stripe customer id (`cus_…`). */
63 id: string;
64 /** Customer's email address, if set. */
65 email: string | undefined;
66 /** The customer's full name or business name, if set. */
67 name: string | undefined;
68 /** Arbitrary description displayed in the dashboard, if set. */
69 description: string | undefined;
70 /** The customer's phone number, if set. */
71 phone: string | undefined;
72 /** User-defined metadata (Alchemy ownership keys stripped). */
73 metadata: Record<string, string>;
74 /** Unix timestamp when the customer was created. */
75 created: number;
76 /** Whether the customer exists in live mode. */
77 livemode: boolean;
78 },
79 never,
80 Providers
81>;
82
83/**
84 * A Stripe Customer — the billing identity invoices, subscriptions, and
85 * Checkout sessions attach to. Email, name, description, phone, and
86 * metadata are updated in place. Deleting a customer is permanent and
87 * immediately cancels any active subscriptions.
88 *
89 * @see https://docs.stripe.com/api/customers
90 *
91 * ### Creating a Customer
92 * **Example:** Email and name
93 * ```typescript
94 * const customer = yield* Stripe.Customer("alice", {
95 * email: "alice@example.com",
96 * name: "Alice Example",
97 * });
98 * ```
99 *
100 * **Example:** Description, phone, and metadata
101 * ```typescript
102 * const customer = yield* Stripe.Customer("alice", {
103 * email: "alice@example.com",
104 * name: "Alice Example",
105 * description: "Beta tester",
106 * phone: "+15555550100",
107 * metadata: { plan: "pro" },
108 * });
109 * ```
110 *
111 * ### Updating a Customer
112 * **Example:** Change email and metadata
113 * ```typescript
114 * const customer = yield* Stripe.Customer("alice", {
115 * email: "alice+updated@example.com",
116 * name: "Alice Example",
117 * metadata: { plan: "enterprise" },
118 * });
119 * ```
120 *
121 * @resource
122 * @product Customer
123 */
124export const Customer = Resource<Customer>("Stripe.Customer");
125
126const userMetadata = (
127 metadata: Record<string, string | undefined> | null | undefined,
128): Record<string, string> => stripInternalMetadata(tagRecord(metadata));
129
130const isDeletedCustomer = (
131 value: StripeCustomer | DeletedCustomer,
132): value is DeletedCustomer => "deleted" in value && value.deleted === true;
133
134const asCustomer = (
135 value: StripeCustomer | DeletedCustomer | undefined,
136): StripeCustomer | undefined => {
137 if (value === undefined || isDeletedCustomer(value)) return undefined;
138 return value;
139};
140
141const toAttrs = (customer: StripeCustomer) => ({
142 id: customer.id,
143 email: customer.email ?? undefined,
144 name: customer.name ?? undefined,
145 description: customer.description ?? undefined,
146 phone: customer.phone ?? undefined,
147 metadata: userMetadata(customer.metadata),
148 created: customer.created,
149 livemode: customer.livemode,
150});
151
152const isMissingCustomer = isMissingStripeResource;
153
154const getById = (customer: string) =>
155 GetCustomer({ customer }).pipe(
156 Effect.map(asCustomer),
157 Effect.catchIf(isMissingCustomer, () => Effect.succeed(undefined)),
158 );
159
160const listAllCustomers = Effect.fn(function* () {
161 const customers: StripeCustomer[] = [];
162 let startingAfter: string | undefined;
163 for (let page = 0; page < LIST_MAX_PAGES; page++) {
164 const response = yield* GetCustomers({
165 limit: LIST_PAGE_SIZE,
166 ...(startingAfter !== undefined ? { starting_after: startingAfter } : {}),
167 });
168 customers.push(...response.data);
169 if (!response.has_more || response.data.length === 0) {
170 break;
171 }
172 startingAfter = response.data[response.data.length - 1]?.id;
173 if (startingAfter === undefined) {
174 break;
175 }
176 }
177 return customers;
178});
179
180const findByAlchemyId = Effect.fn(function* (id: string) {
181 const customers = yield* listAllCustomers();
182 const matches: StripeCustomer[] = [];
183 for (const customer of customers) {
184 if (yield* hasAlchemyMetadata(id, tagRecord(customer.metadata))) {
185 matches.push(customer);
186 }
187 }
188 matches.sort((a, b) => b.created - a.created);
189 return matches[0];
190});
191
192const observe = Effect.fn(function* (input: {
193 id?: string;
194 logicalId: string;
195}) {
196 if (input.id !== undefined) {
197 const byId = yield* getById(input.id);
198 if (byId !== undefined) return byId;
199 }
200 return yield* findByAlchemyId(input.logicalId);
201});
202
203const desiredMetadata = Effect.fn(function* (
204 id: string,
205 metadata: Record<string, string> | undefined,
206) {
207 return {
208 ...toMetadata(metadata),
209 ...(yield* createInternalMetadata(id)),
210 };
211});
212
213export const CustomerProvider = () =>
214 Provider.succeed(Customer, {
215 stables: ["id", "created", "livemode"],
216
217 diff: Effect.fn(function* ({ news }) {
218 if (!isResolved(news)) return undefined;
219 return undefined;
220 }),
221
222 read: Effect.fn(function* ({ id, output }) {
223 const existing = yield* observe({
224 id: output?.id,
225 logicalId: id,
226 });
227 if (existing === undefined) return undefined;
228 const attrs = toAttrs(existing);
229 return (yield* hasAlchemyMetadata(id, tagRecord(existing.metadata)))
230 ? attrs
231 : Unowned(attrs);
232 }),
233
234 list: Effect.fn(function* () {
235 const customers = yield* listAllCustomers();
236 return customers
237 .filter((customer) => {
238 const metadata = tagRecord(customer.metadata);
239 return metadata[alchemyMetadataKeys.stack] !== undefined;
240 })
241 .map(toAttrs);
242 }),
243
244 reconcile: Effect.fn(function* ({ id, news, output, instanceId }) {
245 const metadata = yield* desiredMetadata(id, news.metadata);
246 const desiredEmail = news.email ?? "";
247 const desiredName = news.name ?? "";
248 const desiredDescription = news.description ?? "";
249 const desiredPhone = news.phone ?? "";
250
251 let current = yield* observe({
252 id: output?.id,
253 logicalId: id,
254 });
255
256 if (current === undefined) {
257 current = yield* CreateCustomer({
258 ...(desiredEmail.length > 0 ? { email: desiredEmail } : {}),
259 ...(desiredName.length > 0 ? { name: desiredName } : {}),
260 ...(desiredDescription.length > 0
261 ? { description: desiredDescription }
262 : {}),
263 ...(desiredPhone.length > 0 ? { phone: desiredPhone } : {}),
264 metadata,
265 }).pipe(
266 withRequestOptions({
267 idempotencyKey: `alchemy-customer-${instanceId}`,
268 }),
269 );
270 }
271
272 const observedMetadata = tagRecord(current.metadata);
273 const { upsert, removed } = diffMetadata(observedMetadata, metadata);
274 const metadataChanged = upsert.length > 0 || removed.length > 0;
275 const emailChanged = (current.email ?? "") !== desiredEmail;
276 const nameChanged = (current.name ?? "") !== desiredName;
277 const descriptionChanged =
278 (current.description ?? "") !== desiredDescription;
279 const phoneChanged = (current.phone ?? "") !== desiredPhone;
280
281 if (
282 !emailChanged &&
283 !nameChanged &&
284 !descriptionChanged &&
285 !phoneChanged &&
286 !metadataChanged
287 ) {
288 return toAttrs(current);
289 }
290
291 const updated = yield* UpdateCustomer({
292 customer: current.id,
293 ...(emailChanged ? { email: desiredEmail } : {}),
294 ...(nameChanged ? { name: desiredName } : {}),
295 ...(descriptionChanged ? { description: desiredDescription } : {}),
296 ...(phoneChanged ? { phone: desiredPhone } : {}),
297 ...(metadataChanged
298 ? {
299 metadata: {
300 ...Object.fromEntries(
301 upsert.map((tag) => [tag.Key, tag.Value]),
302 ),
303 ...Object.fromEntries(removed.map((key) => [key, ""])),
304 },
305 }
306 : {}),
307 });
308 return toAttrs(updated);
309 }),
310
311 delete: Effect.fn(function* ({ output }) {
312 yield* DeleteCustomer({ customer: output.id }).pipe(
313 Effect.catchIf(isMissingCustomer, () => Effect.void),
314 );
315 }),
316 });