1import * as androidpublisher from "@distilled.cloud/gcp/androidpublisher_v3";
2import * as Effect from "effect/Effect";
3import { createPhysicalName } from "../../PhysicalName.ts";
4
5export const DEFAULT_LANGUAGE = "en-US";
6export const DEFAULT_REGION = "US";
7export const DEFAULT_CURRENCY = "USD";
8export const DEFAULT_REGIONS_VERSION = "2025/01";
9export const DEFAULT_PRICE_MICROS = "990000";
10export const DEFAULT_SUBSCRIPTION_PRICE_UNITS = "5";
11export const DEFAULT_BILLING_PERIOD = "P1M";
12export const DEFAULT_OFFER_DURATION = "P1W";
13export const MAX_PRODUCT_ID_LENGTH = 40;
14export const MAX_BASE_PLAN_ID_LENGTH = 63;
15export const MAX_OFFER_ID_LENGTH = 63;
16export const MAX_SKU_LENGTH = 40;
17export const MAX_LISTING_TITLE_LENGTH = 50;
18
19export const sameText = (left: string | undefined, right: string | undefined) =>
20 (left ?? "") === (right ?? "");
21
22export const jsonEqual = (left: unknown, right: unknown) =>
23 JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
24
25export const sameStringList = (
26 left: readonly string[] | undefined,
27 right: readonly string[] | undefined,
28) =>
29 jsonEqual(
30 [...(left ?? [])].slice().sort(),
31 [...(right ?? [])].slice().sort(),
32 );
33
34export const updateMaskOf = (...fields: Array<string | undefined>) =>
35 fields.filter((field): field is string => field !== undefined).join(",");
36
37const isMissing = <E extends { readonly _tag: string }>(
38 error: E,
39): error is Extract<E, { readonly _tag: "NotFound" }> =>
40 error._tag === "NotFound";
41
42export const catchMissing = <A, E extends { readonly _tag: string }, R>(
43 effect: Effect.Effect<A, E, R>,
44) => effect.pipe(Effect.catchIf(isMissing, () => Effect.succeed(undefined)));
45
46export const ignoreMissing = <E extends { readonly _tag: string }, R>(
47 effect: Effect.Effect<unknown, E, R>,
48) => effect.pipe(Effect.catchIf(isMissing, () => Effect.void));
49
50const toGenerated = (
51 id: string,
52 requested: string | undefined,
53 existing: string | undefined,
54 options: {
55 maxLength: number;
56 delimiter: string;
57 prefixIfNeeded: string;
58 },
59) =>
60 Effect.gen(function* () {
61 if (requested !== undefined && requested.length > 0) return requested;
62 if (existing !== undefined && existing.length > 0) return existing;
63 const generated = yield* createPhysicalName({
64 id,
65 maxLength: options.maxLength,
66 lowercase: true,
67 delimiter: options.delimiter,
68 });
69 const next = /^[a-z]/.test(generated)
70 ? generated
71 : `${options.prefixIfNeeded}${generated}`.slice(0, options.maxLength);
72 const trimmed = next
73 .replace(new RegExp(`${options.delimiter}+`, "g"), options.delimiter)
74 .replace(
75 new RegExp(`^${options.delimiter}|${options.delimiter}$`, "g"),
76 "",
77 );
78 return trimmed.length >= 1
79 ? trimmed
80 : `${options.prefixIfNeeded}1`.slice(0, options.maxLength);
81 });
82
83export const toProductId = (
84 id: string,
85 requested: string | undefined,
86 existing: string | undefined,
87) =>
88 toGenerated(id, requested, existing, {
89 maxLength: MAX_PRODUCT_ID_LENGTH,
90 delimiter: "_",
91 prefixIfNeeded: "a",
92 });
93
94export const toSku = (
95 id: string,
96 requested: string | undefined,
97 existing: string | undefined,
98) =>
99 toGenerated(id, requested, existing, {
100 maxLength: MAX_SKU_LENGTH,
101 delimiter: "_",
102 prefixIfNeeded: "a",
103 });
104
105export const toBasePlanId = (
106 id: string,
107 requested: string | undefined,
108 existing: string | undefined,
109) =>
110 toGenerated(id, requested, existing, {
111 maxLength: MAX_BASE_PLAN_ID_LENGTH,
112 delimiter: "-",
113 prefixIfNeeded: "b",
114 });
115
116export const toOfferId = (
117 id: string,
118 requested: string | undefined,
119 existing: string | undefined,
120) =>
121 toGenerated(id, requested, existing, {
122 maxLength: MAX_OFFER_ID_LENGTH,
123 delimiter: "-",
124 prefixIfNeeded: "o",
125 });
126
127export const toDisplayName = (
128 id: string,
129 requested: string | undefined,
130 existing: string | undefined,
131 maxLength = MAX_LISTING_TITLE_LENGTH,
132) =>
133 Effect.gen(function* () {
134 if (requested !== undefined && requested.length > 0) {
135 return requested.slice(0, maxLength);
136 }
137 if (existing !== undefined && existing.length > 0) {
138 return existing.slice(0, maxLength);
139 }
140 return (yield* toProductId(id, undefined, undefined)).slice(0, maxLength);
141 });
142
143export const defaultSubscriptionListings = (
144 listings: readonly androidpublisher.SubscriptionListing[] | undefined,
145 title: string,
146): androidpublisher.SubscriptionListing[] =>
147 listings && listings.length > 0
148 ? listings.map((listing) => ({
149 ...listing,
150 languageCode: listing.languageCode ?? DEFAULT_LANGUAGE,
151 }))
152 : [{ languageCode: DEFAULT_LANGUAGE, title }];
153
154export const defaultInappListings = (
155 listings: androidpublisher.InAppProductListingMap | undefined,
156 title: string,
157 defaultLanguage: string,
158): androidpublisher.InAppProductListingMap => {
159 const next = { ...(listings ?? {}) };
160 const current = next[defaultLanguage] ?? { title };
161 next[defaultLanguage] = { ...current, title: current.title ?? title };
162 return next;
163};
164
165export const publicBasePlans = (
166 plans: readonly androidpublisher.BasePlan[] | undefined,
167) =>
168 plans?.map((plan) => ({
169 regionalConfigs: plan.regionalConfigs,
170 otherRegionsConfig: plan.otherRegionsConfig,
171 offerTags: plan.offerTags,
172 installmentsBasePlanType: plan.installmentsBasePlanType,
173 autoRenewingBasePlanType: plan.autoRenewingBasePlanType,
174 basePlanId: plan.basePlanId,
175 prepaidBasePlanType: plan.prepaidBasePlanType,
176 }));
177
178export const defaultOfferPhases =
179 (): androidpublisher.SubscriptionOfferPhase[] => [
180 {
181 duration: DEFAULT_OFFER_DURATION,
182 recurrenceCount: 1,
183 regionalConfigs: [{ regionCode: DEFAULT_REGION, free: {} }],
184 },
185 ];
186
187export const defaultOfferRegionalConfigs =
188 (): androidpublisher.RegionalSubscriptionOfferConfig[] => [
189 { regionCode: DEFAULT_REGION, newSubscriberAvailability: true },
190 ];
191
192export const defaultOfferTargeting =
193 (): androidpublisher.SubscriptionOfferTargeting => ({
194 acquisitionRule: { scope: { thisSubscription: {} } },
195 });
196
197export const defaultInappPrice = (): androidpublisher.Price => ({
198 currency: DEFAULT_CURRENCY,
199 priceMicros: DEFAULT_PRICE_MICROS,
200});
201
202export const getSubscription = (packageName: string, productId: string) =>
203 packageName.length === 0 || productId.length === 0
204 ? Effect.succeed(undefined)
205 : catchMissing(
206 androidpublisher.getMonetizationSubscriptions({
207 packageName,
208 productId,
209 }),
210 );
211
212export const getOffer = (
213 packageName: string,
214 productId: string,
215 basePlanId: string,
216 offerId: string,
217) =>
218 packageName.length === 0 ||
219 productId.length === 0 ||
220 basePlanId.length === 0 ||
221 offerId.length === 0
222 ? Effect.succeed(undefined)
223 : catchMissing(
224 androidpublisher.getMonetizationSubscriptionsBasePlansOffers({
225 packageName,
226 productId,
227 basePlanId,
228 offerId,
229 }),
230 );
231
232export const getInappproduct = (packageName: string, sku: string) =>
233 packageName.length === 0 || sku.length === 0
234 ? Effect.succeed(undefined)
235 : catchMissing(androidpublisher.getInappproducts({ packageName, sku }));
236
237export const getEdit = (packageName: string, editId: string) =>
238 packageName.length === 0 || editId.length === 0
239 ? Effect.succeed(undefined)
240 : catchMissing(androidpublisher.getEdits({ packageName, editId }));