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 defaultSubscriptionListings,
13 getSubscription,
14 jsonEqual,
15 publicBasePlans,
16 toDisplayName,
17 toProductId,
18 updateMaskOf,
19} from "./internal.ts";
20
21export 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
65export 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 */
158export const MonetizationSubscription = Resource<MonetizationSubscription>(
159 "GCP.AndroidPublisher.MonetizationSubscription",
160);
161
162export class MonetizationSubscriptionNotResolved extends Data.TaggedError(
163 "GCP.AndroidPublisher.MonetizationSubscriptionNotResolved",
164)<{
165 packageName: string;
166 productId: string;
167}> {}
168
169const 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
183const 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
197const 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
218const 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
247export 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 });