1import { withRequestOptions } from "@distilled.cloud/stripe";
2import {
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";
11import * as Data from "effect/Data";
12import * as Effect from "effect/Effect";
13import { Unowned } from "../AdoptPolicy.ts";
14import { deepEqual, isResolved } from "../Diff.ts";
15import * as Provider from "../Provider.ts";
16import { Resource } from "../Resource.ts";
17import { tagRecord } from "../Tags.ts";
18import {
19 alchemyMetadataKeys,
20 createInternalMetadata,
21 diffMetadata,
22 hasAlchemyMetadata,
23 stripInternalMetadata,
24 toMetadata,
25} from "./Metadata.ts";
26import type { Providers } from "./Providers.ts";
27import { isMissingStripeResource } from "./missing.ts";
28
29const LIST_PAGE_SIZE = 100;
30const LIST_MAX_PAGES = 100;
31
32export type CustomerUpdateAllowedUpdate =
33 | "address"
34 | "email"
35 | "name"
36 | "phone"
37 | "shipping"
38 | "tax_id";
39
40export type SubscriptionCancelMode = "at_period_end" | "immediately";
41
42export type SubscriptionCancelProrationBehavior =
43 | "always_invoice"
44 | "create_prorations"
45 | "none";
46
47export 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
57export type SubscriptionUpdateAllowedUpdate =
58 | "price"
59 | "promotion_code"
60 | "quantity";
61
62export type SubscriptionUpdateBillingCycleAnchor = "now" | "unchanged";
63
64export type SubscriptionUpdateProrationBehavior =
65 | "always_invoice"
66 | "create_prorations"
67 | "none";
68
69export type SubscriptionUpdateTrialBehavior = "continue_trial" | "end_trial";
70
71export type ScheduleAtPeriodEndConditionType =
72 | "decreasing_item_amount"
73 | "shortening_interval";
74
75export 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
86export interface BillingPortalInvoiceHistory {
87 /**
88 * Whether the portal shows invoice history.
89 */
90 enabled: boolean;
91}
92
93export 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
105export 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
116export 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
136export 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
151export 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
166export interface BillingPortalScheduleAtPeriodEnd {
167 /**
168 * Conditions that schedule an update for the end of the current period.
169 */
170 conditions?: { type: ScheduleAtPeriodEndConditionType }[];
171}
172
173export 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
205export 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
228export 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
243export interface BillingPortalLoginPage {
244 /**
245 * When true, Stripe generates a shareable hosted login URL.
246 */
247 enabled: boolean;
248}
249
250export 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
286export 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
323export 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 */
423export const BillingPortalConfiguration = Resource<BillingPortalConfiguration>(
424 "Stripe.BillingPortalConfiguration",
425);
426
427export class BillingPortalConfigurationNotResolved extends Data.TaggedError(
428 "Stripe.BillingPortalConfigurationNotResolved",
429)<{
430 configurationId: string | undefined;
431}> {}
432
433type ConfigurationAttributes = BillingPortalConfiguration["Attributes"];
434
435const userMetadata = (
436 metadata: Record<string, string | undefined> | null | undefined,
437): Record<string, string> => stripInternalMetadata(tagRecord(metadata));
438
439const 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
487const 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
513const emptyOrList = <T>(value: T[] | undefined): T[] | "" | undefined => {
514 if (value === undefined) return undefined;
515 return value.length === 0 ? "" : value;
516};
517
518const 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
660const 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
684const featuresNeedSync = (
685 desired: BillingPortalFeatures,
686 observed: BillingPortalFeaturesState,
687): boolean =>
688 !deepEqual(desired, pickSpecified(desired, observed), {
689 stripNullish: true,
690 });
691
692const 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
702const 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
712const isMissingConfiguration = isMissingStripeResource;
713
714const getById = (configuration: string) =>
715 GetBillingPortalConfiguration({ configuration }).pipe(
716 Effect.catchIf(isMissingConfiguration, () => Effect.succeed(undefined)),
717 );
718
719const 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
740const 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
755const 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
767const 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
778const 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
788export 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 });