Skip to content

Commit a8161b9

Browse files
committed
playorder: impl separate consume api
1 parent 47461f6 commit a8161b9

2 files changed

Lines changed: 231 additions & 0 deletions

File tree

src/index.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
import {
1818
cancelSubscription,
1919
googlePlayAcknowledgePurchase,
20+
googlePlayConsumePurchase,
2021
googlePlayGetEntitlements,
2122
googlePlayNotification,
2223
revokeSubscription,
@@ -120,6 +121,10 @@ async function handle(r, env, ctx) {
120121
// TODO: must be a POST request
121122
// g/ack/[vcode]?cid&purchaseToken&vcode[&force&sku&test]
122123
return googlePlayAcknowledgePurchase(env, r);
124+
} else if (p2 === "con") {
125+
// TODO: must be a POST request
126+
// g/con/[vcode]?cid&purchaseToken&vcode[&sku&test]
127+
return googlePlayConsumePurchase(env, r);
123128
} else if (p2 === "ent") {
124129
// TODO: must be a GET request
125130
// TODO: mere possession of cid is auth, right now

src/playorder.js

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3045,6 +3045,232 @@ export async function googlePlayAcknowledgePurchase(env, req) {
30453045
}
30463046
}
30473047

3048+
/**
3049+
* Consumes a Google Play one-time purchase if all conditions are met.
3050+
* Consumption can only be triggered within 30 days before expiry, or at any
3051+
* time after expiry provided the purchase has not already been fully consumed.
3052+
*
3053+
* @param {any} env - Worker environment.
3054+
* @param {Request} req - HTTP request containing purchase token and product ID for a one-time purchase to be consumed.
3055+
* @returns {Promise<Response>} - HTTP response indicating success or failure of the consumption.
3056+
*/
3057+
export async function googlePlayConsumePurchase(env, req) {
3058+
let test = false;
3059+
let purchasetoken = "";
3060+
let obstoken = "";
3061+
let cid = "";
3062+
let sku = "";
3063+
3064+
try {
3065+
if (req.method !== "POST") {
3066+
return r400j({ error: "method not allowed" });
3067+
}
3068+
3069+
const url = new URL(req.url);
3070+
purchasetoken =
3071+
url.searchParams.get("purchaseToken") ||
3072+
url.searchParams.get("purchasetoken");
3073+
cid = url.searchParams.get("cid");
3074+
sku =
3075+
url.searchParams.get("sku") ||
3076+
url.searchParams.get("productId") ||
3077+
url.searchParams.get("productid") ||
3078+
stdProductId;
3079+
test = url.searchParams.has("test");
3080+
3081+
if (emptyString(purchasetoken)) {
3082+
return r400j({ error: "missing purchase token" });
3083+
}
3084+
if (emptyString(sku)) {
3085+
return r400j({ error: "missing product id" });
3086+
}
3087+
if (!cid || cid.length < mincidlength || !/^[a-fA-F0-9]+$/.test(cid)) {
3088+
return r400j({ error: "missing/invalid client id" });
3089+
}
3090+
3091+
// consume only applies to onetime purchases
3092+
if (!knownOnetimeProductsAndPlans.has(sku)) {
3093+
return r400j({
3094+
error: "consume not applicable",
3095+
cid: cid,
3096+
sku: sku,
3097+
test: test,
3098+
});
3099+
}
3100+
3101+
obstoken = await obfuscate(purchasetoken);
3102+
3103+
return await als.run(new ExecCtx(env, test, obstoken), async () => {
3104+
const dbres = await dbx.playSub(dbx.db(env), purchasetoken);
3105+
if (dbres == null || dbres.results == null || dbres.results.length <= 0) {
3106+
return r400j({
3107+
error: "purchase not found",
3108+
cid: cid,
3109+
sku: sku,
3110+
purchaseId: test ? purchasetoken : obstoken,
3111+
test: test,
3112+
});
3113+
}
3114+
3115+
const entry = dbres.results[0];
3116+
const storedcid = entry.cid;
3117+
// identifiers must be immutable for onetime purchases
3118+
if (accountIdentifiersImmutable() && storedcid !== cid) {
3119+
return r400j({
3120+
error: "cid mismatch",
3121+
cid: cid,
3122+
storedCid: test ? storedcid : undefined,
3123+
purchaseId: test ? purchasetoken : obstoken,
3124+
sku: sku,
3125+
test: test,
3126+
});
3127+
}
3128+
3129+
const purchase2 = await getOnetimeProductV2(env, purchasetoken);
3130+
const testPurchase = isOnetimeTest2(purchase2);
3131+
const consumed = isOnetimeAllConsumed2(purchase2);
3132+
const productIds = allProducts2(purchase2);
3133+
const unconsumedProductIds = unconsumedProducts2(purchase2);
3134+
const paid = isOnetimePaid2(purchase2);
3135+
const pending = isOnetimeUnpaid2(purchase2);
3136+
const onetimeState = onetimePurchaseStateStr2(purchase2);
3137+
3138+
logi(
3139+
`onetime: ack/con ${onetimeState} for ${cid} / tok: ${obstoken} sku=${sku} ${productIds} / consumed? ${consumed} test? ${test}`,
3140+
);
3141+
3142+
// already fully consumed — after expiry this is fine; report success
3143+
if (consumed) {
3144+
logi(
3145+
`onetime: already consumed for ${cid} / tok: ${obstoken}; test? ${test}`,
3146+
);
3147+
return r200j({
3148+
success: true,
3149+
message: "onetime purchase already consumed",
3150+
cid: cid,
3151+
state: onetimeState,
3152+
allProducts: productIds,
3153+
unconsumedProducts: unconsumedProductIds,
3154+
purchaseId: test ? purchasetoken : obstoken,
3155+
expiry: gent.expiryDate,
3156+
sku: sku,
3157+
expired: expired,
3158+
test: test,
3159+
});
3160+
}
3161+
3162+
if (testPurchase !== test) {
3163+
return r400j({
3164+
error: "test domain mismatch",
3165+
purchaseId: testPurchase ? purchasetoken : obstoken,
3166+
state: onetimeState,
3167+
sku: sku,
3168+
allProducts: productIds,
3169+
unconsumedProducts: unconsumedProductIds,
3170+
test: test,
3171+
});
3172+
} // else: test === testPurchase
3173+
3174+
if (!paid || pending) {
3175+
return r400j({
3176+
error: "purchase not completed",
3177+
purchaseId: test ? purchasetoken : obstoken,
3178+
state: onetimeState,
3179+
sku: sku,
3180+
allProducts: productIds,
3181+
unconsumedProducts: unconsumedProductIds,
3182+
test: test,
3183+
});
3184+
}
3185+
3186+
const gent = onetimePlan(purchase2);
3187+
if (gent == null) {
3188+
// such purchases can only be cancelled/refunded
3189+
return r400j({
3190+
error: "not a valid product; will be auto refunded",
3191+
purchaseId: test ? purchasetoken : obstoken,
3192+
cid: cid,
3193+
state: onetimeState,
3194+
sku: sku,
3195+
allProducts: productIds,
3196+
unconsumedProducts: unconsumedProductIds,
3197+
test: test,
3198+
});
3199+
}
3200+
3201+
const now = Date.now();
3202+
const expiryMs = gent.expiry.getTime();
3203+
const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000;
3204+
const expired = now >= expiryMs;
3205+
const withinConsumeWindow = now >= expiryMs - thirtyDaysMs; // within 30d before or after expiry
3206+
3207+
// consume is only allowed within 30d of expiry or anytime after expiry
3208+
if (!withinConsumeWindow) {
3209+
return r400j({
3210+
error: "too early to consume",
3211+
purchaseId: test ? purchasetoken : obstoken,
3212+
cid: cid,
3213+
state: onetimeState,
3214+
sku: sku,
3215+
allProducts: productIds,
3216+
unconsumedProducts: unconsumedProductIds,
3217+
expiry: gent.expiryDate,
3218+
test: test,
3219+
});
3220+
}
3221+
3222+
try {
3223+
await consumeOnetimePurchases(
3224+
env,
3225+
cid,
3226+
unconsumedProductIds,
3227+
purchasetoken,
3228+
);
3229+
} catch (e) {
3230+
loge(`onetime: err consume: ${cid} / tok: ${obstoken}: ${e.message}`);
3231+
return r500j({
3232+
error: "failed to consume",
3233+
details: e.message,
3234+
purchaseId: test ? purchasetoken : obstoken,
3235+
cid: cid,
3236+
sku: sku,
3237+
allProducts: productIds,
3238+
unconsumedProducts: unconsumedProductIds,
3239+
expiry: gent.expiryDate,
3240+
test: test,
3241+
});
3242+
}
3243+
3244+
logi(
3245+
`onetime: ack/con done for ${cid} / tok: ${obstoken}; expired? ${expired} test? ${test}`,
3246+
);
3247+
3248+
return r200j({
3249+
success: true,
3250+
message: "onetime purchase consumed",
3251+
cid: cid,
3252+
state: onetimeState,
3253+
allProducts: productIds,
3254+
unconsumedProducts: unconsumedProductIds,
3255+
purchaseId: test ? purchasetoken : obstoken,
3256+
expiry: gent.expiryDate,
3257+
sku: sku,
3258+
test: test,
3259+
});
3260+
});
3261+
} catch (err) {
3262+
return r500j({
3263+
error: "consume failed",
3264+
details: err.message,
3265+
purchaseId: test ? purchasetoken : obstoken,
3266+
cid: cid,
3267+
sku: sku,
3268+
allProducts: [],
3269+
test: test,
3270+
});
3271+
}
3272+
}
3273+
30483274
/**
30493275
* Retrieves stored WSEntitlement for the requesting client.
30503276
*

0 commit comments

Comments
 (0)