Skip to content

Commit d6aa2b4

Browse files
authored
Add IAP and subscription pricing management (#3)
* Fix modal positioning by preventing animation transforms from creating containing blocks animation-fill-mode: both retains transform values after completion, which creates a new CSS containing block on ancestor elements. This causes position: fixed modals to be sized relative to page content instead of the viewport. Changed fill-mode to backwards (styles only applied before animation starts, removed after) and keyframe end values to transform: none. * Add IAP and subscription pricing management Adds per-territory price viewing and editing for in-app purchases and subscriptions. Includes backend pricing routes with ASC API integration, a PricingPanel component embedded in the product edit modal, and a territory reference dataset. --------- Co-authored-by: Quang Tran <16215255+trmquang93@users.noreply.github.com>
1 parent 14f3543 commit d6aa2b4

12 files changed

Lines changed: 988 additions & 10 deletions

server/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import accountsRouter from "./routes/accounts.js";
33
import appsRouter from "./routes/apps.js";
44
import productsRouter from "./routes/products.js";
55
import xcodeCloudRouter from "./routes/xcode-cloud.js";
6+
import pricingRouter from "./routes/pricing.js";
67

78
const app = express();
89

@@ -11,6 +12,7 @@ app.use(express.json());
1112
app.use("/api/accounts", accountsRouter);
1213
app.use("/api/apps", appsRouter);
1314
app.use("/api/apps", productsRouter);
15+
app.use("/api/apps", pricingRouter);
1416
app.use("/api/apps", xcodeCloudRouter);
1517

1618
const PORT = process.env.SERVER_PORT || 3001;

server/routes/pricing.js

Lines changed: 372 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,372 @@
1+
import { Router } from "express";
2+
import { getAccounts } from "../lib/account-store.js";
3+
import { ascFetch } from "../lib/asc-client.js";
4+
import { apiCache } from "../lib/cache.js";
5+
6+
const router = Router();
7+
8+
// ── Helpers ─────────────────────────────────────────────────────────────────
9+
10+
function resolveAccount(req, res) {
11+
const accountId = req.query.accountId || req.body?.accountId;
12+
const accounts = getAccounts();
13+
const account = accounts.find((a) => a.id === accountId) || accounts[0];
14+
if (!account) {
15+
res.status(400).json({ error: "No accounts configured" });
16+
return null;
17+
}
18+
return account;
19+
}
20+
21+
async function fetchAllPages(account, path, maxPages = 20) {
22+
const allData = [];
23+
const allIncluded = [];
24+
let url = path;
25+
let page = 0;
26+
27+
while (url) {
28+
if (page >= maxPages) break;
29+
const result = await ascFetch(account, url);
30+
if (result.data) allData.push(...result.data);
31+
if (result.included) allIncluded.push(...result.included);
32+
page++;
33+
url = result.links?.next || null;
34+
if (url) {
35+
// ASC returns full URLs for pagination; strip the base
36+
url = url.replace("https://api.appstoreconnect.apple.com", "");
37+
}
38+
}
39+
40+
return { data: allData, included: allIncluded };
41+
}
42+
43+
// ── IAP Prices ──────────────────────────────────────────────────────────────
44+
45+
// Get current prices for an IAP (fast — only fetches schedule, not all price points)
46+
router.get("/:appId/iap/:iapId/prices", async (req, res) => {
47+
const { iapId } = req.params;
48+
const account = resolveAccount(req, res);
49+
if (!account) return;
50+
51+
const cacheKey = `iap:prices:${iapId}:${account.id}`;
52+
const cached = apiCache.get(cacheKey);
53+
if (cached) return res.json(cached);
54+
55+
try {
56+
const scheduleRes = await ascFetch(
57+
account,
58+
`/v1/inAppPurchases/${iapId}/inAppPurchasePriceSchedule?include=manualPrices,baseTerritory`
59+
).catch(() => ({ data: null, included: [] }));
60+
61+
// Extract base territory
62+
const baseTerritoryData = (scheduleRes.included || []).find(
63+
(i) => i.type === "territories"
64+
);
65+
const baseTerritory = baseTerritoryData?.id || null;
66+
67+
// Build map of current prices from manual prices in schedule
68+
const manualPrices = (scheduleRes.included || []).filter(
69+
(i) => i.type === "inAppPurchasePrices"
70+
);
71+
72+
// Collect price point IDs we need to resolve customerPrice for
73+
const pricePointIds = new Set();
74+
const currentPrices = {};
75+
76+
for (const mp of manualPrices) {
77+
const territoryId = mp.relationships?.territory?.data?.id;
78+
const pricePointId = mp.relationships?.inAppPurchasePricePoint?.data?.id;
79+
if (territoryId && pricePointId) {
80+
currentPrices[territoryId] = { pricePointId, customerPrice: null };
81+
pricePointIds.add(pricePointId);
82+
}
83+
}
84+
85+
// Resolve customerPrice from included inAppPurchasePricePoints
86+
for (const inc of scheduleRes.included || []) {
87+
if (inc.type === "inAppPurchasePricePoints" && pricePointIds.has(inc.id)) {
88+
for (const entry of Object.values(currentPrices)) {
89+
if (entry.pricePointId === inc.id) {
90+
entry.customerPrice = inc.attributes?.customerPrice || null;
91+
}
92+
}
93+
}
94+
}
95+
96+
const result = { baseTerritory, currentPrices };
97+
98+
apiCache.set(cacheKey, result);
99+
res.json(result);
100+
} catch (err) {
101+
console.error(`Failed to fetch IAP prices for ${iapId}:`, err.message);
102+
res.status(502).json({ error: err.message });
103+
}
104+
});
105+
106+
// Get available price points for a specific territory (lazy-loaded by frontend)
107+
router.get("/:appId/iap/:iapId/price-points", async (req, res) => {
108+
const { iapId } = req.params;
109+
const territory = req.query.territory;
110+
const account = resolveAccount(req, res);
111+
if (!account) return;
112+
113+
if (!territory) {
114+
return res.status(400).json({ error: "territory query parameter is required" });
115+
}
116+
117+
const cacheKey = `iap:price-points:${iapId}:${territory}:${account.id}`;
118+
const cached = apiCache.get(cacheKey);
119+
if (cached) return res.json(cached);
120+
121+
try {
122+
const ppRes = await fetchAllPages(
123+
account,
124+
`/v1/inAppPurchases/${iapId}/pricePoints?filter[territory]=${territory}&include=territory&limit=200`
125+
);
126+
127+
const pricePoints = (ppRes.data || []).map((pp) => ({
128+
id: pp.id,
129+
customerPrice: pp.attributes?.customerPrice || "0",
130+
proceeds: pp.attributes?.proceeds || "0",
131+
})).sort((a, b) => parseFloat(a.customerPrice) - parseFloat(b.customerPrice));
132+
133+
const result = { pricePoints };
134+
apiCache.set(cacheKey, result);
135+
res.json(result);
136+
} catch (err) {
137+
console.error(`Failed to fetch IAP price points for ${iapId}/${territory}:`, err.message);
138+
res.status(502).json({ error: err.message });
139+
}
140+
});
141+
142+
router.post("/:appId/iap/:iapId/prices", async (req, res) => {
143+
const { iapId } = req.params;
144+
const { accountId, baseTerritory, manualPrices } = req.body;
145+
146+
if (!accountId || !baseTerritory || !manualPrices) {
147+
return res
148+
.status(400)
149+
.json({ error: "accountId, baseTerritory, and manualPrices are required" });
150+
}
151+
152+
const accounts = getAccounts();
153+
const account = accounts.find((a) => a.id === accountId);
154+
if (!account) return res.status(400).json({ error: "Account not found" });
155+
156+
try {
157+
// Build the included array with client-generated IDs
158+
const included = manualPrices.map((mp, index) => ({
159+
type: "inAppPurchasePrices",
160+
id: `price-${mp.territory}-${index}`,
161+
attributes: {
162+
startDate: null,
163+
},
164+
relationships: {
165+
inAppPurchaseV2: {
166+
data: { type: "inAppPurchases", id: iapId },
167+
},
168+
inAppPurchasePricePoint: {
169+
data: {
170+
type: "inAppPurchasePricePoints",
171+
id: mp.pricePointId,
172+
},
173+
},
174+
},
175+
}));
176+
177+
const body = {
178+
data: {
179+
type: "inAppPurchasePriceSchedules",
180+
relationships: {
181+
inAppPurchase: {
182+
data: { type: "inAppPurchases", id: iapId },
183+
},
184+
baseTerritory: {
185+
data: { type: "territories", id: baseTerritory },
186+
},
187+
manualPrices: {
188+
data: included.map((inc) => ({
189+
type: inc.type,
190+
id: inc.id,
191+
})),
192+
},
193+
},
194+
},
195+
included,
196+
};
197+
198+
await ascFetch(account, "/v1/inAppPurchasePriceSchedules", {
199+
method: "POST",
200+
body,
201+
});
202+
203+
apiCache.deleteByPrefix(`iap:prices:${iapId}:`);
204+
res.json({ success: true });
205+
} catch (err) {
206+
console.error(`Failed to set IAP prices for ${iapId}:`, err.message);
207+
res.status(502).json({ error: err.message });
208+
}
209+
});
210+
211+
// ── Subscription Prices ─────────────────────────────────────────────────────
212+
213+
// Get current prices for a subscription (fast — only fetches current prices, not all price points)
214+
router.get(
215+
"/:appId/subscription-groups/:groupId/subscriptions/:subId/prices",
216+
async (req, res) => {
217+
const { subId } = req.params;
218+
const account = resolveAccount(req, res);
219+
if (!account) return;
220+
221+
const cacheKey = `sub:prices:${subId}:${account.id}`;
222+
const cached = apiCache.get(cacheKey);
223+
if (cached) return res.json(cached);
224+
225+
try {
226+
const currentPricesRes = await fetchAllPages(
227+
account,
228+
`/v1/subscriptions/${subId}/prices?include=subscriptionPricePoint,territory&limit=200`
229+
);
230+
231+
// Map current price point IDs to their prices from included
232+
const pricePointDetailMap = new Map();
233+
for (const inc of currentPricesRes.included || []) {
234+
if (inc.type === "subscriptionPricePoints") {
235+
pricePointDetailMap.set(inc.id, {
236+
customerPrice: inc.attributes?.customerPrice || "0",
237+
proceeds: inc.attributes?.proceeds || "0",
238+
});
239+
}
240+
}
241+
242+
// Build current prices map: territory -> { pricePointId, customerPrice }
243+
const currentPrices = {};
244+
for (const price of currentPricesRes.data || []) {
245+
const territoryId = price.relationships?.territory?.data?.id;
246+
const pricePointId =
247+
price.relationships?.subscriptionPricePoint?.data?.id;
248+
if (territoryId && pricePointId) {
249+
const detail = pricePointDetailMap.get(pricePointId);
250+
currentPrices[territoryId] = {
251+
pricePointId,
252+
customerPrice: detail?.customerPrice || null,
253+
};
254+
}
255+
}
256+
257+
const result = { baseTerritory: null, currentPrices };
258+
259+
apiCache.set(cacheKey, result);
260+
res.json(result);
261+
} catch (err) {
262+
console.error(
263+
`Failed to fetch subscription prices for ${subId}:`,
264+
err.message
265+
);
266+
res.status(502).json({ error: err.message });
267+
}
268+
}
269+
);
270+
271+
// Get available price points for a specific territory (lazy-loaded by frontend)
272+
router.get(
273+
"/:appId/subscription-groups/:groupId/subscriptions/:subId/price-points",
274+
async (req, res) => {
275+
const { subId } = req.params;
276+
const territory = req.query.territory;
277+
const account = resolveAccount(req, res);
278+
if (!account) return;
279+
280+
if (!territory) {
281+
return res.status(400).json({ error: "territory query parameter is required" });
282+
}
283+
284+
const cacheKey = `sub:price-points:${subId}:${territory}:${account.id}`;
285+
const cached = apiCache.get(cacheKey);
286+
if (cached) return res.json(cached);
287+
288+
try {
289+
const ppRes = await fetchAllPages(
290+
account,
291+
`/v1/subscriptions/${subId}/pricePoints?filter[territory]=${territory}&include=territory&limit=200`
292+
);
293+
294+
const pricePoints = (ppRes.data || []).map((pp) => ({
295+
id: pp.id,
296+
customerPrice: pp.attributes?.customerPrice || "0",
297+
proceeds: pp.attributes?.proceeds || "0",
298+
})).sort((a, b) => parseFloat(a.customerPrice) - parseFloat(b.customerPrice));
299+
300+
const result = { pricePoints };
301+
apiCache.set(cacheKey, result);
302+
res.json(result);
303+
} catch (err) {
304+
console.error(`Failed to fetch subscription price points for ${subId}/${territory}:`, err.message);
305+
res.status(502).json({ error: err.message });
306+
}
307+
}
308+
);
309+
310+
router.post(
311+
"/:appId/subscription-groups/:groupId/subscriptions/:subId/prices",
312+
async (req, res) => {
313+
const { subId } = req.params;
314+
const { accountId, prices } = req.body;
315+
316+
if (!accountId || !prices || !Array.isArray(prices)) {
317+
return res
318+
.status(400)
319+
.json({ error: "accountId and prices array are required" });
320+
}
321+
322+
const accounts = getAccounts();
323+
const account = accounts.find((a) => a.id === accountId);
324+
if (!account) return res.status(400).json({ error: "Account not found" });
325+
326+
const results = await Promise.allSettled(
327+
prices.map((p) =>
328+
ascFetch(account, "/v1/subscriptionPrices", {
329+
method: "POST",
330+
body: {
331+
data: {
332+
type: "subscriptionPrices",
333+
attributes: {
334+
startDate: null,
335+
preserveCurrentPrice: false,
336+
},
337+
relationships: {
338+
subscription: {
339+
data: { type: "subscriptions", id: subId },
340+
},
341+
subscriptionPricePoint: {
342+
data: {
343+
type: "subscriptionPricePoints",
344+
id: p.pricePointId,
345+
},
346+
},
347+
},
348+
},
349+
},
350+
})
351+
)
352+
);
353+
354+
const errors = [];
355+
let saved = 0;
356+
results.forEach((r, i) => {
357+
if (r.status === "fulfilled") {
358+
saved++;
359+
} else {
360+
errors.push({
361+
territory: prices[i].territory,
362+
message: r.reason?.message || "Unknown error",
363+
});
364+
}
365+
});
366+
367+
apiCache.deleteByPrefix(`sub:prices:${subId}:`);
368+
res.json({ saved, errors });
369+
}
370+
);
371+
372+
export default router;

0 commit comments

Comments
 (0)