| 1 | import * as androidpublisher from "@distilled.cloud/gcp/androidpublisher_v3"; |
| 2 | import * as Data from "effect/Data"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import { Unowned } from "../../AdoptPolicy.ts"; |
| 5 | import { isResolved } from "../../Diff.ts"; |
| 6 | import * as Provider from "../../Provider.ts"; |
| 7 | import { Resource } from "../../Resource.ts"; |
| 8 | import { GcpEnvironment } from "../Environment.ts"; |
| 9 | import type { Providers } from "../Providers.ts"; |
| 10 | import { |
| 11 | DEFAULT_REGIONS_VERSION, |
| 12 | defaultSubscriptionListings, |
| 13 | getSubscription, |
| 14 | jsonEqual, |
| 15 | publicBasePlans, |
| 16 | toDisplayName, |
| 17 | toProductId, |
| 18 | updateMaskOf, |
| 19 | } from "./internal.ts"; |
| 20 | |
| 21 | export type MonetizationSubscriptionProps = { |
| 22 | /** |
| 23 | * Play package name of the parent app (for example `com.example.app`). |
| 24 | * Immutable — changing it replaces the subscription. |
| 25 | */ |
| 26 | packageName: string; |
| 27 | /** |
| 28 | * Unique product id within the app. Lower-case letters, numbers, |
| 29 | * underscores, and dots; 1-40 characters. If omitted, a unique id is |
| 30 | * generated. Immutable — changing it replaces the subscription. |
| 31 | */ |
| 32 | productId?: string; |
| 33 | /** |
| 34 | * Localized store listings. Must include the app's default language; an |
| 35 | * `en-US` listing titled after the product id is used when omitted. |
| 36 | */ |
| 37 | listings?: androidpublisher.SubscriptionListing[]; |
| 38 | /** |
| 39 | * Base plans (prices and billing periods). Draft until activated. |
| 40 | */ |
| 41 | basePlans?: androidpublisher.BasePlan[]; |
| 42 | /** |
| 43 | * Tax and legal-compliance settings. |
| 44 | */ |
| 45 | taxAndComplianceSettings?: androidpublisher.SubscriptionTaxAndComplianceSettings; |
| 46 | /** |
| 47 | * Countries where purchase is restricted to payment methods registered |
| 48 | * in the same country. |
| 49 | */ |
| 50 | restrictedPaymentCountries?: androidpublisher.RestrictedPaymentCountries; |
| 51 | /** |
| 52 | * Regions-version string used when creating or updating regional |
| 53 | * prices (for example `2025/01`). |
| 54 | * @default "2025/01" |
| 55 | */ |
| 56 | regionsVersion?: string; |
| 57 | /** |
| 58 | * Propagation latency tolerance for product updates. |
| 59 | */ |
| 60 | latencyTolerance?: |
| 61 | | androidpublisher.PatchMonetizationSubscriptionsLatencyToleranceEnum |
| 62 | | (string & {}); |
| 63 | }; |
| 64 | |
| 65 | export type MonetizationSubscription = Resource< |
| 66 | "GCP.AndroidPublisher.MonetizationSubscription", |
| 67 | MonetizationSubscriptionProps, |
| 68 | { |
| 69 | /** Play package name. */ |
| 70 | packageName: string; |
| 71 | /** Product id. */ |
| 72 | productId: string; |
| 73 | /** Project id used when the subscription was reconciled. */ |
| 74 | project: string; |
| 75 | /** Localized store listings. */ |
| 76 | listings: androidpublisher.SubscriptionListing[] | undefined; |
| 77 | /** Base plans (output-only state omitted from equality). */ |
| 78 | basePlans: androidpublisher.BasePlan[] | undefined; |
| 79 | /** Tax and compliance settings. */ |
| 80 | taxAndComplianceSettings: |
| 81 | | androidpublisher.SubscriptionTaxAndComplianceSettings |
| 82 | | undefined; |
| 83 | /** Payment-country restrictions. */ |
| 84 | restrictedPaymentCountries: |
| 85 | | androidpublisher.RestrictedPaymentCountries |
| 86 | | undefined; |
| 87 | /** Whether the subscription is archived. */ |
| 88 | archived: boolean | undefined; |
| 89 | }, |
| 90 | never, |
| 91 | Providers |
| 92 | >; |
| 93 | |
| 94 | /** |
| 95 | * A Google Play subscription product (`monetization.subscriptions`). |
| 96 | * |
| 97 | * Subscriptions have no labels field, so ownership rests on the |
| 98 | * deterministic product id: `read` reports a subscription it finds without |
| 99 | * prior state as unowned (adopt it with `--adopt`). `packageName` and |
| 100 | * `productId` are identity — changing either replaces the subscription. |
| 101 | * Listings, base plans, tax settings, and payment-country restrictions |
| 102 | * update in place. A subscription can only be deleted while every base |
| 103 | * plan is still a draft. |
| 104 | * |
| 105 | * ### Creating a Subscription |
| 106 | * **Example:** Generated product id |
| 107 | * ```typescript |
| 108 | * const subscription = yield* GCP.AndroidPublisher.MonetizationSubscription( |
| 109 | * "Premium", |
| 110 | * { |
| 111 | * packageName: "com.example.app", |
| 112 | * listings: [{ languageCode: "en-US", title: "Premium" }], |
| 113 | * }, |
| 114 | * ); |
| 115 | * ``` |
| 116 | * |
| 117 | * **Example:** Explicit id and monthly base plan |
| 118 | * ```typescript |
| 119 | * const subscription = yield* GCP.AndroidPublisher.MonetizationSubscription( |
| 120 | * "Premium", |
| 121 | * { |
| 122 | * packageName: "com.example.app", |
| 123 | * productId: "premium", |
| 124 | * listings: [{ languageCode: "en-US", title: "Premium" }], |
| 125 | * basePlans: [ |
| 126 | * { |
| 127 | * basePlanId: "monthly", |
| 128 | * autoRenewingBasePlanType: { billingPeriodDuration: "P1M" }, |
| 129 | * regionalConfigs: [ |
| 130 | * { |
| 131 | * regionCode: "US", |
| 132 | * newSubscriberAvailability: true, |
| 133 | * price: { currencyCode: "USD", units: "5" }, |
| 134 | * }, |
| 135 | * ], |
| 136 | * }, |
| 137 | * ], |
| 138 | * }, |
| 139 | * ); |
| 140 | * ``` |
| 141 | * |
| 142 | * ### Updating a Subscription |
| 143 | * **Example:** Change the listing title |
| 144 | * ```typescript |
| 145 | * // Same logical id as before; only the listing changes. |
| 146 | * const subscription = yield* GCP.AndroidPublisher.MonetizationSubscription( |
| 147 | * "Premium", |
| 148 | * { |
| 149 | * packageName: "com.example.app", |
| 150 | * listings: [{ languageCode: "en-US", title: "Premium Plus" }], |
| 151 | * }, |
| 152 | * ); |
| 153 | * ``` |
| 154 | * |
| 155 | * @resource |
| 156 | * @category AndroidPublisher |
| 157 | */ |
| 158 | export const MonetizationSubscription = Resource<MonetizationSubscription>( |
| 159 | "GCP.AndroidPublisher.MonetizationSubscription", |
| 160 | ); |
| 161 | |
| 162 | export class MonetizationSubscriptionNotResolved extends Data.TaggedError( |
| 163 | "GCP.AndroidPublisher.MonetizationSubscriptionNotResolved", |
| 164 | )<{ |
| 165 | packageName: string; |
| 166 | productId: string; |
| 167 | }> {} |
| 168 | |
| 169 | const toAttrs = ( |
| 170 | subscription: androidpublisher.Subscription, |
| 171 | project: string, |
| 172 | ) => ({ |
| 173 | packageName: subscription.packageName ?? "", |
| 174 | productId: subscription.productId ?? "", |
| 175 | project, |
| 176 | listings: subscription.listings, |
| 177 | basePlans: publicBasePlans(subscription.basePlans), |
| 178 | taxAndComplianceSettings: subscription.taxAndComplianceSettings, |
| 179 | restrictedPaymentCountries: subscription.restrictedPaymentCountries, |
| 180 | archived: subscription.archived, |
| 181 | }); |
| 182 | |
| 183 | const desiredBody = (input: { |
| 184 | packageName: string; |
| 185 | productId: string; |
| 186 | listings: androidpublisher.SubscriptionListing[]; |
| 187 | news: MonetizationSubscriptionProps; |
| 188 | }): androidpublisher.Subscription => ({ |
| 189 | packageName: input.packageName, |
| 190 | productId: input.productId, |
| 191 | listings: input.listings, |
| 192 | basePlans: input.news.basePlans, |
| 193 | taxAndComplianceSettings: input.news.taxAndComplianceSettings, |
| 194 | restrictedPaymentCountries: input.news.restrictedPaymentCountries, |
| 195 | }); |
| 196 | |
| 197 | const needsSync = ( |
| 198 | current: androidpublisher.Subscription, |
| 199 | desired: androidpublisher.Subscription, |
| 200 | ) => |
| 201 | !jsonEqual(current.listings, desired.listings) || |
| 202 | (desired.basePlans !== undefined && |
| 203 | !jsonEqual( |
| 204 | publicBasePlans(current.basePlans), |
| 205 | publicBasePlans(desired.basePlans), |
| 206 | )) || |
| 207 | (desired.taxAndComplianceSettings !== undefined && |
| 208 | !jsonEqual( |
| 209 | current.taxAndComplianceSettings, |
| 210 | desired.taxAndComplianceSettings, |
| 211 | )) || |
| 212 | (desired.restrictedPaymentCountries !== undefined && |
| 213 | !jsonEqual( |
| 214 | current.restrictedPaymentCountries, |
| 215 | desired.restrictedPaymentCountries, |
| 216 | )); |
| 217 | |
| 218 | const syncMask = ( |
| 219 | current: androidpublisher.Subscription, |
| 220 | desired: androidpublisher.Subscription, |
| 221 | ) => |
| 222 | updateMaskOf( |
| 223 | !jsonEqual(current.listings, desired.listings) ? "listings" : undefined, |
| 224 | desired.basePlans !== undefined && |
| 225 | !jsonEqual( |
| 226 | publicBasePlans(current.basePlans), |
| 227 | publicBasePlans(desired.basePlans), |
| 228 | ) |
| 229 | ? "basePlans" |
| 230 | : undefined, |
| 231 | desired.taxAndComplianceSettings !== undefined && |
| 232 | !jsonEqual( |
| 233 | current.taxAndComplianceSettings, |
| 234 | desired.taxAndComplianceSettings, |
| 235 | ) |
| 236 | ? "taxAndComplianceSettings" |
| 237 | : undefined, |
| 238 | desired.restrictedPaymentCountries !== undefined && |
| 239 | !jsonEqual( |
| 240 | current.restrictedPaymentCountries, |
| 241 | desired.restrictedPaymentCountries, |
| 242 | ) |
| 243 | ? "restrictedPaymentCountries" |
| 244 | : undefined, |
| 245 | ); |
| 246 | |
| 247 | export const MonetizationSubscriptionProvider = () => |
| 248 | Provider.succeed(MonetizationSubscription, { |
| 249 | stables: ["packageName", "productId", "project"], |
| 250 | |
| 251 | diff: Effect.fn(function* ({ news, olds, output }) { |
| 252 | if (!isResolved(news)) return undefined; |
| 253 | const previousPackage = olds?.packageName ?? output?.packageName; |
| 254 | if ( |
| 255 | previousPackage !== undefined && |
| 256 | news.packageName !== previousPackage |
| 257 | ) { |
| 258 | return { action: "replace" as const, deleteFirst: false }; |
| 259 | } |
| 260 | const previousId = olds?.productId ?? output?.productId; |
| 261 | if ( |
| 262 | previousId !== undefined && |
| 263 | news.productId !== undefined && |
| 264 | news.productId !== previousId |
| 265 | ) { |
| 266 | return { action: "replace" as const, deleteFirst: true }; |
| 267 | } |
| 268 | return undefined; |
| 269 | }), |
| 270 | |
| 271 | read: Effect.fn(function* ({ id, olds, output }) { |
| 272 | const env = yield* GcpEnvironment.current; |
| 273 | const packageName = olds?.packageName ?? output?.packageName ?? ""; |
| 274 | const productId = yield* toProductId( |
| 275 | id, |
| 276 | olds?.productId, |
| 277 | output?.productId, |
| 278 | ); |
| 279 | const existing = yield* getSubscription(packageName, productId); |
| 280 | if (existing === undefined) return undefined; |
| 281 | const attrs = toAttrs(existing, env.project); |
| 282 | // No labels field: without prior state the subscription may not be ours. |
| 283 | return output !== undefined ? attrs : Unowned(attrs); |
| 284 | }), |
| 285 | |
| 286 | reconcile: Effect.fn(function* ({ id, news, output }) { |
| 287 | const env = yield* GcpEnvironment.current; |
| 288 | const packageName = news.packageName; |
| 289 | const productId = yield* toProductId( |
| 290 | id, |
| 291 | news.productId, |
| 292 | output?.productId, |
| 293 | ); |
| 294 | const title = yield* toDisplayName( |
| 295 | id, |
| 296 | news.listings?.[0]?.title, |
| 297 | output?.listings?.[0]?.title, |
| 298 | ); |
| 299 | const listings = defaultSubscriptionListings(news.listings, title); |
| 300 | const regionsVersion = news.regionsVersion ?? DEFAULT_REGIONS_VERSION; |
| 301 | const desired = desiredBody({ |
| 302 | packageName, |
| 303 | productId, |
| 304 | listings, |
| 305 | news, |
| 306 | }); |
| 307 | |
| 308 | let current = yield* getSubscription(packageName, productId); |
| 309 | |
| 310 | if (current === undefined) { |
| 311 | const created = yield* androidpublisher |
| 312 | .createMonetizationSubscriptions({ |
| 313 | packageName, |
| 314 | productId, |
| 315 | "regionsVersion.version": regionsVersion, |
| 316 | body: desired, |
| 317 | }) |
| 318 | .pipe( |
| 319 | Effect.catchTag("Conflict", () => |
| 320 | getSubscription(packageName, productId), |
| 321 | ), |
| 322 | ); |
| 323 | current = created ?? undefined; |
| 324 | } |
| 325 | |
| 326 | if (current === undefined) { |
| 327 | return yield* new MonetizationSubscriptionNotResolved({ |
| 328 | packageName, |
| 329 | productId, |
| 330 | }); |
| 331 | } |
| 332 | |
| 333 | if (needsSync(current, desired)) { |
| 334 | const updateMask = syncMask(current, desired); |
| 335 | if (updateMask.length > 0) { |
| 336 | current = yield* androidpublisher.patchMonetizationSubscriptions({ |
| 337 | packageName, |
| 338 | productId: current.productId ?? productId, |
| 339 | updateMask, |
| 340 | "regionsVersion.version": regionsVersion, |
| 341 | latencyTolerance: news.latencyTolerance, |
| 342 | body: desired, |
| 343 | }); |
| 344 | } |
| 345 | } |
| 346 | |
| 347 | return toAttrs(current, env.project); |
| 348 | }), |
| 349 | |
| 350 | delete: Effect.fn(function* ({ output }) { |
| 351 | if (!output.packageName || !output.productId) return; |
| 352 | yield* androidpublisher |
| 353 | .deleteMonetizationSubscriptions({ |
| 354 | packageName: output.packageName, |
| 355 | productId: output.productId, |
| 356 | }) |
| 357 | .pipe(Effect.catchTag("NotFound", () => Effect.void)); |
| 358 | }), |
| 359 | }); |