1import * as androidpublisher from "@distilled.cloud/gcp/androidpublisher_v3";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { GcpEnvironment } from "../Environment.ts";
9import type { Providers } from "../Providers.ts";
10import {
11 DEFAULT_REGIONS_VERSION,
12 defaultOfferPhases,
13 defaultOfferRegionalConfigs,
14 defaultOfferTargeting,
15 getOffer,
16 jsonEqual,
17 toOfferId,
18 updateMaskOf,
19} from "./internal.ts";
20
21export type MonetizationSubscriptionsBasePlansOfferProps = {
22 /**
23 * Play package name of the parent app. Immutable — changing it
24 * replaces the offer.
25 */
26 packageName: string;
27 /**
28 * Parent subscription product id. Immutable — changing it replaces
29 * the offer.
30 */
31 productId: string;
32 /**
33 * Parent auto-renewing base plan id. Immutable — changing it
34 * replaces the offer.
35 */
36 basePlanId: string;
37 /**
38 * Unique offer id within the base plan. If omitted, a unique id is
39 * generated. Immutable — changing it replaces the offer.
40 */
41 offerId?: string;
42 /**
43 * Offer phases (1-2). Defaults to a one-week free phase in the US.
44 */
45 phases?: androidpublisher.SubscriptionOfferPhase[];
46 /**
47 * Region-specific availability. Defaults to the US.
48 */
49 regionalConfigs?: androidpublisher.RegionalSubscriptionOfferConfig[];
50 /**
51 * Configuration for locations Play may launch in later.
52 */
53 otherRegionsConfig?: androidpublisher.OtherRegionsSubscriptionOfferConfig;
54 /**
55 * Custom billing-library tags (RFC-1034, max 20 characters each).
56 */
57 offerTags?: androidpublisher.OfferTag[];
58 /**
59 * Eligibility targeting. Defaults to new subscribers of this
60 * subscription.
61 */
62 targeting?: androidpublisher.SubscriptionOfferTargeting;
63 /**
64 * Regions-version string used when creating or updating regional
65 * prices (for example `2025/01`).
66 * @default "2025/01"
67 */
68 regionsVersion?: string;
69 /**
70 * Propagation latency tolerance for product updates.
71 */
72 latencyTolerance?:
73 | androidpublisher.PatchMonetizationSubscriptionsBasePlansOffersLatencyToleranceEnum
74 | (string & {});
75};
76
77export type MonetizationSubscriptionsBasePlansOffer = Resource<
78 "GCP.AndroidPublisher.MonetizationSubscriptionsBasePlansOffer",
79 MonetizationSubscriptionsBasePlansOfferProps,
80 {
81 /** Play package name. */
82 packageName: string;
83 /** Parent subscription product id. */
84 productId: string;
85 /** Parent base plan id. */
86 basePlanId: string;
87 /** Offer id. */
88 offerId: string;
89 /** Project id used when the offer was reconciled. */
90 project: string;
91 /** Offer state (`DRAFT`, `ACTIVE`, `INACTIVE`). */
92 state: string | undefined;
93 /** Offer phases. */
94 phases: androidpublisher.SubscriptionOfferPhase[] | undefined;
95 /** Regional availability. */
96 regionalConfigs:
97 | androidpublisher.RegionalSubscriptionOfferConfig[]
98 | undefined;
99 /** Configuration for future Play locations. */
100 otherRegionsConfig:
101 | androidpublisher.OtherRegionsSubscriptionOfferConfig
102 | undefined;
103 /** Custom billing-library tags. */
104 offerTags: androidpublisher.OfferTag[] | undefined;
105 /** Eligibility targeting. */
106 targeting: androidpublisher.SubscriptionOfferTargeting | undefined;
107 },
108 never,
109 Providers
110>;
111
112/**
113 * A Google Play subscription offer
114 * (`monetization.subscriptions.basePlans.offers`).
115 *
116 * Offers have no labels field, so ownership rests on the deterministic
117 * offer id: `read` reports an offer it finds without prior state as
118 * unowned (adopt it with `--adopt`). `packageName`, `productId`,
119 * `basePlanId`, and `offerId` are identity — changing any of them
120 * replaces the offer. Phases, regional configs, tags, and targeting
121 * update in place. Only draft offers can be deleted. The parent base
122 * plan must be auto-renewing.
123 *
124 * ### Creating an Offer
125 * **Example:** Generated offer id (one-week free trial)
126 * ```typescript
127 * const offer = yield* GCP.AndroidPublisher.MonetizationSubscriptionsBasePlansOffer(
128 * "Intro",
129 * {
130 * packageName: "com.example.app",
131 * productId: subscription.productId,
132 * basePlanId: "monthly",
133 * },
134 * );
135 * ```
136 *
137 * **Example:** Explicit id and discounted first month
138 * ```typescript
139 * const offer = yield* GCP.AndroidPublisher.MonetizationSubscriptionsBasePlansOffer(
140 * "Intro",
141 * {
142 * packageName: "com.example.app",
143 * productId: "premium",
144 * basePlanId: "monthly",
145 * offerId: "intro",
146 * phases: [
147 * {
148 * duration: "P1M",
149 * recurrenceCount: 1,
150 * regionalConfigs: [
151 * { regionCode: "US", relativeDiscount: 0.5 },
152 * ],
153 * },
154 * ],
155 * },
156 * );
157 * ```
158 *
159 * ### Updating an Offer
160 * **Example:** Close the offer to new subscribers
161 * ```typescript
162 * // Same logical id as before; only the regional config changes.
163 * const offer = yield* GCP.AndroidPublisher.MonetizationSubscriptionsBasePlansOffer(
164 * "Intro",
165 * {
166 * packageName: "com.example.app",
167 * productId: subscription.productId,
168 * basePlanId: "monthly",
169 * regionalConfigs: [
170 * { regionCode: "US", newSubscriberAvailability: false },
171 * ],
172 * },
173 * );
174 * ```
175 *
176 * @resource
177 * @category AndroidPublisher
178 */
179export const MonetizationSubscriptionsBasePlansOffer =
180 Resource<MonetizationSubscriptionsBasePlansOffer>(
181 "GCP.AndroidPublisher.MonetizationSubscriptionsBasePlansOffer",
182 );
183
184export class MonetizationSubscriptionsBasePlansOfferNotResolved extends Data.TaggedError(
185 "GCP.AndroidPublisher.MonetizationSubscriptionsBasePlansOfferNotResolved",
186)<{
187 packageName: string;
188 productId: string;
189 basePlanId: string;
190 offerId: string;
191}> {}
192
193const toAttrs = (
194 offer: androidpublisher.SubscriptionOffer,
195 project: string,
196) => ({
197 packageName: offer.packageName ?? "",
198 productId: offer.productId ?? "",
199 basePlanId: offer.basePlanId ?? "",
200 offerId: offer.offerId ?? "",
201 project,
202 state: offer.state,
203 phases: offer.phases,
204 regionalConfigs: offer.regionalConfigs,
205 otherRegionsConfig: offer.otherRegionsConfig,
206 offerTags: offer.offerTags,
207 targeting: offer.targeting,
208});
209
210const desiredBody = (input: {
211 packageName: string;
212 productId: string;
213 basePlanId: string;
214 offerId: string;
215 news: MonetizationSubscriptionsBasePlansOfferProps;
216}): androidpublisher.SubscriptionOffer => ({
217 packageName: input.packageName,
218 productId: input.productId,
219 basePlanId: input.basePlanId,
220 offerId: input.offerId,
221 phases: input.news.phases ?? defaultOfferPhases(),
222 regionalConfigs: input.news.regionalConfigs ?? defaultOfferRegionalConfigs(),
223 otherRegionsConfig: input.news.otherRegionsConfig,
224 offerTags: input.news.offerTags,
225 targeting: input.news.targeting ?? defaultOfferTargeting(),
226});
227
228const needsSync = (
229 current: androidpublisher.SubscriptionOffer,
230 desired: androidpublisher.SubscriptionOffer,
231) =>
232 !jsonEqual(current.phases, desired.phases) ||
233 !jsonEqual(current.regionalConfigs, desired.regionalConfigs) ||
234 (desired.otherRegionsConfig !== undefined &&
235 !jsonEqual(current.otherRegionsConfig, desired.otherRegionsConfig)) ||
236 (desired.offerTags !== undefined &&
237 !jsonEqual(current.offerTags, desired.offerTags)) ||
238 (desired.targeting !== undefined &&
239 !jsonEqual(current.targeting, desired.targeting));
240
241const syncMask = (
242 current: androidpublisher.SubscriptionOffer,
243 desired: androidpublisher.SubscriptionOffer,
244) =>
245 updateMaskOf(
246 !jsonEqual(current.phases, desired.phases) ? "phases" : undefined,
247 !jsonEqual(current.regionalConfigs, desired.regionalConfigs)
248 ? "regionalConfigs"
249 : undefined,
250 desired.otherRegionsConfig !== undefined &&
251 !jsonEqual(current.otherRegionsConfig, desired.otherRegionsConfig)
252 ? "otherRegionsConfig"
253 : undefined,
254 desired.offerTags !== undefined &&
255 !jsonEqual(current.offerTags, desired.offerTags)
256 ? "offerTags"
257 : undefined,
258 desired.targeting !== undefined &&
259 !jsonEqual(current.targeting, desired.targeting)
260 ? "targeting"
261 : undefined,
262 );
263
264export const MonetizationSubscriptionsBasePlansOfferProvider = () =>
265 Provider.succeed(MonetizationSubscriptionsBasePlansOffer, {
266 stables: ["packageName", "productId", "basePlanId", "offerId", "project"],
267
268 diff: Effect.fn(function* ({ news, olds, output }) {
269 if (!isResolved(news)) return undefined;
270 const previousPackage = olds?.packageName ?? output?.packageName;
271 if (
272 previousPackage !== undefined &&
273 news.packageName !== previousPackage
274 ) {
275 return { action: "replace" as const, deleteFirst: false };
276 }
277 const previousProduct = olds?.productId ?? output?.productId;
278 if (previousProduct !== undefined && news.productId !== previousProduct) {
279 return { action: "replace" as const, deleteFirst: true };
280 }
281 const previousPlan = olds?.basePlanId ?? output?.basePlanId;
282 if (previousPlan !== undefined && news.basePlanId !== previousPlan) {
283 return { action: "replace" as const, deleteFirst: true };
284 }
285 const previousOffer = olds?.offerId ?? output?.offerId;
286 if (
287 previousOffer !== undefined &&
288 news.offerId !== undefined &&
289 news.offerId !== previousOffer
290 ) {
291 return { action: "replace" as const, deleteFirst: true };
292 }
293 return undefined;
294 }),
295
296 read: Effect.fn(function* ({ id, olds, output }) {
297 const env = yield* GcpEnvironment.current;
298 const packageName = olds?.packageName ?? output?.packageName ?? "";
299 const productId = olds?.productId ?? output?.productId ?? "";
300 const basePlanId = olds?.basePlanId ?? output?.basePlanId ?? "";
301 const offerId = yield* toOfferId(id, olds?.offerId, output?.offerId);
302 const existing = yield* getOffer(
303 packageName,
304 productId,
305 basePlanId,
306 offerId,
307 );
308 if (existing === undefined) return undefined;
309 const attrs = toAttrs(existing, env.project);
310 // No labels field: without prior state the offer may not be ours.
311 return output !== undefined ? attrs : Unowned(attrs);
312 }),
313
314 reconcile: Effect.fn(function* ({ id, news, output }) {
315 const env = yield* GcpEnvironment.current;
316 const packageName = news.packageName;
317 const productId = news.productId;
318 const basePlanId = news.basePlanId;
319 const offerId = yield* toOfferId(id, news.offerId, output?.offerId);
320 const regionsVersion = news.regionsVersion ?? DEFAULT_REGIONS_VERSION;
321 const desired = desiredBody({
322 packageName,
323 productId,
324 basePlanId,
325 offerId,
326 news,
327 });
328
329 let current = yield* getOffer(
330 packageName,
331 productId,
332 basePlanId,
333 offerId,
334 );
335
336 if (current === undefined) {
337 const created = yield* androidpublisher
338 .createMonetizationSubscriptionsBasePlansOffers({
339 packageName,
340 productId,
341 basePlanId,
342 offerId,
343 "regionsVersion.version": regionsVersion,
344 body: desired,
345 })
346 .pipe(
347 Effect.catchTag("Conflict", () =>
348 getOffer(packageName, productId, basePlanId, offerId),
349 ),
350 );
351 current = created ?? undefined;
352 }
353
354 if (current === undefined) {
355 return yield* new MonetizationSubscriptionsBasePlansOfferNotResolved({
356 packageName,
357 productId,
358 basePlanId,
359 offerId,
360 });
361 }
362
363 if (needsSync(current, desired)) {
364 const updateMask = syncMask(current, desired);
365 if (updateMask.length > 0) {
366 current =
367 yield* androidpublisher.patchMonetizationSubscriptionsBasePlansOffers(
368 {
369 packageName,
370 productId,
371 basePlanId,
372 offerId: current.offerId ?? offerId,
373 updateMask,
374 "regionsVersion.version": regionsVersion,
375 latencyTolerance: news.latencyTolerance,
376 body: desired,
377 },
378 );
379 }
380 }
381
382 return toAttrs(current, env.project);
383 }),
384
385 delete: Effect.fn(function* ({ output }) {
386 if (
387 !output.packageName ||
388 !output.productId ||
389 !output.basePlanId ||
390 !output.offerId
391 ) {
392 return;
393 }
394 yield* androidpublisher
395 .deleteMonetizationSubscriptionsBasePlansOffers({
396 packageName: output.packageName,
397 productId: output.productId,
398 basePlanId: output.basePlanId,
399 offerId: output.offerId,
400 })
401 .pipe(Effect.catchTag("NotFound", () => Effect.void));
402 }),
403 });