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_LANGUAGE,
12 defaultInappListings,
13 defaultInappPrice,
14 getInappproduct,
15 jsonEqual,
16 sameText,
17 toDisplayName,
18 toSku,
19} from "./internal.ts";
20
21export type InappproductProps = {
22 /**
23 * Play package name of the parent app (for example `com.example.app`).
24 * Immutable — changing it replaces the in-app product.
25 */
26 packageName: string;
27 /**
28 * SKU unique within the app. If omitted, a unique SKU is generated.
29 * Immutable — changing it replaces the product.
30 */
31 sku?: string;
32 /**
33 * Default listing language (BCP-47, for example `en-US`).
34 * @default "en-US"
35 */
36 defaultLanguage?: string;
37 /**
38 * Localized titles and descriptions, keyed by language. A default-language
39 * listing titled after the SKU is added when missing.
40 */
41 listings?: androidpublisher.InAppProductListingMap;
42 /**
43 * Default price in the developer's Checkout currency. Cannot be zero.
44 * @default { currency: "USD", priceMicros: "990000" }
45 */
46 defaultPrice?: androidpublisher.Price;
47 /**
48 * Prices per buyer region.
49 */
50 prices?: androidpublisher.PriceMap;
51 /**
52 * Product status (`active` or `inactive`).
53 */
54 status?: androidpublisher.InAppProductStatusEnum | (string & {});
55 /**
56 * Purchase type. Use `managedUser` for one-time products. Subscriptions
57 * should use `MonetizationSubscription` instead.
58 * @default "managedUser"
59 */
60 purchaseType?: androidpublisher.InAppProductPurchaseTypeEnum | (string & {});
61 /**
62 * Subscription period (ISO 8601). Only for legacy subscription SKUs.
63 */
64 subscriptionPeriod?: string;
65 /**
66 * Trial period (ISO 8601, `P7D` to `P999D`).
67 */
68 trialPeriod?: string;
69 /**
70 * Grace period (ISO 8601) for declined subscription recurrences.
71 */
72 gracePeriod?: string;
73 /**
74 * Tax and compliance settings for managed products.
75 */
76 managedProductTaxesAndComplianceSettings?: androidpublisher.ManagedProductTaxAndComplianceSettings;
77 /**
78 * Tax and compliance settings for legacy subscription SKUs.
79 */
80 subscriptionTaxesAndComplianceSettings?: androidpublisher.SubscriptionTaxAndComplianceSettings;
81 /**
82 * Auto-convert missing regional prices from the default price.
83 * @default true
84 */
85 autoConvertMissingPrices?: boolean;
86 /**
87 * Propagation latency tolerance for product updates.
88 */
89 latencyTolerance?:
90 | androidpublisher.PatchInappproductsLatencyToleranceEnum
91 | (string & {});
92};
93
94export type Inappproduct = Resource<
95 "GCP.AndroidPublisher.Inappproduct",
96 InappproductProps,
97 {
98 /** Play package name. */
99 packageName: string;
100 /** SKU. */
101 sku: string;
102 /** Project id used when the product was reconciled. */
103 project: string;
104 /** Default listing language. */
105 defaultLanguage: string | undefined;
106 /** Localized listings. */
107 listings: androidpublisher.InAppProductListingMap | undefined;
108 /** Default price. */
109 defaultPrice: androidpublisher.Price | undefined;
110 /** Regional prices. */
111 prices: androidpublisher.PriceMap | undefined;
112 /** Product status. */
113 status: string | undefined;
114 /** Purchase type. */
115 purchaseType: string | undefined;
116 /** Subscription period, if any. */
117 subscriptionPeriod: string | undefined;
118 /** Trial period, if any. */
119 trialPeriod: string | undefined;
120 /** Grace period, if any. */
121 gracePeriod: string | undefined;
122 },
123 never,
124 Providers
125>;
126
127/**
128 * A Google Play in-app product (`inappproducts`).
129 *
130 * In-app products have no labels field, so ownership rests on the
131 * deterministic SKU: `read` reports a product it finds without prior state
132 * as unowned (adopt it with `--adopt`). `packageName` and `sku` are identity — changing either replaces the
133 * product. Listings, prices, and status update in place. Prefer
134 * `MonetizationSubscription` for subscriptions; this resource is for
135 * managed one-time products.
136 *
137 * ### Creating an In-app Product
138 * **Example:** Generated SKU
139 * ```typescript
140 * const gem = yield* GCP.AndroidPublisher.Inappproduct("GemPack", {
141 * packageName: "com.example.app",
142 * listings: { "en-US": { title: "Gem pack" } },
143 * });
144 * ```
145 *
146 * **Example:** Explicit SKU and price
147 * ```typescript
148 * const gem = yield* GCP.AndroidPublisher.Inappproduct("GemPack", {
149 * packageName: "com.example.app",
150 * sku: "gem_pack_small",
151 * defaultPrice: { currency: "USD", priceMicros: "1990000" },
152 * listings: { "en-US": { title: "Small gem pack" } },
153 * });
154 * ```
155 *
156 * ### Updating an In-app Product
157 * **Example:** Change the title
158 * ```typescript
159 * // Same logical id as before; only the listing changes.
160 * const gem = yield* GCP.AndroidPublisher.Inappproduct("GemPack", {
161 * packageName: "com.example.app",
162 * listings: { "en-US": { title: "Starter gem pack" } },
163 * });
164 * ```
165 *
166 * @resource
167 * @category AndroidPublisher
168 */
169export const Inappproduct = Resource<Inappproduct>(
170 "GCP.AndroidPublisher.Inappproduct",
171);
172
173export class InappproductNotResolved extends Data.TaggedError(
174 "GCP.AndroidPublisher.InappproductNotResolved",
175)<{
176 packageName: string;
177 sku: string;
178}> {}
179
180const toAttrs = (product: androidpublisher.InAppProduct, project: string) => ({
181 packageName: product.packageName ?? "",
182 sku: product.sku ?? "",
183 project,
184 defaultLanguage: product.defaultLanguage,
185 listings: product.listings,
186 defaultPrice: product.defaultPrice,
187 prices: product.prices,
188 status: product.status,
189 purchaseType: product.purchaseType,
190 subscriptionPeriod: product.subscriptionPeriod,
191 trialPeriod: product.trialPeriod,
192 gracePeriod: product.gracePeriod,
193});
194
195const desiredBody = (input: {
196 packageName: string;
197 sku: string;
198 defaultLanguage: string;
199 listings: androidpublisher.InAppProductListingMap;
200 news: InappproductProps;
201}): androidpublisher.InAppProduct => ({
202 packageName: input.packageName,
203 sku: input.sku,
204 defaultLanguage: input.defaultLanguage,
205 listings: input.listings,
206 defaultPrice: input.news.defaultPrice ?? defaultInappPrice(),
207 prices: input.news.prices,
208 status: input.news.status,
209 purchaseType: input.news.purchaseType ?? "managedUser",
210 subscriptionPeriod: input.news.subscriptionPeriod,
211 trialPeriod: input.news.trialPeriod,
212 gracePeriod: input.news.gracePeriod,
213 managedProductTaxesAndComplianceSettings:
214 input.news.managedProductTaxesAndComplianceSettings,
215 subscriptionTaxesAndComplianceSettings:
216 input.news.subscriptionTaxesAndComplianceSettings,
217});
218
219const needsSync = (
220 current: androidpublisher.InAppProduct,
221 desired: androidpublisher.InAppProduct,
222) =>
223 !sameText(current.defaultLanguage, desired.defaultLanguage) ||
224 !jsonEqual(current.listings, desired.listings) ||
225 !jsonEqual(current.defaultPrice, desired.defaultPrice) ||
226 (desired.prices !== undefined &&
227 !jsonEqual(current.prices, desired.prices)) ||
228 (desired.status !== undefined && !sameText(current.status, desired.status)) ||
229 (desired.purchaseType !== undefined &&
230 !sameText(current.purchaseType, desired.purchaseType)) ||
231 !sameText(current.subscriptionPeriod, desired.subscriptionPeriod) ||
232 !sameText(current.trialPeriod, desired.trialPeriod) ||
233 !sameText(current.gracePeriod, desired.gracePeriod) ||
234 (desired.managedProductTaxesAndComplianceSettings !== undefined &&
235 !jsonEqual(
236 current.managedProductTaxesAndComplianceSettings,
237 desired.managedProductTaxesAndComplianceSettings,
238 )) ||
239 (desired.subscriptionTaxesAndComplianceSettings !== undefined &&
240 !jsonEqual(
241 current.subscriptionTaxesAndComplianceSettings,
242 desired.subscriptionTaxesAndComplianceSettings,
243 ));
244
245export const InappproductProvider = () =>
246 Provider.succeed(Inappproduct, {
247 stables: ["packageName", "sku", "project", "purchaseType"],
248
249 diff: Effect.fn(function* ({ news, olds, output }) {
250 if (!isResolved(news)) return undefined;
251 const previousPackage = olds?.packageName ?? output?.packageName;
252 if (
253 previousPackage !== undefined &&
254 news.packageName !== previousPackage
255 ) {
256 return { action: "replace" as const, deleteFirst: false };
257 }
258 const previousSku = olds?.sku ?? output?.sku;
259 if (
260 previousSku !== undefined &&
261 news.sku !== undefined &&
262 news.sku !== previousSku
263 ) {
264 return { action: "replace" as const, deleteFirst: true };
265 }
266 return undefined;
267 }),
268
269 read: Effect.fn(function* ({ id, olds, output }) {
270 const env = yield* GcpEnvironment.current;
271 const packageName = olds?.packageName ?? output?.packageName ?? "";
272 const sku = yield* toSku(id, olds?.sku, output?.sku);
273 const existing = yield* getInappproduct(packageName, sku);
274 if (existing === undefined) return undefined;
275 const attrs = toAttrs(existing, env.project);
276 // No labels field: without prior state the product may not be ours.
277 return output !== undefined ? attrs : Unowned(attrs);
278 }),
279
280 reconcile: Effect.fn(function* ({ id, news, output }) {
281 const env = yield* GcpEnvironment.current;
282 const packageName = news.packageName;
283 const sku = yield* toSku(id, news.sku, output?.sku);
284 const defaultLanguage =
285 news.defaultLanguage ?? output?.defaultLanguage ?? DEFAULT_LANGUAGE;
286 const title = yield* toDisplayName(
287 id,
288 news.listings?.[defaultLanguage]?.title,
289 output?.listings?.[defaultLanguage]?.title,
290 );
291 const listings = defaultInappListings(
292 news.listings,
293 title,
294 defaultLanguage,
295 );
296 const autoConvertMissingPrices = news.autoConvertMissingPrices ?? true;
297 const desired = desiredBody({
298 packageName,
299 sku,
300 defaultLanguage,
301 listings,
302 news,
303 });
304
305 let current = yield* getInappproduct(packageName, sku);
306
307 if (current === undefined) {
308 const created = yield* androidpublisher
309 .insertInappproducts({
310 packageName,
311 autoConvertMissingPrices,
312 body: desired,
313 })
314 .pipe(
315 Effect.catchTag("Conflict", () =>
316 getInappproduct(packageName, sku),
317 ),
318 );
319 current = created ?? undefined;
320 } else if (needsSync(current, desired)) {
321 current = yield* androidpublisher.patchInappproducts({
322 packageName,
323 sku: current.sku ?? sku,
324 autoConvertMissingPrices,
325 latencyTolerance: news.latencyTolerance,
326 body: desired,
327 });
328 }
329
330 if (current === undefined) {
331 return yield* new InappproductNotResolved({ packageName, sku });
332 }
333
334 return toAttrs(current, env.project);
335 }),
336
337 delete: Effect.fn(function* ({ output }) {
338 if (!output.packageName || !output.sku) return;
339 yield* androidpublisher
340 .deleteInappproducts({
341 packageName: output.packageName,
342 sku: output.sku,
343 })
344 .pipe(Effect.catchTag("NotFound", () => Effect.void));
345 }),
346 });