| 1 | import { withRequestOptions } from "@distilled.cloud/stripe"; |
| 2 | import { |
| 3 | GetBillingPortalConfigurations, |
| 4 | GetBillingPortalConfiguration, |
| 5 | CreateBillingPortalConfiguration, |
| 6 | UpdateBillingPortalConfiguration, |
| 7 | type BillingPortalConfiguration as StripeBillingPortalConfiguration, |
| 8 | type PortalFeatures, |
| 9 | type CreateBillingPortalConfigurationRequestFeatures, |
| 10 | } from "@distilled.cloud/stripe/stripe"; |
| 11 | import * as Data from "effect/Data"; |
| 12 | import * as Effect from "effect/Effect"; |
| 13 | import { Unowned } from "../AdoptPolicy.ts"; |
| 14 | import { deepEqual, isResolved } from "../Diff.ts"; |
| 15 | import * as Provider from "../Provider.ts"; |
| 16 | import { Resource } from "../Resource.ts"; |
| 17 | import { tagRecord } from "../Tags.ts"; |
| 18 | import { |
| 19 | alchemyMetadataKeys, |
| 20 | createInternalMetadata, |
| 21 | diffMetadata, |
| 22 | hasAlchemyMetadata, |
| 23 | stripInternalMetadata, |
| 24 | toMetadata, |
| 25 | } from "./Metadata.ts"; |
| 26 | import type { Providers } from "./Providers.ts"; |
| 27 | import { isMissingStripeResource } from "./missing.ts"; |
| 28 | |
| 29 | const LIST_PAGE_SIZE = 100; |
| 30 | const LIST_MAX_PAGES = 100; |
| 31 | |
| 32 | export type CustomerUpdateAllowedUpdate = |
| 33 | | "address" |
| 34 | | "email" |
| 35 | | "name" |
| 36 | | "phone" |
| 37 | | "shipping" |
| 38 | | "tax_id"; |
| 39 | |
| 40 | export type SubscriptionCancelMode = "at_period_end" | "immediately"; |
| 41 | |
| 42 | export type SubscriptionCancelProrationBehavior = |
| 43 | | "always_invoice" |
| 44 | | "create_prorations" |
| 45 | | "none"; |
| 46 | |
| 47 | export type CancellationReasonOption = |
| 48 | | "customer_service" |
| 49 | | "low_quality" |
| 50 | | "missing_features" |
| 51 | | "other" |
| 52 | | "switched_service" |
| 53 | | "too_complex" |
| 54 | | "too_expensive" |
| 55 | | "unused"; |
| 56 | |
| 57 | export type SubscriptionUpdateAllowedUpdate = |
| 58 | | "price" |
| 59 | | "promotion_code" |
| 60 | | "quantity"; |
| 61 | |
| 62 | export type SubscriptionUpdateBillingCycleAnchor = "now" | "unchanged"; |
| 63 | |
| 64 | export type SubscriptionUpdateProrationBehavior = |
| 65 | | "always_invoice" |
| 66 | | "create_prorations" |
| 67 | | "none"; |
| 68 | |
| 69 | export type SubscriptionUpdateTrialBehavior = "continue_trial" | "end_trial"; |
| 70 | |
| 71 | export type ScheduleAtPeriodEndConditionType = |
| 72 | | "decreasing_item_amount" |
| 73 | | "shortening_interval"; |
| 74 | |
| 75 | export interface BillingPortalCustomerUpdate { |
| 76 | /** |
| 77 | * Whether customers can update their details in the portal. |
| 78 | */ |
| 79 | enabled: boolean; |
| 80 | /** |
| 81 | * Customer fields that can be updated. Empty means none are updateable. |
| 82 | */ |
| 83 | allowedUpdates?: CustomerUpdateAllowedUpdate[]; |
| 84 | } |
| 85 | |
| 86 | export interface BillingPortalInvoiceHistory { |
| 87 | /** |
| 88 | * Whether the portal shows invoice history. |
| 89 | */ |
| 90 | enabled: boolean; |
| 91 | } |
| 92 | |
| 93 | export interface BillingPortalPaymentMethodUpdate { |
| 94 | /** |
| 95 | * Whether customers can update payment methods in the portal. |
| 96 | */ |
| 97 | enabled: boolean; |
| 98 | /** |
| 99 | * Payment Method Configuration id to use. When omitted, Stripe uses the |
| 100 | * default configuration. |
| 101 | */ |
| 102 | paymentMethodConfiguration?: string; |
| 103 | } |
| 104 | |
| 105 | export interface BillingPortalCancellationReason { |
| 106 | /** |
| 107 | * Whether cancellation reasons are collected in the portal. |
| 108 | */ |
| 109 | enabled: boolean; |
| 110 | /** |
| 111 | * Cancellation reasons shown to the customer. |
| 112 | */ |
| 113 | options?: CancellationReasonOption[]; |
| 114 | } |
| 115 | |
| 116 | export interface BillingPortalSubscriptionCancel { |
| 117 | /** |
| 118 | * Whether customers can cancel subscriptions in the portal. |
| 119 | */ |
| 120 | enabled: boolean; |
| 121 | /** |
| 122 | * When to cancel: immediately or at period end. |
| 123 | */ |
| 124 | mode?: SubscriptionCancelMode; |
| 125 | /** |
| 126 | * Proration behavior when canceling. `create_prorations` is only valid |
| 127 | * with `mode: "immediately"`. |
| 128 | */ |
| 129 | prorationBehavior?: SubscriptionCancelProrationBehavior; |
| 130 | /** |
| 131 | * Cancellation-reason collection in the portal. |
| 132 | */ |
| 133 | cancellationReason?: BillingPortalCancellationReason; |
| 134 | } |
| 135 | |
| 136 | export interface BillingPortalAdjustableQuantity { |
| 137 | /** |
| 138 | * Whether quantity can be adjusted to any non-negative integer. |
| 139 | */ |
| 140 | enabled: boolean; |
| 141 | /** |
| 142 | * Maximum quantity that can be set. |
| 143 | */ |
| 144 | maximum?: number; |
| 145 | /** |
| 146 | * Minimum quantity that can be set. |
| 147 | */ |
| 148 | minimum?: number; |
| 149 | } |
| 150 | |
| 151 | export interface BillingPortalSubscriptionUpdateProduct { |
| 152 | /** |
| 153 | * Product id customers can switch to. |
| 154 | */ |
| 155 | product: string; |
| 156 | /** |
| 157 | * Price ids for this product that a subscription can be updated to. |
| 158 | */ |
| 159 | prices: string[]; |
| 160 | /** |
| 161 | * Quantity-adjustment controls for this product. |
| 162 | */ |
| 163 | adjustableQuantity?: BillingPortalAdjustableQuantity; |
| 164 | } |
| 165 | |
| 166 | export interface BillingPortalScheduleAtPeriodEnd { |
| 167 | /** |
| 168 | * Conditions that schedule an update for the end of the current period. |
| 169 | */ |
| 170 | conditions?: { type: ScheduleAtPeriodEndConditionType }[]; |
| 171 | } |
| 172 | |
| 173 | export interface BillingPortalSubscriptionUpdate { |
| 174 | /** |
| 175 | * Whether customers can update subscriptions in the portal. |
| 176 | */ |
| 177 | enabled: boolean; |
| 178 | /** |
| 179 | * Billing cycle anchor after an update (`now` or `unchanged`). |
| 180 | */ |
| 181 | billingCycleAnchor?: SubscriptionUpdateBillingCycleAnchor; |
| 182 | /** |
| 183 | * Subscription fields customers may change for listed products. |
| 184 | */ |
| 185 | defaultAllowedUpdates?: SubscriptionUpdateAllowedUpdate[]; |
| 186 | /** |
| 187 | * Up to 10 products that support subscription updates. Required when |
| 188 | * the feature is enabled. |
| 189 | */ |
| 190 | products?: BillingPortalSubscriptionUpdateProduct[]; |
| 191 | /** |
| 192 | * How to handle prorations from subscription updates. |
| 193 | */ |
| 194 | prorationBehavior?: SubscriptionUpdateProrationBehavior; |
| 195 | /** |
| 196 | * When to apply an update at period end instead of immediately. |
| 197 | */ |
| 198 | scheduleAtPeriodEnd?: BillingPortalScheduleAtPeriodEnd; |
| 199 | /** |
| 200 | * How to handle updates to a trialing subscription. |
| 201 | */ |
| 202 | trialUpdateBehavior?: SubscriptionUpdateTrialBehavior; |
| 203 | } |
| 204 | |
| 205 | export interface BillingPortalFeatures { |
| 206 | /** |
| 207 | * Customer-details updates in the portal. |
| 208 | */ |
| 209 | customerUpdate?: BillingPortalCustomerUpdate; |
| 210 | /** |
| 211 | * Invoice history in the portal. |
| 212 | */ |
| 213 | invoiceHistory?: BillingPortalInvoiceHistory; |
| 214 | /** |
| 215 | * Payment-method updates in the portal. |
| 216 | */ |
| 217 | paymentMethodUpdate?: BillingPortalPaymentMethodUpdate; |
| 218 | /** |
| 219 | * Subscription cancellation in the portal. |
| 220 | */ |
| 221 | subscriptionCancel?: BillingPortalSubscriptionCancel; |
| 222 | /** |
| 223 | * Subscription updates in the portal. |
| 224 | */ |
| 225 | subscriptionUpdate?: BillingPortalSubscriptionUpdate; |
| 226 | } |
| 227 | |
| 228 | export interface BillingPortalBusinessProfile { |
| 229 | /** |
| 230 | * Messaging shown to customers in the portal. |
| 231 | */ |
| 232 | headline?: string; |
| 233 | /** |
| 234 | * Public privacy-policy URL. |
| 235 | */ |
| 236 | privacyPolicyUrl?: string; |
| 237 | /** |
| 238 | * Public terms-of-service URL. |
| 239 | */ |
| 240 | termsOfServiceUrl?: string; |
| 241 | } |
| 242 | |
| 243 | export interface BillingPortalLoginPage { |
| 244 | /** |
| 245 | * When true, Stripe generates a shareable hosted login URL. |
| 246 | */ |
| 247 | enabled: boolean; |
| 248 | } |
| 249 | |
| 250 | export interface BillingPortalConfigurationProps { |
| 251 | /** |
| 252 | * Display name of the configuration. |
| 253 | */ |
| 254 | name?: string; |
| 255 | /** |
| 256 | * Whether the configuration can be used to create portal sessions. |
| 257 | * New configurations are created active. Destroy sets this to `false`. |
| 258 | * @default true |
| 259 | */ |
| 260 | active?: boolean; |
| 261 | /** |
| 262 | * Default URL customers return to from the portal. Empty string clears |
| 263 | * it on update. |
| 264 | */ |
| 265 | defaultReturnUrl?: string; |
| 266 | /** |
| 267 | * Business information shown in the portal. |
| 268 | */ |
| 269 | businessProfile?: BillingPortalBusinessProfile; |
| 270 | /** |
| 271 | * Features available in the portal. Required on create. |
| 272 | */ |
| 273 | features: BillingPortalFeatures; |
| 274 | /** |
| 275 | * Hosted login page for this configuration. |
| 276 | */ |
| 277 | loginPage?: BillingPortalLoginPage; |
| 278 | /** |
| 279 | * User-defined metadata. Alchemy ownership keys (`alchemy_stack` / |
| 280 | * `alchemy_stage` / `alchemy_id`) are merged in automatically. Keys may |
| 281 | * not contain `:`. |
| 282 | */ |
| 283 | metadata?: Record<string, string>; |
| 284 | } |
| 285 | |
| 286 | export interface BillingPortalFeaturesState { |
| 287 | /** Customer-details updates currently configured. */ |
| 288 | customerUpdate: { |
| 289 | enabled: boolean; |
| 290 | allowedUpdates: CustomerUpdateAllowedUpdate[]; |
| 291 | }; |
| 292 | /** Invoice history currently configured. */ |
| 293 | invoiceHistory: { enabled: boolean }; |
| 294 | /** Payment-method updates currently configured. */ |
| 295 | paymentMethodUpdate: { |
| 296 | enabled: boolean; |
| 297 | paymentMethodConfiguration: string | undefined; |
| 298 | }; |
| 299 | /** Subscription cancellation currently configured. */ |
| 300 | subscriptionCancel: { |
| 301 | enabled: boolean; |
| 302 | mode: SubscriptionCancelMode; |
| 303 | prorationBehavior: SubscriptionCancelProrationBehavior; |
| 304 | cancellationReason: { |
| 305 | enabled: boolean; |
| 306 | options: CancellationReasonOption[]; |
| 307 | }; |
| 308 | }; |
| 309 | /** Subscription updates currently configured. */ |
| 310 | subscriptionUpdate: { |
| 311 | enabled: boolean; |
| 312 | billingCycleAnchor: SubscriptionUpdateBillingCycleAnchor | undefined; |
| 313 | defaultAllowedUpdates: SubscriptionUpdateAllowedUpdate[]; |
| 314 | products: BillingPortalSubscriptionUpdateProduct[] | undefined; |
| 315 | prorationBehavior: SubscriptionUpdateProrationBehavior; |
| 316 | scheduleAtPeriodEnd: { |
| 317 | conditions: { type: ScheduleAtPeriodEndConditionType }[]; |
| 318 | }; |
| 319 | trialUpdateBehavior: SubscriptionUpdateTrialBehavior; |
| 320 | }; |
| 321 | } |
| 322 | |
| 323 | export type BillingPortalConfiguration = Resource< |
| 324 | "Stripe.BillingPortalConfiguration", |
| 325 | BillingPortalConfigurationProps, |
| 326 | { |
| 327 | /** Stripe billing portal configuration id (`bpc_…`). */ |
| 328 | id: string; |
| 329 | /** Display name of the configuration, if set. */ |
| 330 | name: string | undefined; |
| 331 | /** Whether the configuration can create portal sessions. */ |
| 332 | active: boolean; |
| 333 | /** Whether this is the account's default configuration. */ |
| 334 | isDefault: boolean; |
| 335 | /** Default return URL, if set. */ |
| 336 | defaultReturnUrl: string | undefined; |
| 337 | /** Business information shown in the portal. */ |
| 338 | businessProfile: { |
| 339 | headline: string | undefined; |
| 340 | privacyPolicyUrl: string | undefined; |
| 341 | termsOfServiceUrl: string | undefined; |
| 342 | }; |
| 343 | /** Features currently configured on the portal. */ |
| 344 | features: BillingPortalFeaturesState; |
| 345 | /** Hosted login page (`url` is set when enabled). */ |
| 346 | loginPage: { enabled: boolean; url: string | undefined }; |
| 347 | /** User-defined metadata (Alchemy ownership keys stripped). */ |
| 348 | metadata: Record<string, string>; |
| 349 | /** Unix timestamp when the configuration was created. */ |
| 350 | created: number; |
| 351 | /** Unix timestamp when the configuration was last updated. */ |
| 352 | updated: number; |
| 353 | /** Whether the configuration exists in live mode. */ |
| 354 | livemode: boolean; |
| 355 | }, |
| 356 | never, |
| 357 | Providers |
| 358 | >; |
| 359 | |
| 360 | /** |
| 361 | * A Stripe Customer Portal configuration — the features, branding, and |
| 362 | * return URL used when creating portal sessions. `features` is required |
| 363 | * on create. Name, metadata, business profile, login page, return URL, |
| 364 | * features, and `active` update in place. Stripe does not hard-delete |
| 365 | * portal configurations; destroying this resource deactivates it |
| 366 | * (`active: false`). |
| 367 | * |
| 368 | * @see https://docs.stripe.com/api/customer_portal/configurations |
| 369 | * |
| 370 | * ### Creating a Configuration |
| 371 | * **Example:** Invoice history only |
| 372 | * ```typescript |
| 373 | * const portal = yield* Stripe.BillingPortalConfiguration("customer-portal", { |
| 374 | * name: "Customer portal", |
| 375 | * features: { |
| 376 | * invoiceHistory: { enabled: true }, |
| 377 | * }, |
| 378 | * }); |
| 379 | * ``` |
| 380 | * |
| 381 | * **Example:** Customer updates and payment methods |
| 382 | * ```typescript |
| 383 | * const portal = yield* Stripe.BillingPortalConfiguration("customer-portal", { |
| 384 | * name: "Customer portal", |
| 385 | * defaultReturnUrl: "https://example.com/account", |
| 386 | * features: { |
| 387 | * invoiceHistory: { enabled: true }, |
| 388 | * customerUpdate: { |
| 389 | * enabled: true, |
| 390 | * allowedUpdates: ["email", "address"], |
| 391 | * }, |
| 392 | * paymentMethodUpdate: { enabled: true }, |
| 393 | * }, |
| 394 | * metadata: { env: "prod" }, |
| 395 | * }); |
| 396 | * ``` |
| 397 | * |
| 398 | * ### Updating a Configuration |
| 399 | * **Example:** Rename, retag, and enable cancellation |
| 400 | * ```typescript |
| 401 | * const portal = yield* Stripe.BillingPortalConfiguration("customer-portal", { |
| 402 | * name: "Customer portal (updated)", |
| 403 | * features: { |
| 404 | * invoiceHistory: { enabled: true }, |
| 405 | * subscriptionCancel: { enabled: true, mode: "at_period_end" }, |
| 406 | * }, |
| 407 | * metadata: { env: "prod", revision: "2" }, |
| 408 | * }); |
| 409 | * ``` |
| 410 | * |
| 411 | * ### Deactivating a Configuration |
| 412 | * **Example:** Destroy deactivates rather than deleting |
| 413 | * ```typescript |
| 414 | * // stack.destroy() / resource removal sets active: false |
| 415 | * const portal = yield* Stripe.BillingPortalConfiguration("customer-portal", { |
| 416 | * features: { invoiceHistory: { enabled: true } }, |
| 417 | * }); |
| 418 | * ``` |
| 419 | * |
| 420 | * @resource |
| 421 | * @product Billing |
| 422 | */ |
| 423 | export const BillingPortalConfiguration = Resource<BillingPortalConfiguration>( |
| 424 | "Stripe.BillingPortalConfiguration", |
| 425 | ); |
| 426 | |
| 427 | export class BillingPortalConfigurationNotResolved extends Data.TaggedError( |
| 428 | "Stripe.BillingPortalConfigurationNotResolved", |
| 429 | )<{ |
| 430 | configurationId: string | undefined; |
| 431 | }> {} |
| 432 | |
| 433 | type ConfigurationAttributes = BillingPortalConfiguration["Attributes"]; |
| 434 | |
| 435 | const userMetadata = ( |
| 436 | metadata: Record<string, string | undefined> | null | undefined, |
| 437 | ): Record<string, string> => stripInternalMetadata(tagRecord(metadata)); |
| 438 | |
| 439 | const fromObservedFeatures = ( |
| 440 | features: PortalFeatures, |
| 441 | ): BillingPortalFeaturesState => ({ |
| 442 | customerUpdate: { |
| 443 | enabled: features.customer_update.enabled, |
| 444 | allowedUpdates: features.customer_update.allowed_updates, |
| 445 | }, |
| 446 | invoiceHistory: { enabled: features.invoice_history.enabled }, |
| 447 | paymentMethodUpdate: { |
| 448 | enabled: features.payment_method_update.enabled, |
| 449 | paymentMethodConfiguration: |
| 450 | features.payment_method_update.payment_method_configuration ?? undefined, |
| 451 | }, |
| 452 | subscriptionCancel: { |
| 453 | enabled: features.subscription_cancel.enabled, |
| 454 | mode: features.subscription_cancel.mode, |
| 455 | prorationBehavior: features.subscription_cancel.proration_behavior, |
| 456 | cancellationReason: { |
| 457 | enabled: features.subscription_cancel.cancellation_reason.enabled, |
| 458 | options: features.subscription_cancel.cancellation_reason.options, |
| 459 | }, |
| 460 | }, |
| 461 | subscriptionUpdate: { |
| 462 | enabled: features.subscription_update.enabled, |
| 463 | billingCycleAnchor: |
| 464 | features.subscription_update.billing_cycle_anchor ?? undefined, |
| 465 | defaultAllowedUpdates: features.subscription_update.default_allowed_updates, |
| 466 | products: |
| 467 | features.subscription_update.products?.map((product) => ({ |
| 468 | product: product.product, |
| 469 | prices: product.prices, |
| 470 | adjustableQuantity: { |
| 471 | enabled: product.adjustable_quantity.enabled, |
| 472 | maximum: product.adjustable_quantity.maximum ?? undefined, |
| 473 | minimum: product.adjustable_quantity.minimum, |
| 474 | }, |
| 475 | })) ?? undefined, |
| 476 | prorationBehavior: features.subscription_update.proration_behavior, |
| 477 | scheduleAtPeriodEnd: { |
| 478 | conditions: |
| 479 | features.subscription_update.schedule_at_period_end.conditions.map( |
| 480 | (condition) => ({ type: condition.type }), |
| 481 | ), |
| 482 | }, |
| 483 | trialUpdateBehavior: features.subscription_update.trial_update_behavior, |
| 484 | }, |
| 485 | }); |
| 486 | |
| 487 | const toAttrs = ( |
| 488 | configuration: StripeBillingPortalConfiguration, |
| 489 | ): ConfigurationAttributes => ({ |
| 490 | id: configuration.id, |
| 491 | name: configuration.name ?? undefined, |
| 492 | active: configuration.active, |
| 493 | isDefault: configuration.is_default, |
| 494 | defaultReturnUrl: configuration.default_return_url ?? undefined, |
| 495 | businessProfile: { |
| 496 | headline: configuration.business_profile.headline ?? undefined, |
| 497 | privacyPolicyUrl: |
| 498 | configuration.business_profile.privacy_policy_url ?? undefined, |
| 499 | termsOfServiceUrl: |
| 500 | configuration.business_profile.terms_of_service_url ?? undefined, |
| 501 | }, |
| 502 | features: fromObservedFeatures(configuration.features), |
| 503 | loginPage: { |
| 504 | enabled: configuration.login_page.enabled, |
| 505 | url: configuration.login_page.url ?? undefined, |
| 506 | }, |
| 507 | metadata: userMetadata(configuration.metadata), |
| 508 | created: configuration.created, |
| 509 | updated: configuration.updated, |
| 510 | livemode: configuration.livemode, |
| 511 | }); |
| 512 | |
| 513 | const emptyOrList = <T>(value: T[] | undefined): T[] | "" | undefined => { |
| 514 | if (value === undefined) return undefined; |
| 515 | return value.length === 0 ? "" : value; |
| 516 | }; |
| 517 | |
| 518 | const toWireFeatures = ( |
| 519 | features: BillingPortalFeatures, |
| 520 | ): CreateBillingPortalConfigurationRequestFeatures => ({ |
| 521 | ...(features.customerUpdate !== undefined |
| 522 | ? { |
| 523 | customer_update: { |
| 524 | enabled: features.customerUpdate.enabled, |
| 525 | ...(features.customerUpdate.allowedUpdates !== undefined |
| 526 | ? { |
| 527 | allowed_updates: emptyOrList( |
| 528 | features.customerUpdate.allowedUpdates, |
| 529 | ), |
| 530 | } |
| 531 | : {}), |
| 532 | }, |
| 533 | } |
| 534 | : {}), |
| 535 | ...(features.invoiceHistory !== undefined |
| 536 | ? { invoice_history: { enabled: features.invoiceHistory.enabled } } |
| 537 | : {}), |
| 538 | ...(features.paymentMethodUpdate !== undefined |
| 539 | ? { |
| 540 | payment_method_update: { |
| 541 | enabled: features.paymentMethodUpdate.enabled, |
| 542 | ...(features.paymentMethodUpdate.paymentMethodConfiguration !== |
| 543 | undefined |
| 544 | ? { |
| 545 | payment_method_configuration: |
| 546 | features.paymentMethodUpdate.paymentMethodConfiguration, |
| 547 | } |
| 548 | : {}), |
| 549 | }, |
| 550 | } |
| 551 | : {}), |
| 552 | ...(features.subscriptionCancel !== undefined |
| 553 | ? { |
| 554 | subscription_cancel: { |
| 555 | enabled: features.subscriptionCancel.enabled, |
| 556 | ...(features.subscriptionCancel.mode !== undefined |
| 557 | ? { mode: features.subscriptionCancel.mode } |
| 558 | : {}), |
| 559 | ...(features.subscriptionCancel.prorationBehavior !== undefined |
| 560 | ? { |
| 561 | proration_behavior: |
| 562 | features.subscriptionCancel.prorationBehavior, |
| 563 | } |
| 564 | : {}), |
| 565 | ...(features.subscriptionCancel.cancellationReason !== undefined |
| 566 | ? { |
| 567 | cancellation_reason: { |
| 568 | enabled: |
| 569 | features.subscriptionCancel.cancellationReason.enabled, |
| 570 | options: |
| 571 | emptyOrList( |
| 572 | features.subscriptionCancel.cancellationReason.options, |
| 573 | ) ?? "", |
| 574 | }, |
| 575 | } |
| 576 | : {}), |
| 577 | }, |
| 578 | } |
| 579 | : {}), |
| 580 | ...(features.subscriptionUpdate !== undefined |
| 581 | ? { |
| 582 | subscription_update: { |
| 583 | enabled: features.subscriptionUpdate.enabled, |
| 584 | ...(features.subscriptionUpdate.billingCycleAnchor !== undefined |
| 585 | ? { |
| 586 | billing_cycle_anchor: |
| 587 | features.subscriptionUpdate.billingCycleAnchor, |
| 588 | } |
| 589 | : {}), |
| 590 | ...(features.subscriptionUpdate.defaultAllowedUpdates !== undefined |
| 591 | ? { |
| 592 | default_allowed_updates: emptyOrList( |
| 593 | features.subscriptionUpdate.defaultAllowedUpdates, |
| 594 | ), |
| 595 | } |
| 596 | : {}), |
| 597 | ...(features.subscriptionUpdate.products !== undefined |
| 598 | ? { |
| 599 | products: |
| 600 | features.subscriptionUpdate.products.length === 0 |
| 601 | ? "" |
| 602 | : features.subscriptionUpdate.products.map((product) => ({ |
| 603 | product: product.product, |
| 604 | prices: product.prices, |
| 605 | ...(product.adjustableQuantity !== undefined |
| 606 | ? { |
| 607 | adjustable_quantity: { |
| 608 | enabled: product.adjustableQuantity.enabled, |
| 609 | ...(product.adjustableQuantity.maximum !== |
| 610 | undefined |
| 611 | ? { |
| 612 | maximum: |
| 613 | product.adjustableQuantity.maximum, |
| 614 | } |
| 615 | : {}), |
| 616 | ...(product.adjustableQuantity.minimum !== |
| 617 | undefined |
| 618 | ? { |
| 619 | minimum: |
| 620 | product.adjustableQuantity.minimum, |
| 621 | } |
| 622 | : {}), |
| 623 | }, |
| 624 | } |
| 625 | : {}), |
| 626 | })), |
| 627 | } |
| 628 | : {}), |
| 629 | ...(features.subscriptionUpdate.prorationBehavior !== undefined |
| 630 | ? { |
| 631 | proration_behavior: |
| 632 | features.subscriptionUpdate.prorationBehavior, |
| 633 | } |
| 634 | : {}), |
| 635 | ...(features.subscriptionUpdate.scheduleAtPeriodEnd !== undefined |
| 636 | ? { |
| 637 | schedule_at_period_end: { |
| 638 | ...(features.subscriptionUpdate.scheduleAtPeriodEnd |
| 639 | .conditions !== undefined |
| 640 | ? { |
| 641 | conditions: |
| 642 | features.subscriptionUpdate.scheduleAtPeriodEnd |
| 643 | .conditions, |
| 644 | } |
| 645 | : {}), |
| 646 | }, |
| 647 | } |
| 648 | : {}), |
| 649 | ...(features.subscriptionUpdate.trialUpdateBehavior !== undefined |
| 650 | ? { |
| 651 | trial_update_behavior: |
| 652 | features.subscriptionUpdate.trialUpdateBehavior, |
| 653 | } |
| 654 | : {}), |
| 655 | }, |
| 656 | } |
| 657 | : {}), |
| 658 | }); |
| 659 | |
| 660 | const pickSpecified = (desired: unknown, observed: unknown): unknown => { |
| 661 | if (desired === undefined) return undefined; |
| 662 | if (Array.isArray(desired)) { |
| 663 | if (!Array.isArray(observed)) return observed; |
| 664 | return desired.map((item, index) => pickSpecified(item, observed[index])); |
| 665 | } |
| 666 | if ( |
| 667 | desired !== null && |
| 668 | typeof desired === "object" && |
| 669 | observed !== null && |
| 670 | typeof observed === "object" |
| 671 | ) { |
| 672 | const out: Record<string, unknown> = {}; |
| 673 | for (const key of Object.keys(desired as Record<string, unknown>)) { |
| 674 | out[key] = pickSpecified( |
| 675 | (desired as Record<string, unknown>)[key], |
| 676 | (observed as Record<string, unknown>)[key], |
| 677 | ); |
| 678 | } |
| 679 | return out; |
| 680 | } |
| 681 | return observed; |
| 682 | }; |
| 683 | |
| 684 | const featuresNeedSync = ( |
| 685 | desired: BillingPortalFeatures, |
| 686 | observed: BillingPortalFeaturesState, |
| 687 | ): boolean => |
| 688 | !deepEqual(desired, pickSpecified(desired, observed), { |
| 689 | stripNullish: true, |
| 690 | }); |
| 691 | |
| 692 | const businessProfileNeedSync = ( |
| 693 | desired: BillingPortalBusinessProfile | undefined, |
| 694 | observed: ConfigurationAttributes["businessProfile"], |
| 695 | ): boolean => { |
| 696 | if (desired === undefined) return false; |
| 697 | return !deepEqual(desired, pickSpecified(desired, observed), { |
| 698 | stripNullish: true, |
| 699 | }); |
| 700 | }; |
| 701 | |
| 702 | const toWireBusinessProfile = (profile: BillingPortalBusinessProfile) => ({ |
| 703 | ...(profile.headline !== undefined ? { headline: profile.headline } : {}), |
| 704 | ...(profile.privacyPolicyUrl !== undefined |
| 705 | ? { privacy_policy_url: profile.privacyPolicyUrl } |
| 706 | : {}), |
| 707 | ...(profile.termsOfServiceUrl !== undefined |
| 708 | ? { terms_of_service_url: profile.termsOfServiceUrl } |
| 709 | : {}), |
| 710 | }); |
| 711 | |
| 712 | const isMissingConfiguration = isMissingStripeResource; |
| 713 | |
| 714 | const getById = (configuration: string) => |
| 715 | GetBillingPortalConfiguration({ configuration }).pipe( |
| 716 | Effect.catchIf(isMissingConfiguration, () => Effect.succeed(undefined)), |
| 717 | ); |
| 718 | |
| 719 | const listByActive = Effect.fn(function* (active: boolean) { |
| 720 | const configurations: StripeBillingPortalConfiguration[] = []; |
| 721 | let startingAfter: string | undefined; |
| 722 | for (let page = 0; page < LIST_MAX_PAGES; page++) { |
| 723 | const response = yield* GetBillingPortalConfigurations({ |
| 724 | active, |
| 725 | limit: LIST_PAGE_SIZE, |
| 726 | ...(startingAfter !== undefined ? { starting_after: startingAfter } : {}), |
| 727 | }); |
| 728 | configurations.push(...response.data); |
| 729 | if (!response.has_more || response.data.length === 0) { |
| 730 | break; |
| 731 | } |
| 732 | startingAfter = response.data[response.data.length - 1]?.id; |
| 733 | if (startingAfter === undefined) { |
| 734 | break; |
| 735 | } |
| 736 | } |
| 737 | return configurations; |
| 738 | }); |
| 739 | |
| 740 | const listAllConfigurations = Effect.fn(function* () { |
| 741 | const [active, inactive] = yield* Effect.all( |
| 742 | [listByActive(true), listByActive(false)], |
| 743 | { concurrency: 2 }, |
| 744 | ); |
| 745 | const seen = new Set<string>(); |
| 746 | const configurations: StripeBillingPortalConfiguration[] = []; |
| 747 | for (const configuration of [...active, ...inactive]) { |
| 748 | if (seen.has(configuration.id)) continue; |
| 749 | seen.add(configuration.id); |
| 750 | configurations.push(configuration); |
| 751 | } |
| 752 | return configurations; |
| 753 | }); |
| 754 | |
| 755 | const findByAlchemyId = Effect.fn(function* (id: string) { |
| 756 | const configurations = yield* listAllConfigurations(); |
| 757 | const matches: StripeBillingPortalConfiguration[] = []; |
| 758 | for (const configuration of configurations) { |
| 759 | if (yield* hasAlchemyMetadata(id, tagRecord(configuration.metadata))) { |
| 760 | matches.push(configuration); |
| 761 | } |
| 762 | } |
| 763 | matches.sort((a, b) => b.created - a.created); |
| 764 | return matches[0]; |
| 765 | }); |
| 766 | |
| 767 | const observe = Effect.fn(function* (input: { |
| 768 | id?: string; |
| 769 | logicalId: string; |
| 770 | }) { |
| 771 | if (input.id !== undefined) { |
| 772 | const byId = yield* getById(input.id); |
| 773 | if (byId !== undefined) return byId; |
| 774 | } |
| 775 | return yield* findByAlchemyId(input.logicalId); |
| 776 | }); |
| 777 | |
| 778 | const desiredMetadata = Effect.fn(function* ( |
| 779 | id: string, |
| 780 | metadata: Record<string, string> | undefined, |
| 781 | ) { |
| 782 | return { |
| 783 | ...toMetadata(metadata), |
| 784 | ...(yield* createInternalMetadata(id)), |
| 785 | }; |
| 786 | }); |
| 787 | |
| 788 | export const BillingPortalConfigurationProvider = () => |
| 789 | Provider.succeed(BillingPortalConfiguration, { |
| 790 | stables: ["id", "created", "livemode", "isDefault"], |
| 791 | |
| 792 | diff: Effect.fn(function* ({ news }) { |
| 793 | if (!isResolved(news)) return undefined; |
| 794 | return undefined; |
| 795 | }), |
| 796 | |
| 797 | read: Effect.fn(function* ({ id, output }) { |
| 798 | const existing = yield* observe({ |
| 799 | id: output?.id, |
| 800 | logicalId: id, |
| 801 | }); |
| 802 | if (existing === undefined) return undefined; |
| 803 | const attrs = toAttrs(existing); |
| 804 | return (yield* hasAlchemyMetadata(id, tagRecord(existing.metadata))) |
| 805 | ? attrs |
| 806 | : Unowned(attrs); |
| 807 | }), |
| 808 | |
| 809 | list: Effect.fn(function* () { |
| 810 | const configurations = yield* listAllConfigurations(); |
| 811 | return configurations |
| 812 | .filter((configuration) => { |
| 813 | const metadata = tagRecord(configuration.metadata); |
| 814 | return metadata[alchemyMetadataKeys.stack] !== undefined; |
| 815 | }) |
| 816 | .map(toAttrs); |
| 817 | }), |
| 818 | |
| 819 | reconcile: Effect.fn(function* ({ id, news, output, instanceId }) { |
| 820 | const metadata = yield* desiredMetadata(id, news.metadata); |
| 821 | const desiredActive = news.active ?? true; |
| 822 | const wireFeatures = toWireFeatures(news.features); |
| 823 | |
| 824 | let current = yield* observe({ |
| 825 | id: output?.id, |
| 826 | logicalId: id, |
| 827 | }); |
| 828 | |
| 829 | if (current === undefined) { |
| 830 | current = yield* CreateBillingPortalConfiguration({ |
| 831 | features: wireFeatures, |
| 832 | metadata, |
| 833 | ...(news.name !== undefined ? { name: news.name } : {}), |
| 834 | ...(news.defaultReturnUrl !== undefined |
| 835 | ? { default_return_url: news.defaultReturnUrl } |
| 836 | : {}), |
| 837 | ...(news.businessProfile !== undefined |
| 838 | ? { business_profile: toWireBusinessProfile(news.businessProfile) } |
| 839 | : {}), |
| 840 | ...(news.loginPage !== undefined |
| 841 | ? { login_page: { enabled: news.loginPage.enabled } } |
| 842 | : {}), |
| 843 | }).pipe( |
| 844 | withRequestOptions({ |
| 845 | idempotencyKey: `alchemy-billing-portal-configuration-${instanceId}`, |
| 846 | }), |
| 847 | ); |
| 848 | } |
| 849 | |
| 850 | if (current === undefined) { |
| 851 | return yield* new BillingPortalConfigurationNotResolved({ |
| 852 | configurationId: output?.id, |
| 853 | }); |
| 854 | } |
| 855 | |
| 856 | const observedMetadata = tagRecord(current.metadata); |
| 857 | const { upsert, removed } = diffMetadata(observedMetadata, metadata); |
| 858 | const metadataChanged = upsert.length > 0 || removed.length > 0; |
| 859 | const attrs = toAttrs(current); |
| 860 | const activeChanged = current.active !== desiredActive; |
| 861 | const nameChanged = |
| 862 | news.name !== undefined && (current.name ?? "") !== news.name; |
| 863 | const defaultReturnUrlChanged = |
| 864 | news.defaultReturnUrl !== undefined && |
| 865 | (current.default_return_url ?? "") !== news.defaultReturnUrl; |
| 866 | const loginPageChanged = |
| 867 | news.loginPage !== undefined && |
| 868 | current.login_page.enabled !== news.loginPage.enabled; |
| 869 | const featuresChanged = featuresNeedSync(news.features, attrs.features); |
| 870 | const businessProfileChanged = businessProfileNeedSync( |
| 871 | news.businessProfile, |
| 872 | attrs.businessProfile, |
| 873 | ); |
| 874 | |
| 875 | if ( |
| 876 | !activeChanged && |
| 877 | !nameChanged && |
| 878 | !defaultReturnUrlChanged && |
| 879 | !loginPageChanged && |
| 880 | !featuresChanged && |
| 881 | !businessProfileChanged && |
| 882 | !metadataChanged |
| 883 | ) { |
| 884 | return attrs; |
| 885 | } |
| 886 | |
| 887 | const updated = yield* UpdateBillingPortalConfiguration({ |
| 888 | configuration: current.id, |
| 889 | ...(activeChanged ? { active: desiredActive } : {}), |
| 890 | ...(nameChanged ? { name: news.name } : {}), |
| 891 | ...(defaultReturnUrlChanged |
| 892 | ? { default_return_url: news.defaultReturnUrl } |
| 893 | : {}), |
| 894 | ...(loginPageChanged && news.loginPage !== undefined |
| 895 | ? { login_page: { enabled: news.loginPage.enabled } } |
| 896 | : {}), |
| 897 | ...(featuresChanged ? { features: wireFeatures } : {}), |
| 898 | ...(businessProfileChanged && news.businessProfile !== undefined |
| 899 | ? { business_profile: toWireBusinessProfile(news.businessProfile) } |
| 900 | : {}), |
| 901 | ...(metadataChanged |
| 902 | ? { |
| 903 | metadata: { |
| 904 | ...Object.fromEntries(removed.map((key) => [key, ""])), |
| 905 | ...metadata, |
| 906 | }, |
| 907 | } |
| 908 | : {}), |
| 909 | }); |
| 910 | return toAttrs(updated); |
| 911 | }), |
| 912 | |
| 913 | delete: Effect.fn(function* ({ output }) { |
| 914 | const existing = yield* getById(output.id); |
| 915 | if (existing === undefined || !existing.active) return; |
| 916 | yield* UpdateBillingPortalConfiguration({ |
| 917 | configuration: existing.id, |
| 918 | active: false, |
| 919 | }).pipe(Effect.catchIf(isMissingConfiguration, () => Effect.void)); |
| 920 | }), |
| 921 | }); |