| 1 | import { withRequestOptions } from "@distilled.cloud/stripe"; |
| 2 | import { |
| 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"; |
| 15 | import * as Data from "effect/Data"; |
| 16 | import * as Effect from "effect/Effect"; |
| 17 | import { Unowned } from "../AdoptPolicy.ts"; |
| 18 | import { deepEqual, isResolved } from "../Diff.ts"; |
| 19 | import * as Provider from "../Provider.ts"; |
| 20 | import { Resource } from "../Resource.ts"; |
| 21 | import { tagRecord } from "../Tags.ts"; |
| 22 | import { |
| 23 | alchemyMetadataKeys, |
| 24 | createInternalMetadata, |
| 25 | diffMetadata, |
| 26 | hasAlchemyMetadata, |
| 27 | stripInternalMetadata, |
| 28 | toMetadata, |
| 29 | } from "./Metadata.ts"; |
| 30 | import type { Providers } from "./Providers.ts"; |
| 31 | import { isMissingStripeResource } from "./missing.ts"; |
| 32 | |
| 33 | const LIST_PAGE_SIZE = 100; |
| 34 | const LIST_MAX_PAGES = 100; |
| 35 | |
| 36 | export type PaymentLinkBillingAddressCollection = "auto" | "required"; |
| 37 | export type PaymentLinkCustomerCreation = "always" | "if_required"; |
| 38 | export type PaymentLinkPaymentMethodCollection = "always" | "if_required"; |
| 39 | export type PaymentLinkSubmitType = |
| 40 | | "auto" |
| 41 | | "book" |
| 42 | | "donate" |
| 43 | | "pay" |
| 44 | | "subscribe"; |
| 45 | export type PaymentLinkAfterCompletionType = "hosted_confirmation" | "redirect"; |
| 46 | |
| 47 | export 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 | |
| 62 | export 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 | |
| 78 | export interface PaymentLinkAfterCompletionHostedConfirmation { |
| 79 | /** |
| 80 | * Custom message shown on Stripe's hosted confirmation page. |
| 81 | */ |
| 82 | customMessage?: string; |
| 83 | } |
| 84 | |
| 85 | export 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 | |
| 93 | export 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 | |
| 109 | export 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 | |
| 172 | export 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 | */ |
| 261 | export const PaymentLink = Resource<PaymentLink>("Stripe.PaymentLink"); |
| 262 | |
| 263 | export class PaymentLinkNotResolved extends Data.TaggedError( |
| 264 | "Stripe.PaymentLinkNotResolved", |
| 265 | )<{ |
| 266 | lineItems: PaymentLinkLineItem[]; |
| 267 | }> {} |
| 268 | |
| 269 | type PaymentLinkAttributes = PaymentLink["Attributes"]; |
| 270 | |
| 271 | const userMetadata = ( |
| 272 | metadata: Record<string, string | undefined> | null | undefined, |
| 273 | ): Record<string, string> => stripInternalMetadata(tagRecord(metadata)); |
| 274 | |
| 275 | const 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 | |
| 283 | const 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 | |
| 294 | const 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 | |
| 314 | const 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 | |
| 332 | const 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 | |
| 350 | const 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 | |
| 371 | const 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 | |
| 389 | const isMissingPaymentLink = isMissingStripeResource; |
| 390 | |
| 391 | const 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 | |
| 423 | const 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 | |
| 431 | const 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 | |
| 452 | const 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 | |
| 467 | const 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 | |
| 479 | const 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 | |
| 492 | const 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 | |
| 502 | const 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 | |
| 514 | const 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 | |
| 522 | const 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 | |
| 538 | const 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 | |
| 569 | export 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 | }); |