1import { withRequestOptions } from "@distilled.cloud/stripe";
2import {
3 GetPaymentLinks,
4 GetPaymentLink,
5 GetPaymentLinkLineItems,
6 CreatePaymentLink,
7 UpdatePaymentLink,
8 type Item as StripeLineItem,
9 type PaymentLink as StripePaymentLink,
10 type CreatePaymentLinkRequestAfterCompletion,
11 type CreatePaymentLinkRequestLineItemsItem,
12 type UpdatePaymentLinkRequestAfterCompletion,
13 type UpdatePaymentLinkRequestLineItemsItem,
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 * as Provider from "../Provider.ts";
20import { Resource } from "../Resource.ts";
21import { tagRecord } from "../Tags.ts";
22import {
23 alchemyMetadataKeys,
24 createInternalMetadata,
25 diffMetadata,
26 hasAlchemyMetadata,
27 stripInternalMetadata,
28 toMetadata,
29} from "./Metadata.ts";
30import type { Providers } from "./Providers.ts";
31import { isMissingStripeResource } from "./missing.ts";
32
33const LIST_PAGE_SIZE = 100;
34const LIST_MAX_PAGES = 100;
35
36export type PaymentLinkBillingAddressCollection = "auto" | "required";
37export type PaymentLinkCustomerCreation = "always" | "if_required";
38export type PaymentLinkPaymentMethodCollection = "always" | "if_required";
39export type PaymentLinkSubmitType =
40 | "auto"
41 | "book"
42 | "donate"
43 | "pay"
44 | "subscribe";
45export type PaymentLinkAfterCompletionType = "hosted_confirmation" | "redirect";
46
47export interface PaymentLinkLineItemAdjustableQuantity {
48 /**
49 * When true, the customer can change this item's quantity at checkout.
50 */
51 enabled: boolean;
52 /**
53 * Minimum quantity the customer can purchase. Defaults to `0`.
54 */
55 minimum?: number;
56 /**
57 * Maximum quantity the customer can purchase. Defaults to `99`.
58 */
59 maximum?: number;
60}
61
62export interface PaymentLinkLineItem {
63 /**
64 * Stripe Price id (`price_…`). Changing the set of prices replaces
65 * the payment link — existing items can only change quantity.
66 */
67 price: string;
68 /**
69 * Quantity of this line item. Mutable for existing items.
70 */
71 quantity: number;
72 /**
73 * When set, the customer can adjust quantity during checkout.
74 */
75 adjustableQuantity?: PaymentLinkLineItemAdjustableQuantity;
76}
77
78export interface PaymentLinkAfterCompletionHostedConfirmation {
79 /**
80 * Custom message shown on Stripe's hosted confirmation page.
81 */
82 customMessage?: string;
83}
84
85export interface PaymentLinkAfterCompletionRedirect {
86 /**
87 * URL the customer is sent to after purchase. `{CHECKOUT_SESSION_ID}`
88 * is substituted with the completed session id.
89 */
90 url: string;
91}
92
93export interface PaymentLinkAfterCompletion {
94 /**
95 * `hosted_confirmation` shows Stripe's confirmation page;
96 * `redirect` sends the customer to `redirect.url`.
97 */
98 type: PaymentLinkAfterCompletionType;
99 /**
100 * Configuration when `type` is `hosted_confirmation`.
101 */
102 hostedConfirmation?: PaymentLinkAfterCompletionHostedConfirmation;
103 /**
104 * Configuration when `type` is `redirect`.
105 */
106 redirect?: PaymentLinkAfterCompletionRedirect;
107}
108
109export interface PaymentLinkProps {
110 /**
111 * Line items sold by this payment link. At least one is required.
112 * Changing the set of prices replaces the link; quantity and
113 * `adjustableQuantity` of existing items update in place.
114 */
115 lineItems: PaymentLinkLineItem[];
116 /**
117 * Whether the payment link URL is active. When `false`, visitors see
118 * a deactivated page (and `inactiveMessage` if set).
119 * @default true
120 */
121 active?: boolean;
122 /**
123 * Whether customers can enter promotion codes at checkout.
124 * @default false
125 */
126 allowPromotionCodes?: boolean;
127 /**
128 * How Checkout collects a billing address. `auto` collects one when
129 * needed (tax, shipping); `required` always collects it.
130 * @default "auto"
131 */
132 billingAddressCollection?: PaymentLinkBillingAddressCollection;
133 /**
134 * Behavior after the purchase completes.
135 */
136 afterCompletion?: PaymentLinkAfterCompletion;
137 /**
138 * Custom message shown when the payment link is inactive.
139 */
140 inactiveMessage?: string;
141 /**
142 * Submit-button copy and `buy.stripe.com` / `donate.stripe.com`
143 * hostname. `auto` infers from the line items.
144 * @default "auto"
145 */
146 submitType?: PaymentLinkSubmitType;
147 /**
148 * Whether Checkout Sessions created by this link also create a
149 * Customer. `if_required` only creates one when needed.
150 * @default "if_required"
151 */
152 customerCreation?: PaymentLinkCustomerCreation;
153 /**
154 * Whether Checkout collects a payment method. `if_required` skips
155 * collection when the total due is 0 (subscription mode only).
156 * @default "always"
157 */
158 paymentMethodCollection?: PaymentLinkPaymentMethodCollection;
159 /**
160 * When true, Checkout collects a phone number.
161 * @default false
162 */
163 phoneNumberCollection?: boolean;
164 /**
165 * User-defined metadata. Alchemy ownership keys (`alchemy_stack` /
166 * `alchemy_stage` / `alchemy_id`) are merged in automatically. Keys may
167 * not contain `:`. Copied onto Checkout Sessions created by this link.
168 */
169 metadata?: Record<string, string>;
170}
171
172export type PaymentLink = Resource<
173 "Stripe.PaymentLink",
174 PaymentLinkProps,
175 {
176 /** Stripe payment link id (`plink_…`). */
177 id: string;
178 /** Public URL customers open to pay. */
179 url: string;
180 /** Whether the payment link URL is active. */
181 active: boolean;
182 /** Line items sold by this payment link. */
183 lineItems: PaymentLinkLineItem[];
184 /** Whether customers can enter promotion codes at checkout. */
185 allowPromotionCodes: boolean;
186 /** How Checkout collects a billing address. */
187 billingAddressCollection: PaymentLinkBillingAddressCollection;
188 /** Behavior after the purchase completes. */
189 afterCompletion: PaymentLinkAfterCompletion;
190 /** Custom message shown when the payment link is inactive. */
191 inactiveMessage: string | undefined;
192 /** Submit-button copy / hostname hint. */
193 submitType: PaymentLinkSubmitType;
194 /** Whether Checkout Sessions create a Customer. */
195 customerCreation: PaymentLinkCustomerCreation;
196 /** Whether Checkout collects a payment method. */
197 paymentMethodCollection: PaymentLinkPaymentMethodCollection;
198 /** Whether Checkout collects a phone number. */
199 phoneNumberCollection: boolean;
200 /** Three-letter ISO currency code. */
201 currency: string;
202 /** User-defined metadata (Alchemy ownership keys stripped). */
203 metadata: Record<string, string>;
204 /** Whether the payment link exists in live mode. */
205 livemode: boolean;
206 },
207 never,
208 Providers
209>;
210
211/**
212 * A Stripe Payment Link — a shareable URL that opens a hosted Checkout
213 * page. Requires at least one `lineItems` price. Quantity, metadata,
214 * `active`, and most Checkout options update in place; changing the set
215 * of prices replaces the link. Payment links cannot be deleted; destroy
216 * deactivates them (`active=false`).
217 *
218 * @see https://docs.stripe.com/api/payment_links
219 *
220 * ### Creating a Payment Link
221 * **Example:** One-time price
222 * ```typescript
223 * const product = yield* Stripe.Product("pro-plan", { name: "Pro Plan" });
224 * const price = yield* Stripe.Price("pro-once", {
225 * product: product.id,
226 * currency: "usd",
227 * unitAmount: 2000,
228 * });
229 * const link = yield* Stripe.PaymentLink("buy", {
230 * lineItems: [{ price: price.id, quantity: 1 }],
231 * });
232 * ```
233 *
234 * **Example:** Promotion codes and a confirmation message
235 * ```typescript
236 * const link = yield* Stripe.PaymentLink("buy", {
237 * lineItems: [{ price: price.id, quantity: 1 }],
238 * allowPromotionCodes: true,
239 * afterCompletion: {
240 * type: "hosted_confirmation",
241 * hostedConfirmation: { customMessage: "Thanks for your order." },
242 * },
243 * metadata: { campaign: "launch" },
244 * });
245 * ```
246 *
247 * ### Updating a Payment Link
248 * **Example:** Quantity, metadata, and deactivate
249 * ```typescript
250 * const link = yield* Stripe.PaymentLink("buy", {
251 * lineItems: [{ price: price.id, quantity: 2 }],
252 * active: false,
253 * inactiveMessage: "This link is no longer available.",
254 * metadata: { campaign: "paused" },
255 * });
256 * ```
257 *
258 * @resource
259 * @product Checkout
260 */
261export const PaymentLink = Resource<PaymentLink>("Stripe.PaymentLink");
262
263export class PaymentLinkNotResolved extends Data.TaggedError(
264 "Stripe.PaymentLinkNotResolved",
265)<{
266 lineItems: PaymentLinkLineItem[];
267}> {}
268
269type PaymentLinkAttributes = PaymentLink["Attributes"];
270
271const userMetadata = (
272 metadata: Record<string, string | undefined> | null | undefined,
273): Record<string, string> => stripInternalMetadata(tagRecord(metadata));
274
275const priceIdOf = (
276 price: StripeLineItem["price"] | string | null | undefined,
277): string | undefined => {
278 if (price == null) return undefined;
279 if (typeof price === "string") return price;
280 return price.id;
281};
282
283const fromObservedAdjustableQuantity = (
284 value: StripeLineItem["adjustable_quantity"],
285): PaymentLinkLineItemAdjustableQuantity | undefined => {
286 if (value == null) return undefined;
287 return {
288 enabled: value.enabled,
289 ...(value.minimum != null ? { minimum: value.minimum } : {}),
290 ...(value.maximum != null ? { maximum: value.maximum } : {}),
291 };
292};
293
294const fromObservedLineItems = (
295 items: ReadonlyArray<StripeLineItem> | undefined,
296): PaymentLinkLineItem[] => {
297 if (items === undefined) return [];
298 const out: PaymentLinkLineItem[] = [];
299 for (const item of items) {
300 const price = priceIdOf(item.price);
301 if (price === undefined) continue;
302 const adjustableQuantity = fromObservedAdjustableQuantity(
303 item.adjustable_quantity,
304 );
305 out.push({
306 price,
307 quantity: item.quantity ?? 1,
308 ...(adjustableQuantity !== undefined ? { adjustableQuantity } : {}),
309 });
310 }
311 return out;
312};
313
314const fromObservedAfterCompletion = (
315 value: StripePaymentLink["after_completion"],
316): PaymentLinkAfterCompletion => ({
317 type: value.type,
318 ...(value.hosted_confirmation !== undefined
319 ? {
320 hostedConfirmation: {
321 ...(value.hosted_confirmation.custom_message != null
322 ? { customMessage: value.hosted_confirmation.custom_message }
323 : {}),
324 },
325 }
326 : {}),
327 ...(value.redirect !== undefined
328 ? { redirect: { url: value.redirect.url } }
329 : {}),
330});
331
332const toWireAfterCompletion = (
333 value: PaymentLinkAfterCompletion,
334): CreatePaymentLinkRequestAfterCompletion => ({
335 type: value.type,
336 ...(value.type === "hosted_confirmation"
337 ? {
338 hosted_confirmation: {
339 ...(value.hostedConfirmation?.customMessage !== undefined
340 ? { custom_message: value.hostedConfirmation.customMessage }
341 : {}),
342 },
343 }
344 : {}),
345 ...(value.type === "redirect" && value.redirect !== undefined
346 ? { redirect: { url: value.redirect.url } }
347 : {}),
348});
349
350const toWireCreateLineItems = (
351 items: PaymentLinkLineItem[],
352): CreatePaymentLinkRequestLineItemsItem[] =>
353 items.map((item) => ({
354 price: item.price,
355 quantity: item.quantity,
356 ...(item.adjustableQuantity !== undefined
357 ? {
358 adjustable_quantity: {
359 enabled: item.adjustableQuantity.enabled,
360 ...(item.adjustableQuantity.minimum !== undefined
361 ? { minimum: item.adjustableQuantity.minimum }
362 : {}),
363 ...(item.adjustableQuantity.maximum !== undefined
364 ? { maximum: item.adjustableQuantity.maximum }
365 : {}),
366 },
367 }
368 : {}),
369 }));
370
371const toAttrs = (link: StripePaymentLink): PaymentLinkAttributes => ({
372 id: link.id,
373 url: link.url,
374 active: link.active,
375 lineItems: fromObservedLineItems(link.line_items?.data),
376 allowPromotionCodes: link.allow_promotion_codes,
377 billingAddressCollection: link.billing_address_collection,
378 afterCompletion: fromObservedAfterCompletion(link.after_completion),
379 inactiveMessage: link.inactive_message ?? undefined,
380 submitType: link.submit_type,
381 customerCreation: link.customer_creation,
382 paymentMethodCollection: link.payment_method_collection,
383 phoneNumberCollection: link.phone_number_collection.enabled,
384 currency: link.currency,
385 metadata: userMetadata(link.metadata),
386 livemode: link.livemode,
387});
388
389const isMissingPaymentLink = isMissingStripeResource;
390
391const hydrateLineItems = (link: StripePaymentLink) =>
392 Effect.gen(function* () {
393 const data: StripeLineItem[] = [];
394 let startingAfter: string | undefined;
395 for (let page = 0; page < LIST_MAX_PAGES; page++) {
396 const response = yield* GetPaymentLinkLineItems({
397 payment_link: link.id,
398 limit: LIST_PAGE_SIZE,
399 ...(startingAfter !== undefined
400 ? { starting_after: startingAfter }
401 : {}),
402 });
403 data.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 {
413 ...link,
414 line_items: {
415 data,
416 has_more: false,
417 object: "list" as const,
418 url: link.line_items?.url ?? `/v1/payment_links/${link.id}/line_items`,
419 },
420 };
421 });
422
423const getById = (payment_link: string) =>
424 GetPaymentLink({ payment_link }).pipe(
425 Effect.catchIf(isMissingPaymentLink, () => Effect.succeed(undefined)),
426 Effect.flatMap((link) =>
427 link === undefined ? Effect.succeed(undefined) : hydrateLineItems(link),
428 ),
429 );
430
431const listByActive = Effect.fn(function* (active: boolean) {
432 const links: StripePaymentLink[] = [];
433 let startingAfter: string | undefined;
434 for (let page = 0; page < LIST_MAX_PAGES; page++) {
435 const response = yield* GetPaymentLinks({
436 active,
437 limit: LIST_PAGE_SIZE,
438 ...(startingAfter !== undefined ? { starting_after: startingAfter } : {}),
439 });
440 links.push(...response.data);
441 if (!response.has_more || response.data.length === 0) {
442 break;
443 }
444 startingAfter = response.data[response.data.length - 1]?.id;
445 if (startingAfter === undefined) {
446 break;
447 }
448 }
449 return links;
450});
451
452const listAllPaymentLinks = Effect.fn(function* () {
453 const [active, inactive] = yield* Effect.all(
454 [listByActive(true), listByActive(false)],
455 { concurrency: 2 },
456 );
457 const seen = new Set<string>();
458 const links: StripePaymentLink[] = [];
459 for (const link of [...active, ...inactive]) {
460 if (seen.has(link.id)) continue;
461 seen.add(link.id);
462 links.push(link);
463 }
464 return links;
465});
466
467const findByAlchemyId = Effect.fn(function* (id: string) {
468 const links = yield* listAllPaymentLinks();
469 const matches: StripePaymentLink[] = [];
470 for (const link of links) {
471 if (yield* hasAlchemyMetadata(id, tagRecord(link.metadata))) {
472 matches.push(link);
473 }
474 }
475 matches.sort((a, b) => Number(b.active) - Number(a.active));
476 return matches[0];
477});
478
479const observe = Effect.fn(function* (input: {
480 id?: string;
481 logicalId: string;
482}) {
483 if (input.id !== undefined) {
484 const byId = yield* getById(input.id);
485 if (byId !== undefined) return byId;
486 }
487 const found = yield* findByAlchemyId(input.logicalId);
488 if (found === undefined) return undefined;
489 return yield* getById(found.id);
490});
491
492const desiredMetadata = Effect.fn(function* (
493 id: string,
494 metadata: Record<string, string> | undefined,
495) {
496 return {
497 ...toMetadata(metadata),
498 ...(yield* createInternalMetadata(id)),
499 };
500});
501
502const shouldReplace = (
503 news: PaymentLinkProps,
504 output: PaymentLinkAttributes | undefined,
505): boolean => {
506 if (output === undefined) return false;
507 if (output.lineItems.length === 0) return false;
508 if (news.lineItems.length !== output.lineItems.length) return true;
509 return news.lineItems.some(
510 (item, index) => item.price !== output.lineItems[index]?.price,
511 );
512};
513
514const adjustableQuantityEqual = (
515 desired: PaymentLinkLineItemAdjustableQuantity | undefined,
516 observed: PaymentLinkLineItemAdjustableQuantity | undefined,
517): boolean => {
518 if (desired === undefined) return true;
519 return deepEqual(desired, observed, { stripNullish: true });
520};
521
522const lineItemsNeedUpdate = (
523 desired: PaymentLinkLineItem[],
524 observedItems: ReadonlyArray<StripeLineItem>,
525): boolean => {
526 if (desired.length !== observedItems.length) return false;
527 return desired.some((item, index) => {
528 const observed = observedItems[index];
529 if (observed === undefined) return false;
530 if ((observed.quantity ?? 1) !== item.quantity) return true;
531 return !adjustableQuantityEqual(
532 item.adjustableQuantity,
533 fromObservedAdjustableQuantity(observed.adjustable_quantity),
534 );
535 });
536};
537
538const toWireUpdateLineItems = (
539 desired: PaymentLinkLineItem[],
540 observedItems: ReadonlyArray<StripeLineItem>,
541): UpdatePaymentLinkRequestLineItemsItem[] | undefined => {
542 if (desired.length !== observedItems.length) return undefined;
543 const items: UpdatePaymentLinkRequestLineItemsItem[] = [];
544 for (let index = 0; index < desired.length; index++) {
545 const item = desired[index];
546 const observed = observedItems[index];
547 if (item === undefined || observed === undefined) return undefined;
548 items.push({
549 id: observed.id,
550 quantity: item.quantity,
551 ...(item.adjustableQuantity !== undefined
552 ? {
553 adjustable_quantity: {
554 enabled: item.adjustableQuantity.enabled,
555 ...(item.adjustableQuantity.minimum !== undefined
556 ? { minimum: item.adjustableQuantity.minimum }
557 : {}),
558 ...(item.adjustableQuantity.maximum !== undefined
559 ? { maximum: item.adjustableQuantity.maximum }
560 : {}),
561 },
562 }
563 : {}),
564 });
565 }
566 return items;
567};
568
569export const PaymentLinkProvider = () =>
570 Provider.succeed(PaymentLink, {
571 stables: ["id", "currency", "livemode"],
572
573 diff: Effect.fn(function* ({ news, output }) {
574 if (!isResolved(news)) return undefined;
575 if (shouldReplace(news, output)) {
576 return { action: "replace" } as const;
577 }
578 return undefined;
579 }),
580
581 read: Effect.fn(function* ({ id, output }) {
582 const existing = yield* observe({
583 id: output?.id,
584 logicalId: id,
585 });
586 if (existing === undefined) return undefined;
587 const attrs = toAttrs(existing);
588 return (yield* hasAlchemyMetadata(id, tagRecord(existing.metadata)))
589 ? attrs
590 : Unowned(attrs);
591 }),
592
593 list: Effect.fn(function* () {
594 // Default list API is active payment links; deactivated rows stay
595 // in Stripe but must not re-enter nuke. Filter to alchemy_stack so
596 // account-wide teardown only touches our rows.
597 const links = yield* listByActive(true);
598 return links
599 .filter((link) => {
600 const metadata = tagRecord(link.metadata);
601 return metadata[alchemyMetadataKeys.stack] !== undefined;
602 })
603 .map(toAttrs);
604 }),
605
606 reconcile: Effect.fn(function* ({ id, news, output, instanceId }) {
607 const metadata = yield* desiredMetadata(id, news.metadata);
608 const desiredActive = news.active ?? true;
609 const afterCompletion = news.afterCompletion;
610
611 let current: StripePaymentLink | undefined = yield* observe({
612 id: output?.id,
613 logicalId: id,
614 });
615 if (current !== undefined && shouldReplace(news, toAttrs(current))) {
616 current = undefined;
617 }
618
619 if (current === undefined) {
620 current = yield* CreatePaymentLink({
621 line_items: toWireCreateLineItems(news.lineItems),
622 metadata,
623 ...(news.allowPromotionCodes !== undefined
624 ? { allow_promotion_codes: news.allowPromotionCodes }
625 : {}),
626 ...(news.billingAddressCollection !== undefined
627 ? { billing_address_collection: news.billingAddressCollection }
628 : {}),
629 ...(news.submitType !== undefined
630 ? { submit_type: news.submitType }
631 : {}),
632 ...(news.customerCreation !== undefined
633 ? { customer_creation: news.customerCreation }
634 : {}),
635 ...(news.paymentMethodCollection !== undefined
636 ? { payment_method_collection: news.paymentMethodCollection }
637 : {}),
638 ...(news.phoneNumberCollection !== undefined
639 ? {
640 phone_number_collection: {
641 enabled: news.phoneNumberCollection,
642 },
643 }
644 : {}),
645 ...(news.inactiveMessage !== undefined
646 ? { inactive_message: news.inactiveMessage }
647 : {}),
648 ...(afterCompletion !== undefined
649 ? { after_completion: toWireAfterCompletion(afterCompletion) }
650 : {}),
651 }).pipe(
652 withRequestOptions({
653 idempotencyKey: `alchemy-payment-link-${instanceId}`,
654 }),
655 );
656 current = yield* hydrateLineItems(current);
657 }
658
659 if (current === undefined) {
660 return yield* new PaymentLinkNotResolved({
661 lineItems: news.lineItems,
662 });
663 }
664
665 const observedMetadata = tagRecord(current.metadata);
666 const { upsert, removed } = diffMetadata(observedMetadata, metadata);
667 const metadataChanged = upsert.length > 0 || removed.length > 0;
668 const activeChanged = current.active !== desiredActive;
669 const allowPromotionCodesChanged =
670 news.allowPromotionCodes !== undefined &&
671 current.allow_promotion_codes !== news.allowPromotionCodes;
672 const billingChanged =
673 news.billingAddressCollection !== undefined &&
674 current.billing_address_collection !== news.billingAddressCollection;
675 const submitTypeChanged =
676 news.submitType !== undefined &&
677 current.submit_type !== news.submitType;
678 const customerCreationChanged =
679 news.customerCreation !== undefined &&
680 current.customer_creation !== news.customerCreation;
681 const paymentMethodCollectionChanged =
682 news.paymentMethodCollection !== undefined &&
683 current.payment_method_collection !== news.paymentMethodCollection;
684 const phoneChanged =
685 news.phoneNumberCollection !== undefined &&
686 current.phone_number_collection.enabled !== news.phoneNumberCollection;
687 const inactiveMessageChanged =
688 news.inactiveMessage !== undefined &&
689 (current.inactive_message ?? "") !== news.inactiveMessage;
690 const afterCompletionChanged =
691 afterCompletion !== undefined &&
692 !deepEqual(
693 afterCompletion,
694 fromObservedAfterCompletion(current.after_completion),
695 { stripNullish: true },
696 );
697 const observedLineItems = current.line_items?.data ?? [];
698 const lineItemsChanged = lineItemsNeedUpdate(
699 news.lineItems,
700 observedLineItems,
701 );
702 const updateLineItems = lineItemsChanged
703 ? toWireUpdateLineItems(news.lineItems, observedLineItems)
704 : undefined;
705
706 if (
707 !activeChanged &&
708 !allowPromotionCodesChanged &&
709 !billingChanged &&
710 !submitTypeChanged &&
711 !customerCreationChanged &&
712 !paymentMethodCollectionChanged &&
713 !phoneChanged &&
714 !inactiveMessageChanged &&
715 !afterCompletionChanged &&
716 !lineItemsChanged &&
717 !metadataChanged
718 ) {
719 return toAttrs(current);
720 }
721
722 const updated = yield* UpdatePaymentLink({
723 payment_link: current.id,
724 ...(activeChanged ? { active: desiredActive } : {}),
725 ...(allowPromotionCodesChanged
726 ? { allow_promotion_codes: news.allowPromotionCodes }
727 : {}),
728 ...(billingChanged
729 ? { billing_address_collection: news.billingAddressCollection }
730 : {}),
731 ...(submitTypeChanged ? { submit_type: news.submitType } : {}),
732 ...(customerCreationChanged
733 ? { customer_creation: news.customerCreation }
734 : {}),
735 ...(paymentMethodCollectionChanged
736 ? { payment_method_collection: news.paymentMethodCollection }
737 : {}),
738 ...(phoneChanged
739 ? {
740 phone_number_collection: {
741 enabled: news.phoneNumberCollection === true,
742 },
743 }
744 : {}),
745 ...(inactiveMessageChanged
746 ? { inactive_message: news.inactiveMessage }
747 : {}),
748 ...(afterCompletionChanged && afterCompletion !== undefined
749 ? {
750 after_completion: toWireAfterCompletion(
751 afterCompletion,
752 ) as UpdatePaymentLinkRequestAfterCompletion,
753 }
754 : {}),
755 ...(updateLineItems !== undefined
756 ? { line_items: updateLineItems }
757 : {}),
758 ...(metadataChanged
759 ? {
760 metadata: {
761 ...Object.fromEntries(
762 upsert.map((tag) => [tag.Key, tag.Value]),
763 ),
764 ...Object.fromEntries(removed.map((key) => [key, ""])),
765 },
766 }
767 : {}),
768 });
769 return toAttrs(yield* hydrateLineItems(updated));
770 }),
771
772 delete: Effect.fn(function* ({ output }) {
773 const existing = yield* getById(output.id);
774 if (existing === undefined || !existing.active) return;
775 yield* UpdatePaymentLink({
776 payment_link: existing.id,
777 active: false,
778 }).pipe(Effect.catchIf(isMissingPaymentLink, () => Effect.void));
779 }),
780 });