| 1 | import { |
| 2 | withRequestOptions, |
| 3 | type StripeOpError, |
| 4 | } from "@distilled.cloud/stripe"; |
| 5 | import { |
| 6 | GetBillingCreditGrants, |
| 7 | GetBillingCreditGrant, |
| 8 | CreateBillingCreditGrant, |
| 9 | UpdateBillingCreditGrant, |
| 10 | CreateBillingCreditGrantVoid, |
| 11 | type BillingCreditGrant as StripeCreditGrant, |
| 12 | type CreateBillingCreditGrantRequestAmount, |
| 13 | type CreateBillingCreditGrantRequestApplicabilityConfig, |
| 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 { createPhysicalName } from "../PhysicalName.ts"; |
| 20 | import * as Provider from "../Provider.ts"; |
| 21 | import { Resource } from "../Resource.ts"; |
| 22 | import { tagRecord } from "../Tags.ts"; |
| 23 | import { |
| 24 | alchemyMetadataKeys, |
| 25 | createInternalMetadata, |
| 26 | diffMetadata, |
| 27 | hasAlchemyMetadata, |
| 28 | stripInternalMetadata, |
| 29 | toMetadata, |
| 30 | } from "./Metadata.ts"; |
| 31 | import type { Providers } from "./Providers.ts"; |
| 32 | import { isMissingStripeResource } from "./missing.ts"; |
| 33 | |
| 34 | const NAME_MAX_LENGTH = 250; |
| 35 | const LIST_PAGE_SIZE = 100; |
| 36 | const LIST_MAX_PAGES = 100; |
| 37 | |
| 38 | /** The type of this amount. Stripe currently only supports monetary credits. */ |
| 39 | export type CreditGrantAmountType = "monetary"; |
| 40 | |
| 41 | /** The category of this credit grant. Tracking only — not shown to customers. */ |
| 42 | export type CreditGrantCategory = "paid" | "promotional"; |
| 43 | |
| 44 | /** |
| 45 | * The price type that credit grants can apply to. Stripe currently only |
| 46 | * supports metered prices (prices with a Billing Meter). |
| 47 | */ |
| 48 | export type CreditGrantPriceType = "metered"; |
| 49 | |
| 50 | export interface CreditGrantMonetaryAmount { |
| 51 | /** |
| 52 | * Three-letter ISO currency code, lowercase (e.g. `"usd"`). |
| 53 | */ |
| 54 | currency: string; |
| 55 | /** |
| 56 | * Positive integer amount in the currency's minor units. |
| 57 | */ |
| 58 | value: number; |
| 59 | } |
| 60 | |
| 61 | export interface CreditGrantAmount { |
| 62 | /** |
| 63 | * Amount type. Stripe currently only supports `"monetary"`. |
| 64 | */ |
| 65 | type: CreditGrantAmountType; |
| 66 | /** |
| 67 | * Monetary amount. Required when `type` is `"monetary"`. Create-only — |
| 68 | * changing it replaces the grant. |
| 69 | */ |
| 70 | monetary?: CreditGrantMonetaryAmount; |
| 71 | } |
| 72 | |
| 73 | export interface CreditGrantApplicablePrice { |
| 74 | /** |
| 75 | * Stripe Price id (`price_…`) this grant can apply to. Metered prices |
| 76 | * only. Cannot be combined with `priceType`. |
| 77 | */ |
| 78 | id: string; |
| 79 | } |
| 80 | |
| 81 | export interface CreditGrantScope { |
| 82 | /** |
| 83 | * Price type this grant applies to. Cannot be combined with `prices`. |
| 84 | * @default "metered" |
| 85 | */ |
| 86 | priceType?: CreditGrantPriceType; |
| 87 | /** |
| 88 | * Specific metered prices this grant applies to (max 20). Cannot be |
| 89 | * combined with `priceType`. |
| 90 | */ |
| 91 | prices?: CreditGrantApplicablePrice[]; |
| 92 | } |
| 93 | |
| 94 | export interface CreditGrantApplicabilityConfig { |
| 95 | /** |
| 96 | * Scope of prices this credit grant can apply to. |
| 97 | */ |
| 98 | scope: CreditGrantScope; |
| 99 | } |
| 100 | |
| 101 | export interface CreditGrantProps { |
| 102 | /** |
| 103 | * Id of the customer receiving the billing credits (`cus_…`). Required |
| 104 | * unless `customerAccount` is set. Create-only — changing it replaces |
| 105 | * the grant. |
| 106 | */ |
| 107 | customer?: string; |
| 108 | /** |
| 109 | * Id of the account representing the customer receiving the billing |
| 110 | * credits. Required unless `customer` is set. Create-only. |
| 111 | */ |
| 112 | customerAccount?: string; |
| 113 | /** |
| 114 | * Amount of this credit grant. Create-only — changing it replaces the |
| 115 | * grant. |
| 116 | */ |
| 117 | amount: CreditGrantAmount; |
| 118 | /** |
| 119 | * What this credit grant applies to. Stripe currently only supports |
| 120 | * metered prices that have a Billing Meter. Create-only. |
| 121 | * @default { scope: { priceType: "metered" } } |
| 122 | */ |
| 123 | applicabilityConfig?: CreditGrantApplicabilityConfig; |
| 124 | /** |
| 125 | * Category of this credit grant. Tracking only — not shown to the |
| 126 | * customer. Create-only. |
| 127 | * @default "paid" |
| 128 | */ |
| 129 | category?: CreditGrantCategory; |
| 130 | /** |
| 131 | * Descriptive name shown in the Stripe Dashboard. If omitted, a unique |
| 132 | * name is generated from the stack, stage, and logical id. Create-only |
| 133 | * (the update API does not accept `name`). |
| 134 | */ |
| 135 | name?: string; |
| 136 | /** |
| 137 | * Unix timestamp when the credits become eligible for use. Defaults to |
| 138 | * the current time. Create-only. |
| 139 | */ |
| 140 | effectiveAt?: number; |
| 141 | /** |
| 142 | * Unix timestamp when the credits expire. If omitted, they never expire. |
| 143 | * Mutable — pass a new timestamp to update. |
| 144 | */ |
| 145 | expiresAt?: number; |
| 146 | /** |
| 147 | * Priority for applying this grant (0 highest, 100 lowest). |
| 148 | * @default 50 |
| 149 | */ |
| 150 | priority?: number; |
| 151 | /** |
| 152 | * User-defined metadata. Alchemy ownership keys (`alchemy_stack` / |
| 153 | * `alchemy_stage` / `alchemy_id`) are merged in automatically. Keys may |
| 154 | * not contain `:`. Mutable. |
| 155 | */ |
| 156 | metadata?: Record<string, string>; |
| 157 | } |
| 158 | |
| 159 | export type CreditGrant = Resource< |
| 160 | "Stripe.CreditGrant", |
| 161 | CreditGrantProps, |
| 162 | { |
| 163 | /** Stripe credit grant id (`credgr_…`). */ |
| 164 | id: string; |
| 165 | /** Dashboard name, if set. */ |
| 166 | name: string | undefined; |
| 167 | /** Customer id receiving the credits (`cus_…`). */ |
| 168 | customer: string | undefined; |
| 169 | /** Customer-account id receiving the credits, if set. */ |
| 170 | customerAccount: string | undefined; |
| 171 | /** Amount of this credit grant. */ |
| 172 | amount: CreditGrantAmount; |
| 173 | /** What this credit grant applies to. */ |
| 174 | applicabilityConfig: CreditGrantApplicabilityConfig; |
| 175 | /** Category of this credit grant. */ |
| 176 | category: CreditGrantCategory; |
| 177 | /** Unix timestamp when the credits become eligible for use. */ |
| 178 | effectiveAt: number | undefined; |
| 179 | /** Unix timestamp when the credits expire, if they expire. */ |
| 180 | expiresAt: number | undefined; |
| 181 | /** Priority for applying this grant (0 highest, 100 lowest). */ |
| 182 | priority: number | undefined; |
| 183 | /** Unix timestamp when this grant was voided, if voided. */ |
| 184 | voidedAt: number | undefined; |
| 185 | /** Unix timestamp when the grant was created. */ |
| 186 | created: number; |
| 187 | /** Unix timestamp when the grant was last updated. */ |
| 188 | updated: number; |
| 189 | /** Whether the grant exists in live mode. */ |
| 190 | livemode: boolean; |
| 191 | /** User-defined metadata (Alchemy ownership keys stripped). */ |
| 192 | metadata: Record<string, string>; |
| 193 | }, |
| 194 | never, |
| 195 | Providers |
| 196 | >; |
| 197 | |
| 198 | /** |
| 199 | * A Stripe Credit Grant — prepaid or promotional billing credits allocated |
| 200 | * to a customer and applied against metered prices. Amount, customer, |
| 201 | * applicability, category, effective time, and priority are immutable; |
| 202 | * changing them replaces the grant. `expiresAt` and `metadata` update in |
| 203 | * place. Credit grants cannot be hard-deleted: destroying this resource |
| 204 | * voids the grant (`voided_at` is set). Already-voided grants are treated |
| 205 | * as success. |
| 206 | * |
| 207 | * @see https://docs.stripe.com/api/billing/credit-grant |
| 208 | * |
| 209 | * ### Creating a Credit Grant |
| 210 | * **Example:** Promotional credits for metered prices |
| 211 | * ```typescript |
| 212 | * const customer = yield* Stripe.Customer("alice", { |
| 213 | * email: "alice@example.com", |
| 214 | * }); |
| 215 | * const grant = yield* Stripe.CreditGrant("welcome-credits", { |
| 216 | * customer: customer.id, |
| 217 | * amount: { type: "monetary", monetary: { currency: "usd", value: 1000 } }, |
| 218 | * applicabilityConfig: { scope: { priceType: "metered" } }, |
| 219 | * category: "promotional", |
| 220 | * name: "Welcome credits", |
| 221 | * }); |
| 222 | * ``` |
| 223 | * |
| 224 | * **Example:** Paid credits scoped to specific prices |
| 225 | * ```typescript |
| 226 | * const grant = yield* Stripe.CreditGrant("prepaid", { |
| 227 | * customer: customer.id, |
| 228 | * amount: { type: "monetary", monetary: { currency: "usd", value: 5000 } }, |
| 229 | * applicabilityConfig: { |
| 230 | * scope: { prices: [{ id: price.id }] }, |
| 231 | * }, |
| 232 | * category: "paid", |
| 233 | * }); |
| 234 | * ``` |
| 235 | * |
| 236 | * ### Updating a Credit Grant |
| 237 | * **Example:** Extend expiry and retag |
| 238 | * ```typescript |
| 239 | * const grant = yield* Stripe.CreditGrant("welcome-credits", { |
| 240 | * customer: customer.id, |
| 241 | * amount: { type: "monetary", monetary: { currency: "usd", value: 1000 } }, |
| 242 | * expiresAt: 4102444800, |
| 243 | * metadata: { campaign: "spring" }, |
| 244 | * }); |
| 245 | * ``` |
| 246 | * |
| 247 | * ### Voiding a Credit Grant |
| 248 | * **Example:** Destroy voids rather than deleting |
| 249 | * ```typescript |
| 250 | * // stack.destroy() / resource removal calls void |
| 251 | * const grant = yield* Stripe.CreditGrant("welcome-credits", { |
| 252 | * customer: customer.id, |
| 253 | * amount: { type: "monetary", monetary: { currency: "usd", value: 1000 } }, |
| 254 | * }); |
| 255 | * ``` |
| 256 | * |
| 257 | * @resource |
| 258 | * @product Billing |
| 259 | */ |
| 260 | export const CreditGrant = Resource<CreditGrant>("Stripe.CreditGrant"); |
| 261 | |
| 262 | export class CreditGrantNotResolved extends Data.TaggedError( |
| 263 | "Stripe.CreditGrantNotResolved", |
| 264 | )<{ |
| 265 | customer: string | undefined; |
| 266 | }> {} |
| 267 | |
| 268 | type CreditGrantAttributes = CreditGrant["Attributes"]; |
| 269 | |
| 270 | const DEFAULT_APPLICABILITY: CreditGrantApplicabilityConfig = { |
| 271 | scope: { priceType: "metered" }, |
| 272 | }; |
| 273 | |
| 274 | const userMetadata = ( |
| 275 | metadata: Record<string, string | undefined> | null | undefined, |
| 276 | ): Record<string, string> => stripInternalMetadata(tagRecord(metadata)); |
| 277 | |
| 278 | const idOf = (value: unknown): string | undefined => { |
| 279 | if (typeof value === "string") return value; |
| 280 | if (value !== null && typeof value === "object" && "id" in value) { |
| 281 | const id = (value as { id?: unknown }).id; |
| 282 | if (typeof id === "string") return id; |
| 283 | } |
| 284 | return undefined; |
| 285 | }; |
| 286 | |
| 287 | const isVoided = (grant: StripeCreditGrant): boolean => grant.voided_at != null; |
| 288 | |
| 289 | const fromWireAmount = ( |
| 290 | amount: StripeCreditGrant["amount"], |
| 291 | ): CreditGrantAmount => ({ |
| 292 | type: amount.type, |
| 293 | ...(amount.monetary != null |
| 294 | ? { |
| 295 | monetary: { |
| 296 | currency: amount.monetary.currency, |
| 297 | value: amount.monetary.value, |
| 298 | }, |
| 299 | } |
| 300 | : {}), |
| 301 | }); |
| 302 | |
| 303 | const toWireAmount = ( |
| 304 | amount: CreditGrantAmount, |
| 305 | ): CreateBillingCreditGrantRequestAmount => ({ |
| 306 | type: amount.type, |
| 307 | ...(amount.monetary !== undefined |
| 308 | ? { |
| 309 | monetary: { |
| 310 | currency: amount.monetary.currency, |
| 311 | value: amount.monetary.value, |
| 312 | }, |
| 313 | } |
| 314 | : {}), |
| 315 | }); |
| 316 | |
| 317 | const fromWireApplicability = ( |
| 318 | config: StripeCreditGrant["applicability_config"], |
| 319 | ): CreditGrantApplicabilityConfig => { |
| 320 | const prices = config.scope.prices |
| 321 | ?.map((price) => (price.id != null ? { id: price.id } : undefined)) |
| 322 | .filter( |
| 323 | (price): price is CreditGrantApplicablePrice => price !== undefined, |
| 324 | ); |
| 325 | return { |
| 326 | scope: { |
| 327 | ...(config.scope.price_type !== undefined |
| 328 | ? { priceType: config.scope.price_type } |
| 329 | : {}), |
| 330 | ...(prices !== undefined && prices.length > 0 ? { prices } : {}), |
| 331 | }, |
| 332 | }; |
| 333 | }; |
| 334 | |
| 335 | const toWireApplicability = ( |
| 336 | config: CreditGrantApplicabilityConfig, |
| 337 | ): CreateBillingCreditGrantRequestApplicabilityConfig => ({ |
| 338 | scope: { |
| 339 | ...(config.scope.priceType !== undefined |
| 340 | ? { price_type: config.scope.priceType } |
| 341 | : {}), |
| 342 | ...(config.scope.prices !== undefined |
| 343 | ? { prices: config.scope.prices.map((price) => ({ id: price.id })) } |
| 344 | : {}), |
| 345 | }, |
| 346 | }); |
| 347 | |
| 348 | const toAttrs = (grant: StripeCreditGrant): CreditGrantAttributes => ({ |
| 349 | id: grant.id, |
| 350 | name: grant.name ?? undefined, |
| 351 | customer: idOf(grant.customer), |
| 352 | customerAccount: grant.customer_account ?? undefined, |
| 353 | amount: fromWireAmount(grant.amount), |
| 354 | applicabilityConfig: fromWireApplicability(grant.applicability_config), |
| 355 | category: grant.category, |
| 356 | effectiveAt: grant.effective_at ?? undefined, |
| 357 | expiresAt: grant.expires_at ?? undefined, |
| 358 | priority: grant.priority ?? undefined, |
| 359 | voidedAt: grant.voided_at ?? undefined, |
| 360 | created: grant.created, |
| 361 | updated: grant.updated, |
| 362 | livemode: grant.livemode, |
| 363 | metadata: userMetadata(grant.metadata), |
| 364 | }); |
| 365 | |
| 366 | const toName = (id: string, name: string | undefined, existing?: string) => |
| 367 | Effect.gen(function* () { |
| 368 | return ( |
| 369 | name ?? |
| 370 | existing ?? |
| 371 | (yield* createPhysicalName({ id, maxLength: NAME_MAX_LENGTH })) |
| 372 | ); |
| 373 | }); |
| 374 | |
| 375 | const isMissingGrant = isMissingStripeResource; |
| 376 | |
| 377 | const alreadyVoided = (error: StripeOpError): boolean => |
| 378 | error._tag === "InvalidRequestError" && |
| 379 | (error.message?.toLowerCase().includes("void") ?? false); |
| 380 | |
| 381 | const getById = (id: string) => |
| 382 | GetBillingCreditGrant({ id }).pipe( |
| 383 | Effect.catchIf(isMissingGrant, () => Effect.succeed(undefined)), |
| 384 | ); |
| 385 | |
| 386 | const listAllCreditGrants = Effect.fn(function* () { |
| 387 | const grants: StripeCreditGrant[] = []; |
| 388 | let startingAfter: string | undefined; |
| 389 | for (let page = 0; page < LIST_MAX_PAGES; page++) { |
| 390 | const response = yield* GetBillingCreditGrants({ |
| 391 | limit: LIST_PAGE_SIZE, |
| 392 | ...(startingAfter !== undefined ? { starting_after: startingAfter } : {}), |
| 393 | }); |
| 394 | grants.push(...response.data); |
| 395 | if (!response.has_more || response.data.length === 0) { |
| 396 | break; |
| 397 | } |
| 398 | startingAfter = response.data[response.data.length - 1]?.id; |
| 399 | if (startingAfter === undefined) { |
| 400 | break; |
| 401 | } |
| 402 | } |
| 403 | return grants; |
| 404 | }); |
| 405 | |
| 406 | const findByAlchemyId = Effect.fn(function* (id: string) { |
| 407 | const grants = yield* listAllCreditGrants(); |
| 408 | const matches: StripeCreditGrant[] = []; |
| 409 | for (const grant of grants) { |
| 410 | if (isVoided(grant)) continue; |
| 411 | if (yield* hasAlchemyMetadata(id, tagRecord(grant.metadata))) { |
| 412 | matches.push(grant); |
| 413 | } |
| 414 | } |
| 415 | matches.sort((a, b) => b.created - a.created); |
| 416 | return matches[0]; |
| 417 | }); |
| 418 | |
| 419 | const observe = Effect.fn(function* (input: { |
| 420 | id?: string; |
| 421 | logicalId: string; |
| 422 | }) { |
| 423 | if (input.id !== undefined) { |
| 424 | const byId = yield* getById(input.id); |
| 425 | if (byId !== undefined && !isVoided(byId)) return byId; |
| 426 | } |
| 427 | return yield* findByAlchemyId(input.logicalId); |
| 428 | }); |
| 429 | |
| 430 | const desiredMetadata = Effect.fn(function* ( |
| 431 | id: string, |
| 432 | metadata: Record<string, string> | undefined, |
| 433 | ) { |
| 434 | return { |
| 435 | ...toMetadata(metadata), |
| 436 | ...(yield* createInternalMetadata(id)), |
| 437 | }; |
| 438 | }); |
| 439 | |
| 440 | const replaceOnCreateOnlyChange = ( |
| 441 | olds: CreditGrantProps | undefined, |
| 442 | news: CreditGrantProps, |
| 443 | output: CreditGrantAttributes | undefined, |
| 444 | ): boolean => { |
| 445 | if (output === undefined && olds === undefined) return false; |
| 446 | if ( |
| 447 | !deepEqual(news.amount, output?.amount ?? olds?.amount, { |
| 448 | stripNullish: true, |
| 449 | }) |
| 450 | ) { |
| 451 | return true; |
| 452 | } |
| 453 | const desiredApplicability = |
| 454 | news.applicabilityConfig ?? DEFAULT_APPLICABILITY; |
| 455 | const previousApplicability = |
| 456 | output?.applicabilityConfig ?? olds?.applicabilityConfig; |
| 457 | if ( |
| 458 | previousApplicability !== undefined && |
| 459 | !deepEqual(desiredApplicability, previousApplicability, { |
| 460 | stripNullish: true, |
| 461 | }) |
| 462 | ) { |
| 463 | return true; |
| 464 | } |
| 465 | const previousCategory = output?.category ?? olds?.category ?? "paid"; |
| 466 | if ((news.category ?? previousCategory) !== previousCategory) { |
| 467 | return true; |
| 468 | } |
| 469 | const previousCustomer = output?.customer ?? olds?.customer; |
| 470 | if ( |
| 471 | news.customer !== undefined && |
| 472 | previousCustomer !== undefined && |
| 473 | news.customer !== previousCustomer |
| 474 | ) { |
| 475 | return true; |
| 476 | } |
| 477 | const previousAccount = output?.customerAccount ?? olds?.customerAccount; |
| 478 | if ( |
| 479 | news.customerAccount !== undefined && |
| 480 | previousAccount !== undefined && |
| 481 | news.customerAccount !== previousAccount |
| 482 | ) { |
| 483 | return true; |
| 484 | } |
| 485 | if ( |
| 486 | news.effectiveAt !== undefined && |
| 487 | news.effectiveAt !== (output?.effectiveAt ?? olds?.effectiveAt) |
| 488 | ) { |
| 489 | return true; |
| 490 | } |
| 491 | if ( |
| 492 | news.priority !== undefined && |
| 493 | news.priority !== (output?.priority ?? olds?.priority) |
| 494 | ) { |
| 495 | return true; |
| 496 | } |
| 497 | return false; |
| 498 | }; |
| 499 | |
| 500 | export const CreditGrantProvider = () => |
| 501 | Provider.succeed(CreditGrant, { |
| 502 | stables: [ |
| 503 | "id", |
| 504 | "customer", |
| 505 | "customerAccount", |
| 506 | "amount", |
| 507 | "applicabilityConfig", |
| 508 | "category", |
| 509 | "effectiveAt", |
| 510 | "priority", |
| 511 | "created", |
| 512 | "livemode", |
| 513 | ], |
| 514 | |
| 515 | diff: Effect.fn(function* ({ olds, news, output }) { |
| 516 | if (!isResolved(news)) return undefined; |
| 517 | if (replaceOnCreateOnlyChange(olds, news, output)) { |
| 518 | return { action: "replace" as const }; |
| 519 | } |
| 520 | return undefined; |
| 521 | }), |
| 522 | |
| 523 | read: Effect.fn(function* ({ id, output }) { |
| 524 | const existing = yield* observe({ |
| 525 | id: output?.id, |
| 526 | logicalId: id, |
| 527 | }); |
| 528 | if (existing === undefined) return undefined; |
| 529 | const attrs = toAttrs(existing); |
| 530 | return (yield* hasAlchemyMetadata(id, tagRecord(existing.metadata))) |
| 531 | ? attrs |
| 532 | : Unowned(attrs); |
| 533 | }), |
| 534 | |
| 535 | list: Effect.fn(function* () { |
| 536 | const grants = yield* listAllCreditGrants(); |
| 537 | return grants |
| 538 | .filter((grant) => { |
| 539 | if (isVoided(grant)) return false; |
| 540 | const metadata = tagRecord(grant.metadata); |
| 541 | return metadata[alchemyMetadataKeys.stack] !== undefined; |
| 542 | }) |
| 543 | .map(toAttrs); |
| 544 | }), |
| 545 | |
| 546 | reconcile: Effect.fn(function* ({ id, news, output, instanceId }) { |
| 547 | const metadata = yield* desiredMetadata(id, news.metadata); |
| 548 | const name = yield* toName(id, news.name, output?.name); |
| 549 | const applicabilityConfig = |
| 550 | news.applicabilityConfig ?? DEFAULT_APPLICABILITY; |
| 551 | |
| 552 | let current: StripeCreditGrant | undefined = yield* observe({ |
| 553 | id: output?.id, |
| 554 | logicalId: id, |
| 555 | }); |
| 556 | if ( |
| 557 | current !== undefined && |
| 558 | replaceOnCreateOnlyChange(undefined, news, toAttrs(current)) |
| 559 | ) { |
| 560 | current = undefined; |
| 561 | } |
| 562 | |
| 563 | if (current === undefined) { |
| 564 | current = yield* CreateBillingCreditGrant({ |
| 565 | amount: toWireAmount(news.amount), |
| 566 | applicability_config: toWireApplicability(applicabilityConfig), |
| 567 | name, |
| 568 | metadata, |
| 569 | ...(news.customer !== undefined ? { customer: news.customer } : {}), |
| 570 | ...(news.customerAccount !== undefined |
| 571 | ? { customer_account: news.customerAccount } |
| 572 | : {}), |
| 573 | ...(news.category !== undefined ? { category: news.category } : {}), |
| 574 | ...(news.effectiveAt !== undefined |
| 575 | ? { effective_at: news.effectiveAt } |
| 576 | : {}), |
| 577 | ...(news.expiresAt !== undefined |
| 578 | ? { expires_at: news.expiresAt } |
| 579 | : {}), |
| 580 | ...(news.priority !== undefined ? { priority: news.priority } : {}), |
| 581 | }).pipe( |
| 582 | withRequestOptions({ |
| 583 | idempotencyKey: `alchemy-credit-grant-${instanceId}`, |
| 584 | }), |
| 585 | ); |
| 586 | } |
| 587 | |
| 588 | if (current === undefined) { |
| 589 | return yield* new CreditGrantNotResolved({ customer: news.customer }); |
| 590 | } |
| 591 | |
| 592 | const observedMetadata = tagRecord(current.metadata); |
| 593 | const { upsert, removed } = diffMetadata(observedMetadata, metadata); |
| 594 | const metadataChanged = upsert.length > 0 || removed.length > 0; |
| 595 | const expiresAtChanged = |
| 596 | news.expiresAt !== undefined && |
| 597 | (current.expires_at ?? undefined) !== news.expiresAt; |
| 598 | |
| 599 | if (!metadataChanged && !expiresAtChanged) { |
| 600 | return toAttrs(current); |
| 601 | } |
| 602 | |
| 603 | const updated = yield* UpdateBillingCreditGrant({ |
| 604 | id: current.id, |
| 605 | ...(expiresAtChanged ? { expires_at: news.expiresAt } : {}), |
| 606 | ...(metadataChanged |
| 607 | ? { |
| 608 | metadata: { |
| 609 | ...Object.fromEntries( |
| 610 | upsert.map((tag) => [tag.Key, tag.Value]), |
| 611 | ), |
| 612 | ...Object.fromEntries(removed.map((key) => [key, ""])), |
| 613 | }, |
| 614 | } |
| 615 | : {}), |
| 616 | }); |
| 617 | return toAttrs(updated); |
| 618 | }), |
| 619 | |
| 620 | delete: Effect.fn(function* ({ output }) { |
| 621 | const existing = yield* getById(output.id); |
| 622 | if (existing === undefined || isVoided(existing)) return; |
| 623 | yield* CreateBillingCreditGrantVoid({ id: existing.id }).pipe( |
| 624 | Effect.catchIf(isMissingGrant, () => Effect.void), |
| 625 | Effect.catchIf(alreadyVoided, () => Effect.void), |
| 626 | ); |
| 627 | }), |
| 628 | }); |