1import { withRequestOptions } from "@distilled.cloud/stripe";
2import {
3 GetFileLinks,
4 GetFileLink,
5 CreateFileLink,
6 UpdateFileLink,
7 type FileLink as StripeFileLink,
8} from "@distilled.cloud/stripe/stripe";
9import * as Effect from "effect/Effect";
10import { Unowned } from "../AdoptPolicy.ts";
11import { isResolved } from "../Diff.ts";
12import * as Provider from "../Provider.ts";
13import { Resource } from "../Resource.ts";
14import { tagRecord } from "../Tags.ts";
15import {
16 alchemyMetadataKeys,
17 createInternalMetadata,
18 diffMetadata,
19 hasAlchemyMetadata,
20 stripInternalMetadata,
21 toMetadata,
22} from "./Metadata.ts";
23import type { Providers } from "./Providers.ts";
24import { isMissingStripeResource } from "./missing.ts";
25
26const LIST_PAGE_SIZE = 100;
27const LIST_MAX_PAGES = 100;
28
29export interface FileLinkProps {
30 /**
31 * Id of the Stripe File this link points at (`file_…`). The file's
32 * `purpose` must allow links (for example `dispute_evidence` or
33 * `tax_document_user_upload`). Create-only — changing it replaces
34 * the file link.
35 */
36 file: string;
37 /**
38 * Unix timestamp after which the link is unusable. Omit for a link
39 * that never expires. Mutable — pass `undefined` to clear a previously
40 * set expiry. Expired links cannot be updated.
41 */
42 expiresAt?: number;
43 /**
44 * User-defined metadata. Alchemy ownership keys (`alchemy_stack` /
45 * `alchemy_stage` / `alchemy_id`) are merged in automatically. Keys may
46 * not contain `:`. Mutable.
47 */
48 metadata?: Record<string, string>;
49}
50
51export type FileLink = Resource<
52 "Stripe.FileLink",
53 FileLinkProps,
54 {
55 /** Stripe file link id (`link_…`). */
56 id: string;
57 /** Id of the File this link points at (`file_…`). */
58 file: string;
59 /** Public URL to download the file, if the link is still valid. */
60 url: string | undefined;
61 /** Whether the link has already expired. */
62 expired: boolean;
63 /** Unix timestamp after which the link is unusable, if set. */
64 expiresAt: number | undefined;
65 /** User-defined metadata (Alchemy ownership keys stripped). */
66 metadata: Record<string, string>;
67 /** Unix timestamp when the file link was created. */
68 created: number;
69 /** Whether the file link exists in live mode. */
70 livemode: boolean;
71 },
72 never,
73 Providers
74>;
75
76/**
77 * A Stripe File Link — a public URL for a File that does not require
78 * Stripe authentication. `file` is create-only; changing it replaces the
79 * link. `expiresAt` and `metadata` update in place. Expired links cannot
80 * be updated. Destroy expires the link (`expires_at: "now"`) rather than
81 * deleting it — Stripe has no file-link delete API.
82 *
83 * @see https://docs.stripe.com/api/file_links
84 *
85 * ### Creating a File Link
86 * **Example:** Link a file
87 * ```typescript
88 * const link = yield* Stripe.FileLink("report", {
89 * file: "file_123",
90 * });
91 * ```
92 *
93 * **Example:** Expiring link with metadata
94 * ```typescript
95 * const link = yield* Stripe.FileLink("report", {
96 * file: "file_123",
97 * expiresAt: 1900000000,
98 * metadata: { kind: "report" },
99 * });
100 * ```
101 *
102 * ### Updating a File Link
103 * **Example:** Extend expiry and retag
104 * ```typescript
105 * const link = yield* Stripe.FileLink("report", {
106 * file: "file_123",
107 * expiresAt: 1920000000,
108 * metadata: { kind: "report", env: "prod" },
109 * });
110 * ```
111 *
112 * ### Expiring a File Link
113 * **Example:** Destroy expires rather than deleting
114 * ```typescript
115 * const link = yield* Stripe.FileLink("report", {
116 * file: "file_123",
117 * });
118 * // stack.destroy() sets expires_at to now
119 * ```
120 *
121 * @resource
122 * @product Files
123 */
124export const FileLink = Resource<FileLink>("Stripe.FileLink");
125
126const userMetadata = (
127 metadata: Record<string, string | undefined> | null | undefined,
128): Record<string, string> => stripInternalMetadata(tagRecord(metadata));
129
130const fileIdOf = (file: StripeFileLink["file"]): string => {
131 if (typeof file === "string") return file;
132 return file.id;
133};
134
135const toAttrs = (link: StripeFileLink) => ({
136 id: link.id,
137 file: fileIdOf(link.file),
138 url: link.url ?? undefined,
139 expired: link.expired,
140 expiresAt: link.expires_at ?? undefined,
141 metadata: userMetadata(link.metadata),
142 created: link.created,
143 livemode: link.livemode,
144});
145
146const isMissingFileLink = isMissingStripeResource;
147
148const getById = (link: string) =>
149 GetFileLink({ link }).pipe(
150 Effect.catchIf(isMissingFileLink, () => Effect.succeed(undefined)),
151 );
152
153const paginateFileLinks = Effect.fn(function* (query: {
154 expired?: boolean;
155 file?: string;
156}) {
157 const links: StripeFileLink[] = [];
158 let startingAfter: string | undefined;
159 for (let page = 0; page < LIST_MAX_PAGES; page++) {
160 const response = yield* GetFileLinks({
161 ...(query.expired !== undefined ? { expired: query.expired } : {}),
162 ...(query.file !== undefined ? { file: query.file } : {}),
163 limit: LIST_PAGE_SIZE,
164 ...(startingAfter !== undefined ? { starting_after: startingAfter } : {}),
165 });
166 links.push(...response.data);
167 if (!response.has_more || response.data.length === 0) {
168 break;
169 }
170 startingAfter = response.data[response.data.length - 1]?.id;
171 if (startingAfter === undefined) {
172 break;
173 }
174 }
175 return links;
176});
177
178const listUnexpired = () => paginateFileLinks({ expired: false });
179
180const findByAlchemyId = Effect.fn(function* (
181 id: string,
182 links: ReadonlyArray<StripeFileLink>,
183) {
184 const matches: StripeFileLink[] = [];
185 for (const link of links) {
186 if (link.expired) continue;
187 if (yield* hasAlchemyMetadata(id, tagRecord(link.metadata))) {
188 matches.push(link);
189 }
190 }
191 matches.sort((a, b) => b.created - a.created);
192 return matches[0];
193});
194
195const observe = Effect.fn(function* (input: {
196 id?: string;
197 logicalId: string;
198 file?: string;
199}) {
200 if (input.id !== undefined) {
201 const byId = yield* getById(input.id);
202 if (byId !== undefined && !byId.expired) return byId;
203 }
204 if (input.file !== undefined) {
205 const byFile = yield* findByAlchemyId(
206 input.logicalId,
207 yield* paginateFileLinks({ expired: false, file: input.file }),
208 );
209 if (byFile !== undefined) return byFile;
210 }
211 return yield* findByAlchemyId(input.logicalId, yield* listUnexpired());
212});
213
214const desiredMetadata = Effect.fn(function* (
215 id: string,
216 metadata: Record<string, string> | undefined,
217) {
218 return {
219 ...toMetadata(metadata),
220 ...(yield* createInternalMetadata(id)),
221 };
222});
223
224export const FileLinkProvider = () =>
225 Provider.succeed(FileLink, {
226 stables: ["id", "file", "created", "livemode"],
227
228 diff: Effect.fn(function* ({ news, output }) {
229 if (!isResolved(news)) return undefined;
230 if (output !== undefined && news.file !== output.file) {
231 return { action: "replace" as const };
232 }
233 return undefined;
234 }),
235
236 read: Effect.fn(function* ({ id, olds, output }) {
237 const existing = yield* observe({
238 id: output?.id,
239 logicalId: id,
240 file: olds?.file ?? output?.file,
241 });
242 if (existing === undefined || existing.expired) return undefined;
243 const attrs = toAttrs(existing);
244 return (yield* hasAlchemyMetadata(id, tagRecord(existing.metadata)))
245 ? attrs
246 : Unowned(attrs);
247 }),
248
249 list: Effect.fn(function* () {
250 // Default list API includes expired links; those are already
251 // "deleted" (expire-on-destroy) and must not re-enter nuke.
252 const links = yield* listUnexpired();
253 return links
254 .filter((link) => {
255 const metadata = tagRecord(link.metadata);
256 return metadata[alchemyMetadataKeys.stack] !== undefined;
257 })
258 .map(toAttrs);
259 }),
260
261 reconcile: Effect.fn(function* ({ id, news, output, instanceId }) {
262 const metadata = yield* desiredMetadata(id, news.metadata);
263 const desiredExpiresAt = news.expiresAt;
264
265 let current: StripeFileLink | undefined = yield* observe({
266 id: output?.id,
267 logicalId: id,
268 file: news.file,
269 });
270 // Expired links cannot be updated, and the File on a link is
271 // immutable. Treat either as missing so ensure creates a new link.
272 if (current !== undefined && current.expired) {
273 current = undefined;
274 }
275 if (current !== undefined && fileIdOf(current.file) !== news.file) {
276 current = undefined;
277 }
278
279 if (current === undefined) {
280 current = yield* CreateFileLink({
281 file: news.file,
282 metadata,
283 ...(desiredExpiresAt !== undefined
284 ? { expires_at: desiredExpiresAt }
285 : {}),
286 }).pipe(
287 withRequestOptions({
288 idempotencyKey: `alchemy-file-link-${instanceId}`,
289 }),
290 );
291 }
292
293 const observedMetadata = tagRecord(current.metadata);
294 const { upsert, removed } = diffMetadata(observedMetadata, metadata);
295 const metadataChanged = upsert.length > 0 || removed.length > 0;
296 const observedExpiresAt = current.expires_at ?? undefined;
297 const expiresAtChanged = observedExpiresAt !== desiredExpiresAt;
298
299 if (!metadataChanged && !expiresAtChanged) {
300 return toAttrs(current);
301 }
302
303 const updated = yield* UpdateFileLink({
304 link: current.id,
305 ...(expiresAtChanged
306 ? {
307 expires_at:
308 desiredExpiresAt !== undefined ? desiredExpiresAt : "",
309 }
310 : {}),
311 ...(metadataChanged
312 ? {
313 metadata: {
314 ...Object.fromEntries(
315 upsert.map((tag) => [tag.Key, tag.Value]),
316 ),
317 ...Object.fromEntries(removed.map((key) => [key, ""])),
318 },
319 }
320 : {}),
321 });
322 return toAttrs(updated);
323 }),
324
325 delete: Effect.fn(function* ({ output }) {
326 const existing = yield* getById(output.id);
327 if (existing === undefined || existing.expired) return;
328 yield* UpdateFileLink({
329 link: existing.id,
330 expires_at: "now",
331 }).pipe(
332 Effect.catchIf(isMissingFileLink, () => Effect.void),
333 Effect.catchTag("InvalidRequestError", () => Effect.void),
334 );
335 }),
336 });