@onesub/server 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/webhook-apple.test.d.ts +12 -0
- package/dist/__tests__/webhook-apple.test.d.ts.map +1 -0
- package/dist/__tests__/webhook-google.test.d.ts +13 -0
- package/dist/__tests__/webhook-google.test.d.ts.map +1 -0
- package/dist/index.cjs +479 -284
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +479 -283
- package/dist/index.js.map +1 -1
- package/dist/providers/apple.d.ts +76 -0
- package/dist/providers/apple.d.ts.map +1 -1
- package/dist/providers/google.d.ts +25 -0
- package/dist/providers/google.d.ts.map +1 -1
- package/dist/routes/admin.d.ts.map +1 -1
- package/dist/routes/apple-offer.d.ts +21 -0
- package/dist/routes/apple-offer.d.ts.map +1 -0
- package/dist/routes/webhook-apple.d.ts +6 -0
- package/dist/routes/webhook-apple.d.ts.map +1 -0
- package/dist/routes/webhook-google.d.ts +7 -0
- package/dist/routes/webhook-google.d.ts.map +1 -0
- package/dist/routes/webhook.d.ts +4 -17
- package/dist/routes/webhook.d.ts.map +1 -1
- package/package.json +2 -2
package/dist/index.cjs
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
Object.defineProperty(exports, '__esModule', { value: true });
|
|
4
|
-
|
|
5
3
|
var express = require('express');
|
|
6
4
|
var shared = require('@onesub/shared');
|
|
7
5
|
var zod = require('zod');
|
|
@@ -479,7 +477,9 @@ async function decodeAppleNotification(payload, skipJwsVerification = false) {
|
|
|
479
477
|
environment,
|
|
480
478
|
status,
|
|
481
479
|
willRenew,
|
|
482
|
-
expiresAt: tx.expiresDate ? new Date(tx.expiresDate).toISOString() : null
|
|
480
|
+
expiresAt: tx.expiresDate ? new Date(tx.expiresDate).toISOString() : null,
|
|
481
|
+
appAccountToken: tx.appAccountToken ?? null,
|
|
482
|
+
inAppOwnershipType: tx.inAppOwnershipType ?? null
|
|
483
483
|
};
|
|
484
484
|
}
|
|
485
485
|
var appleJwtMintPromises = /* @__PURE__ */ new Map();
|
|
@@ -625,6 +625,91 @@ async function fetchAppleSubscriptionStatus(originalTransactionId, config, optio
|
|
|
625
625
|
willRenew: renewal?.autoRenewStatus === 1
|
|
626
626
|
};
|
|
627
627
|
}
|
|
628
|
+
async function fetchAppleTransactionHistory(originalTransactionId, config, options) {
|
|
629
|
+
if (config.mockMode) return null;
|
|
630
|
+
let jwt;
|
|
631
|
+
try {
|
|
632
|
+
jwt = await makeAppleApiJwt(config);
|
|
633
|
+
} catch (err) {
|
|
634
|
+
log.warn("[onesub/apple] Cannot fetch transaction history \u2014 JWT mint failed:", err);
|
|
635
|
+
return null;
|
|
636
|
+
}
|
|
637
|
+
const host = options?.sandbox ? "api.storekit-sandbox.itunes.apple.com" : "api.storekit.itunes.apple.com";
|
|
638
|
+
const results = [];
|
|
639
|
+
let revision;
|
|
640
|
+
for (; ; ) {
|
|
641
|
+
const url = new URL(`https://${host}/inApps/v2/history/${encodeURIComponent(originalTransactionId)}`);
|
|
642
|
+
if (revision) url.searchParams.set("revision", revision);
|
|
643
|
+
let page;
|
|
644
|
+
try {
|
|
645
|
+
const resp = await fetchWithTimeout(url.toString(), {
|
|
646
|
+
headers: { Authorization: `Bearer ${jwt}` }
|
|
647
|
+
});
|
|
648
|
+
if (!resp.ok) {
|
|
649
|
+
const text = await resp.text();
|
|
650
|
+
log.warn(`[onesub/apple] Transaction History API error ${resp.status}: ${text}`);
|
|
651
|
+
return null;
|
|
652
|
+
}
|
|
653
|
+
page = await resp.json();
|
|
654
|
+
} catch (err) {
|
|
655
|
+
log.warn("[onesub/apple] Transaction History API network error:", err);
|
|
656
|
+
return null;
|
|
657
|
+
}
|
|
658
|
+
for (const signed of page.signedTransactions ?? []) {
|
|
659
|
+
try {
|
|
660
|
+
const tx = await decodeJws(signed, config.skipJwsVerification);
|
|
661
|
+
if (!tx.originalTransactionId || !tx.transactionId || !tx.productId) continue;
|
|
662
|
+
results.push({
|
|
663
|
+
originalTransactionId: tx.originalTransactionId,
|
|
664
|
+
transactionId: tx.transactionId,
|
|
665
|
+
productId: tx.productId,
|
|
666
|
+
type: tx.type ?? "Unknown",
|
|
667
|
+
purchasedAt: tx.purchaseDate ? new Date(tx.purchaseDate).toISOString() : (/* @__PURE__ */ new Date()).toISOString(),
|
|
668
|
+
expiresAt: tx.expiresDate ? new Date(tx.expiresDate).toISOString() : null,
|
|
669
|
+
appAccountToken: tx.appAccountToken ?? null,
|
|
670
|
+
inAppOwnershipType: tx.inAppOwnershipType ?? null,
|
|
671
|
+
revocationDate: tx.revocationDate ? new Date(tx.revocationDate).toISOString() : null
|
|
672
|
+
});
|
|
673
|
+
} catch {
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
if (!page.hasMore) break;
|
|
677
|
+
revision = page.revision;
|
|
678
|
+
}
|
|
679
|
+
return results;
|
|
680
|
+
}
|
|
681
|
+
async function signApplePromotionalOffer(input, config) {
|
|
682
|
+
const { offerKeyId, offerPrivateKey } = config;
|
|
683
|
+
if (!offerKeyId || !offerPrivateKey) {
|
|
684
|
+
throw new Error("[onesub/apple] Promotional Offer signing requires config.apple.offerKeyId and config.apple.offerPrivateKey");
|
|
685
|
+
}
|
|
686
|
+
const nonce = crypto.randomUUID().toLowerCase();
|
|
687
|
+
const timestamp = Date.now();
|
|
688
|
+
const separator = "\u2063";
|
|
689
|
+
const message = [
|
|
690
|
+
input.bundleId,
|
|
691
|
+
offerKeyId,
|
|
692
|
+
input.productId,
|
|
693
|
+
input.offerId,
|
|
694
|
+
input.applicationUsername,
|
|
695
|
+
nonce,
|
|
696
|
+
String(timestamp)
|
|
697
|
+
].join(separator);
|
|
698
|
+
if (!offerPrivateKey.includes("BEGIN") || !offerPrivateKey.includes("END")) {
|
|
699
|
+
throw new Error("[onesub/apple] offerPrivateKey does not appear to be PEM-encoded");
|
|
700
|
+
}
|
|
701
|
+
let signatureBuffer;
|
|
702
|
+
try {
|
|
703
|
+
const sign = crypto.createSign("SHA256");
|
|
704
|
+
sign.update(message);
|
|
705
|
+
sign.end();
|
|
706
|
+
signatureBuffer = sign.sign(offerPrivateKey);
|
|
707
|
+
} catch (err) {
|
|
708
|
+
throw new Error(`[onesub/apple] Failed to sign promotional offer \u2014 check that offerPrivateKey is a valid ES256 PEM key: ${err.message}`);
|
|
709
|
+
}
|
|
710
|
+
const signature = signatureBuffer.toString("base64");
|
|
711
|
+
return { keyId: offerKeyId, nonce, timestamp, signature };
|
|
712
|
+
}
|
|
628
713
|
var GOOGLE_NOTIFICATION_TYPE = {
|
|
629
714
|
SUBSCRIPTION_RECOVERED: 1,
|
|
630
715
|
SUBSCRIPTION_RENEWED: 2,
|
|
@@ -687,8 +772,8 @@ async function getAccessToken(serviceAccountKey) {
|
|
|
687
772
|
})
|
|
688
773
|
).toString("base64url");
|
|
689
774
|
const signingInput = `${header}.${payload}`;
|
|
690
|
-
const { createSign } = await import('crypto');
|
|
691
|
-
const sign =
|
|
775
|
+
const { createSign: createSign2 } = await import('crypto');
|
|
776
|
+
const sign = createSign2("RSA-SHA256");
|
|
692
777
|
sign.update(signingInput);
|
|
693
778
|
const signature = sign.sign(key.private_key, "base64url");
|
|
694
779
|
const assertion = `${signingInput}.${signature}`;
|
|
@@ -732,6 +817,7 @@ async function fetchProductPurchase(packageName, productId, purchaseToken, acces
|
|
|
732
817
|
async function acknowledgeGoogleSubscription(purchaseToken, productId, config) {
|
|
733
818
|
if (config.mockMode) return;
|
|
734
819
|
if (!config.serviceAccountKey) return;
|
|
820
|
+
if (!config.packageName) return;
|
|
735
821
|
let accessToken;
|
|
736
822
|
try {
|
|
737
823
|
accessToken = await getCachedAccessToken(config.serviceAccountKey);
|
|
@@ -757,6 +843,7 @@ async function acknowledgeGoogleSubscription(purchaseToken, productId, config) {
|
|
|
757
843
|
async function acknowledgeGoogleProduct(purchaseToken, productId, config) {
|
|
758
844
|
if (config.mockMode) return;
|
|
759
845
|
if (!config.serviceAccountKey) return;
|
|
846
|
+
if (!config.packageName) return;
|
|
760
847
|
let accessToken;
|
|
761
848
|
try {
|
|
762
849
|
accessToken = await getCachedAccessToken(config.serviceAccountKey);
|
|
@@ -781,6 +868,7 @@ async function acknowledgeGoogleProduct(purchaseToken, productId, config) {
|
|
|
781
868
|
}
|
|
782
869
|
async function consumeGoogleProductReceipt(purchaseToken, productId, config) {
|
|
783
870
|
if (!config.serviceAccountKey) return;
|
|
871
|
+
if (!config.packageName) return;
|
|
784
872
|
let accessToken;
|
|
785
873
|
try {
|
|
786
874
|
accessToken = await getCachedAccessToken(config.serviceAccountKey);
|
|
@@ -828,6 +916,10 @@ async function validateGoogleReceipt(receipt, productId, config) {
|
|
|
828
916
|
log.warn("[onesub/google] No serviceAccountKey provided \u2014 cannot call Play API");
|
|
829
917
|
return null;
|
|
830
918
|
}
|
|
919
|
+
if (!config.packageName) {
|
|
920
|
+
log.warn("[onesub/google] No packageName provided \u2014 cannot call Play API");
|
|
921
|
+
return null;
|
|
922
|
+
}
|
|
831
923
|
let purchase;
|
|
832
924
|
try {
|
|
833
925
|
const token = await getCachedAccessToken(config.serviceAccountKey);
|
|
@@ -880,6 +972,10 @@ async function validateGoogleProductReceipt(purchaseToken, productId, config, ty
|
|
|
880
972
|
log.warn("[onesub/google] No serviceAccountKey \u2014 cannot validate product receipt");
|
|
881
973
|
return null;
|
|
882
974
|
}
|
|
975
|
+
if (!config.packageName) {
|
|
976
|
+
log.warn("[onesub/google] No packageName \u2014 cannot validate product receipt");
|
|
977
|
+
return null;
|
|
978
|
+
}
|
|
883
979
|
let purchase;
|
|
884
980
|
try {
|
|
885
981
|
const token = await getCachedAccessToken(config.serviceAccountKey);
|
|
@@ -949,6 +1045,22 @@ function decodeGoogleVoidedNotification(payload) {
|
|
|
949
1045
|
packageName: notification.packageName
|
|
950
1046
|
};
|
|
951
1047
|
}
|
|
1048
|
+
function decodeGoogleOneTimeProductNotification(payload) {
|
|
1049
|
+
let notification;
|
|
1050
|
+
try {
|
|
1051
|
+
const json = Buffer.from(payload.message.data, "base64").toString("utf-8");
|
|
1052
|
+
notification = JSON.parse(json);
|
|
1053
|
+
} catch {
|
|
1054
|
+
return null;
|
|
1055
|
+
}
|
|
1056
|
+
if (!notification.oneTimeProductNotification) return null;
|
|
1057
|
+
const { notificationType, purchaseToken, sku } = notification.oneTimeProductNotification;
|
|
1058
|
+
if (notificationType !== 1 && notificationType !== 2) {
|
|
1059
|
+
log.warn("[onesub/google] Unknown oneTimeProductNotification type:", notificationType);
|
|
1060
|
+
return null;
|
|
1061
|
+
}
|
|
1062
|
+
return { notificationType, purchaseToken, sku, packageName: notification.packageName };
|
|
1063
|
+
}
|
|
952
1064
|
function isGoogleActiveNotification(notificationType) {
|
|
953
1065
|
return notificationType === GOOGLE_NOTIFICATION_TYPE.SUBSCRIPTION_PURCHASED || notificationType === GOOGLE_NOTIFICATION_TYPE.SUBSCRIPTION_RENEWED || notificationType === GOOGLE_NOTIFICATION_TYPE.SUBSCRIPTION_RECOVERED || notificationType === GOOGLE_NOTIFICATION_TYPE.SUBSCRIPTION_RESTARTED;
|
|
954
1066
|
}
|
|
@@ -1072,41 +1184,9 @@ function createStatusRouter(store) {
|
|
|
1072
1184
|
});
|
|
1073
1185
|
return router;
|
|
1074
1186
|
}
|
|
1075
|
-
var
|
|
1076
|
-
var
|
|
1077
|
-
|
|
1078
|
-
if (!googleJwks) {
|
|
1079
|
-
googleJwks = jose.createRemoteJWKSet(new URL(GOOGLE_JWKS_URL));
|
|
1080
|
-
}
|
|
1081
|
-
return googleJwks;
|
|
1082
|
-
}
|
|
1083
|
-
async function verifyGooglePushToken(req, expectedAudience) {
|
|
1084
|
-
const authHeader = req.headers["authorization"];
|
|
1085
|
-
if (typeof authHeader !== "string" || !authHeader.startsWith("Bearer ")) {
|
|
1086
|
-
return false;
|
|
1087
|
-
}
|
|
1088
|
-
const token = authHeader.slice("Bearer ".length).trim();
|
|
1089
|
-
try {
|
|
1090
|
-
await jose.jwtVerify(token, getGoogleJwks(), { audience: expectedAudience });
|
|
1091
|
-
return true;
|
|
1092
|
-
} catch {
|
|
1093
|
-
return false;
|
|
1094
|
-
}
|
|
1095
|
-
}
|
|
1096
|
-
var APPLE_ACTIVE_TYPES = /* @__PURE__ */ new Set([
|
|
1097
|
-
"SUBSCRIBED",
|
|
1098
|
-
"DID_RENEW",
|
|
1099
|
-
"DID_RECOVER",
|
|
1100
|
-
"OFFER_REDEEMED"
|
|
1101
|
-
]);
|
|
1102
|
-
var APPLE_CANCELED_TYPES = /* @__PURE__ */ new Set([
|
|
1103
|
-
"REVOKE",
|
|
1104
|
-
"REFUND",
|
|
1105
|
-
"CONSUMPTION_REQUEST"
|
|
1106
|
-
]);
|
|
1107
|
-
var APPLE_EXPIRED_TYPES = /* @__PURE__ */ new Set([
|
|
1108
|
-
"EXPIRED"
|
|
1109
|
-
]);
|
|
1187
|
+
var APPLE_ACTIVE_TYPES = /* @__PURE__ */ new Set(["SUBSCRIBED", "DID_RENEW", "DID_RECOVER", "OFFER_REDEEMED"]);
|
|
1188
|
+
var APPLE_CANCELED_TYPES = /* @__PURE__ */ new Set(["REVOKE", "REFUND", "CONSUMPTION_REQUEST"]);
|
|
1189
|
+
var APPLE_EXPIRED_TYPES = /* @__PURE__ */ new Set(["EXPIRED"]);
|
|
1110
1190
|
function mapAppleNotificationStatus(notificationType, subtype) {
|
|
1111
1191
|
if (notificationType === "DID_FAIL_TO_RENEW") {
|
|
1112
1192
|
return subtype === "GRACE_PERIOD" ? shared.SUBSCRIPTION_STATUS.GRACE_PERIOD : shared.SUBSCRIPTION_STATUS.ON_HOLD;
|
|
@@ -1117,283 +1197,315 @@ function mapAppleNotificationStatus(notificationType, subtype) {
|
|
|
1117
1197
|
if (APPLE_ACTIVE_TYPES.has(notificationType)) return shared.SUBSCRIPTION_STATUS.ACTIVE;
|
|
1118
1198
|
return null;
|
|
1119
1199
|
}
|
|
1120
|
-
function
|
|
1121
|
-
const
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1200
|
+
async function handleAppleWebhook(req, res, config, store, purchaseStore, webhookEventStore) {
|
|
1201
|
+
const body = req.body;
|
|
1202
|
+
if (!body.signedPayload) {
|
|
1203
|
+
sendError(res, 400, shared.ONESUB_ERROR_CODE.MISSING_SIGNED_PAYLOAD, "Missing signedPayload");
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
let payload;
|
|
1207
|
+
try {
|
|
1208
|
+
payload = await decodeJws(
|
|
1209
|
+
body.signedPayload,
|
|
1210
|
+
config.apple?.skipJwsVerification
|
|
1211
|
+
);
|
|
1212
|
+
} catch (err) {
|
|
1213
|
+
log.error("[onesub/webhook/apple] Failed to decode signedPayload:", err);
|
|
1214
|
+
sendError(res, 400, shared.ONESUB_ERROR_CODE.INVALID_SIGNED_PAYLOAD, "Invalid signedPayload");
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
if (webhookEventStore && typeof payload.notificationUUID === "string") {
|
|
1218
|
+
const fresh = await webhookEventStore.markIfNew("apple", payload.notificationUUID);
|
|
1219
|
+
if (!fresh) {
|
|
1220
|
+
log.info("[onesub/webhook/apple] dedupe: already processed", payload.notificationUUID);
|
|
1221
|
+
res.status(200).json({ received: true, deduped: true });
|
|
1137
1222
|
return;
|
|
1138
1223
|
}
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1224
|
+
}
|
|
1225
|
+
const decoded = await decodeAppleNotification(payload, config.apple?.skipJwsVerification);
|
|
1226
|
+
if (!decoded) {
|
|
1227
|
+
res.status(200).json({ received: true });
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1230
|
+
const {
|
|
1231
|
+
originalTransactionId,
|
|
1232
|
+
transactionId,
|
|
1233
|
+
type,
|
|
1234
|
+
productId,
|
|
1235
|
+
bundleId,
|
|
1236
|
+
environment,
|
|
1237
|
+
status,
|
|
1238
|
+
willRenew,
|
|
1239
|
+
expiresAt,
|
|
1240
|
+
appAccountToken,
|
|
1241
|
+
inAppOwnershipType
|
|
1242
|
+
} = decoded;
|
|
1243
|
+
const notificationType = payload.notificationType;
|
|
1244
|
+
const subtype = payload.subtype;
|
|
1245
|
+
const mapped = mapAppleNotificationStatus(notificationType, subtype);
|
|
1246
|
+
const finalStatus = mapped ?? status;
|
|
1247
|
+
if (notificationType === "CONSUMPTION_REQUEST" && config.apple?.consumptionInfoProvider && transactionId && productId) {
|
|
1248
|
+
const provider = config.apple.consumptionInfoProvider;
|
|
1249
|
+
const appleConfig = config.apple;
|
|
1250
|
+
void (async () => {
|
|
1251
|
+
try {
|
|
1252
|
+
const info = await provider({
|
|
1253
|
+
transactionId,
|
|
1254
|
+
originalTransactionId,
|
|
1255
|
+
productId,
|
|
1256
|
+
bundleId: bundleId ?? appleConfig.bundleId,
|
|
1257
|
+
environment
|
|
1258
|
+
});
|
|
1259
|
+
if (info) {
|
|
1260
|
+
await sendAppleConsumptionResponse(transactionId, info, appleConfig, {
|
|
1261
|
+
sandbox: environment === "Sandbox"
|
|
1262
|
+
});
|
|
1263
|
+
}
|
|
1264
|
+
} catch (err) {
|
|
1265
|
+
log.warn("[onesub/webhook/apple] consumptionInfoProvider failed:", err);
|
|
1266
|
+
}
|
|
1267
|
+
})();
|
|
1268
|
+
}
|
|
1269
|
+
const isOneTimePurchase = type === "Consumable" || type === "Non-Consumable";
|
|
1270
|
+
const isRefundOrRevoke = APPLE_CANCELED_TYPES.has(notificationType);
|
|
1271
|
+
try {
|
|
1272
|
+
if (isOneTimePurchase && isRefundOrRevoke) {
|
|
1273
|
+
const lookupId = transactionId ?? originalTransactionId;
|
|
1274
|
+
const removed = await purchaseStore.deletePurchaseByTransactionId(lookupId);
|
|
1275
|
+
if (!removed) {
|
|
1276
|
+
log.warn("[onesub/webhook/apple] IAP refund for unknown transaction:", lookupId);
|
|
1145
1277
|
}
|
|
1146
|
-
}
|
|
1147
|
-
const decoded = await decodeAppleNotification(payload, config.apple?.skipJwsVerification);
|
|
1148
|
-
if (!decoded) {
|
|
1149
1278
|
res.status(200).json({ received: true });
|
|
1150
1279
|
return;
|
|
1151
1280
|
}
|
|
1152
|
-
const
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
status,
|
|
1160
|
-
willRenew,
|
|
1161
|
-
expiresAt
|
|
1162
|
-
} = decoded;
|
|
1163
|
-
const notificationType = payload.notificationType;
|
|
1164
|
-
const subtype = payload.subtype;
|
|
1165
|
-
const mapped = mapAppleNotificationStatus(notificationType, subtype);
|
|
1166
|
-
const finalStatus = mapped ?? status;
|
|
1167
|
-
if (notificationType === "CONSUMPTION_REQUEST" && config.apple?.consumptionInfoProvider && transactionId && productId) {
|
|
1168
|
-
const provider = config.apple.consumptionInfoProvider;
|
|
1169
|
-
const appleConfig = config.apple;
|
|
1170
|
-
void (async () => {
|
|
1171
|
-
try {
|
|
1172
|
-
const info = await provider({
|
|
1173
|
-
transactionId,
|
|
1174
|
-
originalTransactionId,
|
|
1175
|
-
productId,
|
|
1176
|
-
bundleId: bundleId ?? appleConfig.bundleId,
|
|
1177
|
-
environment
|
|
1178
|
-
});
|
|
1179
|
-
if (info) {
|
|
1180
|
-
await sendAppleConsumptionResponse(transactionId, info, appleConfig, {
|
|
1181
|
-
sandbox: environment === "Sandbox"
|
|
1182
|
-
});
|
|
1183
|
-
}
|
|
1184
|
-
} catch (err) {
|
|
1185
|
-
log.warn("[onesub/webhook/apple] consumptionInfoProvider failed:", err);
|
|
1186
|
-
}
|
|
1187
|
-
})();
|
|
1188
|
-
}
|
|
1189
|
-
const isOneTimePurchase = type === "Consumable" || type === "Non-Consumable";
|
|
1190
|
-
const isRefundOrRevoke = APPLE_CANCELED_TYPES.has(notificationType);
|
|
1191
|
-
try {
|
|
1192
|
-
if (isOneTimePurchase && isRefundOrRevoke) {
|
|
1193
|
-
const lookupId = transactionId ?? originalTransactionId;
|
|
1194
|
-
const removed = await purchaseStore.deletePurchaseByTransactionId(lookupId);
|
|
1195
|
-
if (!removed) {
|
|
1196
|
-
log.warn(
|
|
1197
|
-
"[onesub/webhook/apple] IAP refund for unknown transaction:",
|
|
1198
|
-
lookupId
|
|
1199
|
-
);
|
|
1200
|
-
}
|
|
1201
|
-
res.status(200).json({ received: true });
|
|
1202
|
-
return;
|
|
1281
|
+
const existing = await store.getByTransactionId(originalTransactionId);
|
|
1282
|
+
if (existing) {
|
|
1283
|
+
const isSubscriptionRefund = isRefundOrRevoke && !isOneTimePurchase && notificationType !== "CONSUMPTION_REQUEST";
|
|
1284
|
+
const keepEntitlement = isSubscriptionRefund && config.refundPolicy === "until_expiry";
|
|
1285
|
+
const correctedUserId = appAccountToken && existing.userId === originalTransactionId ? appAccountToken : existing.userId;
|
|
1286
|
+
if (correctedUserId !== existing.userId) {
|
|
1287
|
+
log.info("[onesub/webhook/apple] correcting userId from originalTransactionId to appAccountToken:", correctedUserId);
|
|
1203
1288
|
}
|
|
1204
|
-
const
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
if (fresh) {
|
|
1222
|
-
fresh.userId = originalTransactionId;
|
|
1223
|
-
await store.save(fresh);
|
|
1224
|
-
} else {
|
|
1225
|
-
log.warn(
|
|
1226
|
-
"[onesub/webhook/apple] Unknown transaction and Status API returned no record:",
|
|
1227
|
-
originalTransactionId
|
|
1228
|
-
);
|
|
1289
|
+
const updated = keepEntitlement ? { ...existing, userId: correctedUserId, willRenew: false } : {
|
|
1290
|
+
...existing,
|
|
1291
|
+
userId: correctedUserId,
|
|
1292
|
+
status: finalStatus,
|
|
1293
|
+
willRenew,
|
|
1294
|
+
expiresAt: expiresAt ?? existing.expiresAt
|
|
1295
|
+
};
|
|
1296
|
+
await store.save(updated);
|
|
1297
|
+
} else if (config.apple?.issuerId && config.apple?.keyId && config.apple?.privateKey) {
|
|
1298
|
+
const fresh = await fetchAppleSubscriptionStatus(originalTransactionId, config.apple, {
|
|
1299
|
+
sandbox: environment === "Sandbox"
|
|
1300
|
+
});
|
|
1301
|
+
if (fresh) {
|
|
1302
|
+
fresh.userId = appAccountToken ?? originalTransactionId;
|
|
1303
|
+
if (inAppOwnershipType === "FAMILY_SHARED") {
|
|
1304
|
+
const source = appAccountToken ? "appAccountToken" : "originalTransactionId (fallback)";
|
|
1305
|
+
log.info(`[onesub/webhook/apple] FAMILY_SHARED \u2014 userId: ${fresh.userId} (source: ${source})`);
|
|
1229
1306
|
}
|
|
1307
|
+
await store.save(fresh);
|
|
1230
1308
|
} else {
|
|
1231
1309
|
log.warn(
|
|
1232
|
-
"[onesub/webhook/apple]
|
|
1310
|
+
"[onesub/webhook/apple] Unknown transaction and Status API returned no record:",
|
|
1233
1311
|
originalTransactionId
|
|
1234
1312
|
);
|
|
1235
1313
|
}
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1314
|
+
} else {
|
|
1315
|
+
log.warn(
|
|
1316
|
+
"[onesub/webhook/apple] Received notification for unknown transaction (no API creds to recover):",
|
|
1317
|
+
originalTransactionId
|
|
1318
|
+
);
|
|
1240
1319
|
}
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1320
|
+
res.status(200).json({ received: true });
|
|
1321
|
+
} catch (err) {
|
|
1322
|
+
log.error("[onesub/webhook/apple] Store update error:", err);
|
|
1323
|
+
sendError(res, 500, shared.ONESUB_ERROR_CODE.WEBHOOK_PROCESSING_FAILED, "Failed to update subscription");
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
var GOOGLE_JWKS_URL = "https://www.googleapis.com/oauth2/v3/certs";
|
|
1327
|
+
var googleJwks = null;
|
|
1328
|
+
function getGoogleJwks() {
|
|
1329
|
+
if (!googleJwks) googleJwks = jose.createRemoteJWKSet(new URL(GOOGLE_JWKS_URL));
|
|
1330
|
+
return googleJwks;
|
|
1331
|
+
}
|
|
1332
|
+
async function verifyGooglePushToken(req, expectedAudience) {
|
|
1333
|
+
const authHeader = req.headers["authorization"];
|
|
1334
|
+
if (typeof authHeader !== "string" || !authHeader.startsWith("Bearer ")) return false;
|
|
1335
|
+
try {
|
|
1336
|
+
await jose.jwtVerify(authHeader.slice("Bearer ".length).trim(), getGoogleJwks(), {
|
|
1337
|
+
audience: expectedAudience
|
|
1338
|
+
});
|
|
1339
|
+
return true;
|
|
1340
|
+
} catch {
|
|
1341
|
+
return false;
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
async function handleGoogleWebhook(req, res, config, store, purchaseStore, webhookEventStore) {
|
|
1345
|
+
if (config.google?.pushAudience) {
|
|
1346
|
+
const authenticated = await verifyGooglePushToken(req, config.google.pushAudience);
|
|
1347
|
+
if (!authenticated) {
|
|
1348
|
+
sendError(res, 401, shared.ONESUB_ERROR_CODE.UNAUTHORIZED, "Unauthorized");
|
|
1349
|
+
return;
|
|
1249
1350
|
}
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1351
|
+
}
|
|
1352
|
+
const body = req.body;
|
|
1353
|
+
if (!body.message?.data) {
|
|
1354
|
+
sendError(res, 400, shared.ONESUB_ERROR_CODE.MISSING_MESSAGE_DATA, "Missing message.data");
|
|
1355
|
+
return;
|
|
1356
|
+
}
|
|
1357
|
+
if (webhookEventStore && typeof body.message.messageId === "string") {
|
|
1358
|
+
const fresh = await webhookEventStore.markIfNew("google", body.message.messageId);
|
|
1359
|
+
if (!fresh) {
|
|
1360
|
+
log.info("[onesub/webhook/google] dedupe: already processed", body.message.messageId);
|
|
1361
|
+
res.status(200).json({ received: true, deduped: true });
|
|
1253
1362
|
return;
|
|
1254
1363
|
}
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1364
|
+
}
|
|
1365
|
+
const voided = decodeGoogleVoidedNotification(body);
|
|
1366
|
+
if (voided) {
|
|
1367
|
+
if (config.google?.packageName && voided.packageName !== config.google.packageName) {
|
|
1368
|
+
log.warn("[onesub/webhook/google] voided package name mismatch:", voided.packageName, "!==", config.google.packageName);
|
|
1369
|
+
sendError(res, 400, shared.ONESUB_ERROR_CODE.PACKAGE_NAME_MISMATCH, "Package name mismatch");
|
|
1370
|
+
return;
|
|
1262
1371
|
}
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
"
|
|
1268
|
-
|
|
1269
|
-
"!==",
|
|
1270
|
-
config.google.packageName
|
|
1271
|
-
);
|
|
1272
|
-
sendError(res, 400, shared.ONESUB_ERROR_CODE.PACKAGE_NAME_MISMATCH, "Package name mismatch");
|
|
1273
|
-
return;
|
|
1274
|
-
}
|
|
1275
|
-
try {
|
|
1276
|
-
if (voided.productType === 1) {
|
|
1277
|
-
const existing = await store.getByTransactionId(voided.purchaseToken);
|
|
1278
|
-
if (existing) {
|
|
1279
|
-
const updated = config.refundPolicy === "until_expiry" ? { ...existing, willRenew: false } : { ...existing, status: shared.SUBSCRIPTION_STATUS.CANCELED };
|
|
1280
|
-
await store.save(updated);
|
|
1281
|
-
} else {
|
|
1282
|
-
log.warn(
|
|
1283
|
-
"[onesub/webhook/google] voided subscription for unknown purchaseToken:",
|
|
1284
|
-
voided.purchaseToken
|
|
1285
|
-
);
|
|
1286
|
-
}
|
|
1372
|
+
try {
|
|
1373
|
+
if (voided.productType === 1) {
|
|
1374
|
+
const existing = await store.getByTransactionId(voided.purchaseToken);
|
|
1375
|
+
if (existing) {
|
|
1376
|
+
const updated = config.refundPolicy === "until_expiry" ? { ...existing, willRenew: false } : { ...existing, status: shared.SUBSCRIPTION_STATUS.CANCELED };
|
|
1377
|
+
await store.save(updated);
|
|
1287
1378
|
} else {
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
}
|
|
1379
|
+
log.warn("[onesub/webhook/google] voided subscription for unknown purchaseToken:", voided.purchaseToken);
|
|
1380
|
+
}
|
|
1381
|
+
} else {
|
|
1382
|
+
const removed = await purchaseStore.deletePurchaseByTransactionId(voided.orderId);
|
|
1383
|
+
if (!removed) {
|
|
1384
|
+
log.warn("[onesub/webhook/google] voided IAP for unknown orderId:", voided.orderId);
|
|
1295
1385
|
}
|
|
1296
|
-
res.status(200).json({ received: true });
|
|
1297
|
-
} catch (err) {
|
|
1298
|
-
log.error("[onesub/webhook/google] voided notification error:", err);
|
|
1299
|
-
sendError(res, 500, shared.ONESUB_ERROR_CODE.WEBHOOK_PROCESSING_FAILED, "Failed to process voided notification");
|
|
1300
1386
|
}
|
|
1301
|
-
return;
|
|
1302
|
-
}
|
|
1303
|
-
const notification = decodeGoogleNotification(body);
|
|
1304
|
-
if (!notification) {
|
|
1305
1387
|
res.status(200).json({ received: true });
|
|
1306
|
-
|
|
1388
|
+
} catch (err) {
|
|
1389
|
+
log.error("[onesub/webhook/google] voided notification error:", err);
|
|
1390
|
+
sendError(res, 500, shared.ONESUB_ERROR_CODE.WEBHOOK_PROCESSING_FAILED, "Failed to process voided notification");
|
|
1307
1391
|
}
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
config.google.packageName
|
|
1315
|
-
);
|
|
1392
|
+
return;
|
|
1393
|
+
}
|
|
1394
|
+
const oneTimeProduct = decodeGoogleOneTimeProductNotification(body);
|
|
1395
|
+
if (oneTimeProduct) {
|
|
1396
|
+
if (config.google?.packageName && oneTimeProduct.packageName !== config.google.packageName) {
|
|
1397
|
+
log.warn("[onesub/webhook/google] oneTimeProduct package name mismatch:", oneTimeProduct.packageName, "!==", config.google.packageName);
|
|
1316
1398
|
sendError(res, 400, shared.ONESUB_ERROR_CODE.PACKAGE_NAME_MISMATCH, "Package name mismatch");
|
|
1317
1399
|
return;
|
|
1318
1400
|
}
|
|
1319
1401
|
try {
|
|
1320
|
-
const
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
} else if (isGooglePausedNotification(notificationType)) {
|
|
1329
|
-
finalStatus = shared.SUBSCRIPTION_STATUS.PAUSED;
|
|
1330
|
-
} else if (isGooglePriceChangeConfirmedNotification(notificationType)) {
|
|
1331
|
-
finalStatus = shared.SUBSCRIPTION_STATUS.ACTIVE;
|
|
1332
|
-
} else if (isGoogleCanceledNotification(notificationType)) {
|
|
1333
|
-
finalStatus = shared.SUBSCRIPTION_STATUS.CANCELED;
|
|
1334
|
-
} else if (isGoogleExpiredNotification(notificationType)) {
|
|
1335
|
-
finalStatus = shared.SUBSCRIPTION_STATUS.EXPIRED;
|
|
1402
|
+
const { notificationType: notificationType2, purchaseToken: purchaseToken2, sku } = oneTimeProduct;
|
|
1403
|
+
if (notificationType2 === 1) {
|
|
1404
|
+
log.info("[onesub/webhook/google] oneTimeProduct PURCHASED:", sku);
|
|
1405
|
+
if (config.google?.serviceAccountKey && config.google.packageName) {
|
|
1406
|
+
void acknowledgeGoogleProduct(purchaseToken2, sku, config.google).catch(
|
|
1407
|
+
(err) => log.warn(`[onesub/webhook/google] oneTimeProduct ack failed for SKU ${sku} \u2014 3-day auto-refund risk:`, err)
|
|
1408
|
+
);
|
|
1409
|
+
}
|
|
1336
1410
|
} else {
|
|
1337
|
-
|
|
1411
|
+
log.info("[onesub/webhook/google] oneTimeProduct CANCELED (pre-completion):", sku);
|
|
1338
1412
|
}
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1413
|
+
res.status(200).json({ received: true });
|
|
1414
|
+
} catch (err) {
|
|
1415
|
+
log.error("[onesub/webhook/google] oneTimeProduct error:", err);
|
|
1416
|
+
sendError(res, 500, shared.ONESUB_ERROR_CODE.WEBHOOK_PROCESSING_FAILED, "Failed to process oneTimeProduct notification");
|
|
1417
|
+
}
|
|
1418
|
+
return;
|
|
1419
|
+
}
|
|
1420
|
+
const notification = decodeGoogleNotification(body);
|
|
1421
|
+
if (!notification) {
|
|
1422
|
+
res.status(200).json({ received: true });
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1425
|
+
const { notificationType, purchaseToken, subscriptionId, packageName } = notification;
|
|
1426
|
+
if (config.google?.packageName && packageName !== config.google.packageName) {
|
|
1427
|
+
log.warn("[onesub/webhook/google] Package name mismatch:", packageName, "!==", config.google.packageName);
|
|
1428
|
+
sendError(res, 400, shared.ONESUB_ERROR_CODE.PACKAGE_NAME_MISMATCH, "Package name mismatch");
|
|
1429
|
+
return;
|
|
1430
|
+
}
|
|
1431
|
+
try {
|
|
1432
|
+
const existing = await store.getByTransactionId(purchaseToken);
|
|
1433
|
+
let finalStatus;
|
|
1434
|
+
if (isGoogleActiveNotification(notificationType)) {
|
|
1435
|
+
finalStatus = shared.SUBSCRIPTION_STATUS.ACTIVE;
|
|
1436
|
+
} else if (isGoogleGracePeriodNotification(notificationType)) {
|
|
1437
|
+
finalStatus = shared.SUBSCRIPTION_STATUS.GRACE_PERIOD;
|
|
1438
|
+
} else if (isGoogleOnHoldNotification(notificationType)) {
|
|
1439
|
+
finalStatus = shared.SUBSCRIPTION_STATUS.ON_HOLD;
|
|
1440
|
+
} else if (isGooglePausedNotification(notificationType)) {
|
|
1441
|
+
finalStatus = shared.SUBSCRIPTION_STATUS.PAUSED;
|
|
1442
|
+
} else if (isGooglePriceChangeConfirmedNotification(notificationType)) {
|
|
1443
|
+
finalStatus = shared.SUBSCRIPTION_STATUS.ACTIVE;
|
|
1444
|
+
} else if (isGoogleCanceledNotification(notificationType)) {
|
|
1445
|
+
finalStatus = shared.SUBSCRIPTION_STATUS.CANCELED;
|
|
1446
|
+
} else if (isGoogleExpiredNotification(notificationType)) {
|
|
1447
|
+
finalStatus = shared.SUBSCRIPTION_STATUS.EXPIRED;
|
|
1448
|
+
} else {
|
|
1449
|
+
finalStatus = shared.SUBSCRIPTION_STATUS.ACTIVE;
|
|
1450
|
+
}
|
|
1451
|
+
if (isGooglePriceChangeConfirmedNotification(notificationType) && config.google?.onPriceChangeConfirmed) {
|
|
1452
|
+
const hook = config.google.onPriceChangeConfirmed;
|
|
1453
|
+
void Promise.resolve().then(() => hook({ purchaseToken, subscriptionId, packageName })).catch((err) => log.warn("[onesub/webhook/google] onPriceChangeConfirmed hook failed:", err));
|
|
1454
|
+
}
|
|
1455
|
+
if (existing) {
|
|
1456
|
+
let updated = { ...existing, status: finalStatus };
|
|
1457
|
+
if (config.google?.serviceAccountKey) {
|
|
1458
|
+
const fresh = await validateGoogleReceipt(purchaseToken, subscriptionId, config.google);
|
|
1459
|
+
if (fresh) {
|
|
1460
|
+
const preserveNotificationStatus = finalStatus === shared.SUBSCRIPTION_STATUS.GRACE_PERIOD || finalStatus === shared.SUBSCRIPTION_STATUS.ON_HOLD;
|
|
1461
|
+
updated = {
|
|
1462
|
+
...existing,
|
|
1463
|
+
status: preserveNotificationStatus ? finalStatus : fresh.status,
|
|
1464
|
+
expiresAt: fresh.expiresAt,
|
|
1465
|
+
willRenew: fresh.willRenew,
|
|
1466
|
+
autoResumeTime: fresh.autoResumeTime,
|
|
1467
|
+
linkedPurchaseToken: fresh.linkedPurchaseToken ?? existing.linkedPurchaseToken
|
|
1468
|
+
};
|
|
1363
1469
|
}
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
);
|
|
1376
|
-
} else {
|
|
1377
|
-
fresh.userId = purchaseToken;
|
|
1378
|
-
}
|
|
1379
|
-
} else {
|
|
1380
|
-
fresh.userId = purchaseToken;
|
|
1470
|
+
}
|
|
1471
|
+
await store.save(updated);
|
|
1472
|
+
} else {
|
|
1473
|
+
if (config.google?.serviceAccountKey) {
|
|
1474
|
+
const fresh = await validateGoogleReceipt(purchaseToken, subscriptionId, config.google);
|
|
1475
|
+
if (fresh) {
|
|
1476
|
+
if (fresh.linkedPurchaseToken) {
|
|
1477
|
+
const previous = await store.getByTransactionId(fresh.linkedPurchaseToken);
|
|
1478
|
+
fresh.userId = previous ? previous.userId : purchaseToken;
|
|
1479
|
+
if (previous) {
|
|
1480
|
+
log.info(`[onesub/webhook/google] inherited userId ${previous.userId} from linkedPurchaseToken ${fresh.linkedPurchaseToken} \u2192 new token ${purchaseToken}`);
|
|
1381
1481
|
}
|
|
1382
|
-
|
|
1482
|
+
} else {
|
|
1483
|
+
fresh.userId = purchaseToken;
|
|
1383
1484
|
}
|
|
1384
|
-
|
|
1385
|
-
log.warn(
|
|
1386
|
-
"[onesub/webhook/google] Unknown purchase token and no serviceAccountKey to re-fetch:",
|
|
1387
|
-
purchaseToken
|
|
1388
|
-
);
|
|
1485
|
+
await store.save(fresh);
|
|
1389
1486
|
}
|
|
1487
|
+
} else {
|
|
1488
|
+
log.warn("[onesub/webhook/google] Unknown purchase token and no serviceAccountKey to re-fetch:", purchaseToken);
|
|
1390
1489
|
}
|
|
1391
|
-
res.status(200).json({ received: true });
|
|
1392
|
-
} catch (err) {
|
|
1393
|
-
log.error("[onesub/webhook/google] Error handling notification:", err);
|
|
1394
|
-
sendError(res, 500, shared.ONESUB_ERROR_CODE.WEBHOOK_PROCESSING_FAILED, "Failed to process notification");
|
|
1395
1490
|
}
|
|
1396
|
-
|
|
1491
|
+
res.status(200).json({ received: true });
|
|
1492
|
+
} catch (err) {
|
|
1493
|
+
log.error("[onesub/webhook/google] Error handling notification:", err);
|
|
1494
|
+
sendError(res, 500, shared.ONESUB_ERROR_CODE.WEBHOOK_PROCESSING_FAILED, "Failed to process notification");
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
// src/routes/webhook.ts
|
|
1499
|
+
function createWebhookRouter(config, store, purchaseStore, webhookEventStore) {
|
|
1500
|
+
const router = express.Router();
|
|
1501
|
+
router.post(
|
|
1502
|
+
shared.ROUTES.WEBHOOK_APPLE,
|
|
1503
|
+
(req, res) => handleAppleWebhook(req, res, config, store, purchaseStore, webhookEventStore)
|
|
1504
|
+
);
|
|
1505
|
+
router.post(
|
|
1506
|
+
shared.ROUTES.WEBHOOK_GOOGLE,
|
|
1507
|
+
(req, res) => handleGoogleWebhook(req, res, config, store, purchaseStore, webhookEventStore)
|
|
1508
|
+
);
|
|
1397
1509
|
return router;
|
|
1398
1510
|
}
|
|
1399
1511
|
var NO_PURCHASE = { valid: false, purchase: null };
|
|
@@ -1816,6 +1928,38 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
|
|
|
1816
1928
|
sendError(res, 500, shared.ONESUB_ERROR_CODE.STORE_ERROR, err.message ?? "customer error");
|
|
1817
1929
|
}
|
|
1818
1930
|
});
|
|
1931
|
+
const syncAppleParamsSchema = zod.z.object({
|
|
1932
|
+
originalTransactionId: zod.z.string().min(1).max(256)
|
|
1933
|
+
});
|
|
1934
|
+
router.post(shared.ROUTES.ADMIN_SYNC_APPLE, async (req, res) => {
|
|
1935
|
+
let params;
|
|
1936
|
+
try {
|
|
1937
|
+
params = syncAppleParamsSchema.parse(req.params);
|
|
1938
|
+
} catch {
|
|
1939
|
+
sendError(res, 400, shared.ONESUB_ERROR_CODE.INVALID_INPUT, "originalTransactionId required");
|
|
1940
|
+
return;
|
|
1941
|
+
}
|
|
1942
|
+
if (!config.apple?.issuerId || !config.apple?.keyId || !config.apple?.privateKey) {
|
|
1943
|
+
sendError(res, 400, shared.ONESUB_ERROR_CODE.APPLE_CONFIG_MISSING, "Apple API credentials not configured");
|
|
1944
|
+
return;
|
|
1945
|
+
}
|
|
1946
|
+
try {
|
|
1947
|
+
const { originalTransactionId } = params;
|
|
1948
|
+
const existingForEnv = await store.getByTransactionId(originalTransactionId);
|
|
1949
|
+
const isSandbox = req.query["sandbox"] === "true" || existingForEnv?.environment === "Sandbox";
|
|
1950
|
+
const fresh = await fetchAppleSubscriptionStatus(originalTransactionId, config.apple, { sandbox: isSandbox });
|
|
1951
|
+
if (!fresh) {
|
|
1952
|
+
sendError(res, 404, shared.ONESUB_ERROR_CODE.TRANSACTION_NOT_FOUND, "Apple Status API returned no record");
|
|
1953
|
+
return;
|
|
1954
|
+
}
|
|
1955
|
+
const existing = existingForEnv;
|
|
1956
|
+
fresh.userId = existing?.userId ?? originalTransactionId;
|
|
1957
|
+
await store.save(fresh);
|
|
1958
|
+
res.status(200).json({ ok: true, subscription: fresh });
|
|
1959
|
+
} catch (err) {
|
|
1960
|
+
sendError(res, 500, shared.ONESUB_ERROR_CODE.INTERNAL_ERROR, err.message ?? "sync error");
|
|
1961
|
+
}
|
|
1962
|
+
});
|
|
1819
1963
|
if (webhookQueue?.listDeadLetters) {
|
|
1820
1964
|
router.get("/onesub/admin/webhook-deadletters", async (_req, res) => {
|
|
1821
1965
|
try {
|
|
@@ -2061,6 +2205,55 @@ function createMetricsRouter(config, store, purchaseStore) {
|
|
|
2061
2205
|
});
|
|
2062
2206
|
return router;
|
|
2063
2207
|
}
|
|
2208
|
+
var OFFER_SECRET_HEADER = "x-onesub-offer-secret";
|
|
2209
|
+
var offerBodySchema = zod.z.object({
|
|
2210
|
+
productId: zod.z.string().min(1).max(256),
|
|
2211
|
+
offerId: zod.z.string().min(1).max(256),
|
|
2212
|
+
applicationUsername: zod.z.string().min(1).max(256)
|
|
2213
|
+
});
|
|
2214
|
+
function createAppleOfferRouter(config) {
|
|
2215
|
+
const apple = config.apple;
|
|
2216
|
+
if (!apple?.offerKeyId || !apple?.offerPrivateKey) return null;
|
|
2217
|
+
const router = express.Router();
|
|
2218
|
+
router.post("/onesub/apple/offer-signature", async (req, res) => {
|
|
2219
|
+
if (config.adminSecret) {
|
|
2220
|
+
const provided = req.headers[OFFER_SECRET_HEADER];
|
|
2221
|
+
if (typeof provided !== "string" || provided !== config.adminSecret) {
|
|
2222
|
+
sendError(res, 401, shared.ONESUB_ERROR_CODE.UNAUTHORIZED, "Unauthorized");
|
|
2223
|
+
return;
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
2226
|
+
let body;
|
|
2227
|
+
try {
|
|
2228
|
+
body = offerBodySchema.parse(req.body);
|
|
2229
|
+
} catch (err) {
|
|
2230
|
+
if (err instanceof zod.z.ZodError) {
|
|
2231
|
+
sendZodError(res, err);
|
|
2232
|
+
return;
|
|
2233
|
+
}
|
|
2234
|
+
throw err;
|
|
2235
|
+
}
|
|
2236
|
+
if (!apple.bundleId) {
|
|
2237
|
+
sendError(res, 400, shared.ONESUB_ERROR_CODE.APPLE_CONFIG_MISSING, "config.apple.bundleId is required for offer signing");
|
|
2238
|
+
return;
|
|
2239
|
+
}
|
|
2240
|
+
try {
|
|
2241
|
+
const result = await signApplePromotionalOffer(
|
|
2242
|
+
{
|
|
2243
|
+
bundleId: apple.bundleId,
|
|
2244
|
+
productId: body.productId,
|
|
2245
|
+
offerId: body.offerId,
|
|
2246
|
+
applicationUsername: body.applicationUsername
|
|
2247
|
+
},
|
|
2248
|
+
apple
|
|
2249
|
+
);
|
|
2250
|
+
res.status(200).json(result);
|
|
2251
|
+
} catch (err) {
|
|
2252
|
+
sendError(res, 500, shared.ONESUB_ERROR_CODE.INTERNAL_ERROR, err.message ?? "Offer signing failed");
|
|
2253
|
+
}
|
|
2254
|
+
});
|
|
2255
|
+
return router;
|
|
2256
|
+
}
|
|
2064
2257
|
|
|
2065
2258
|
// src/stores/schema.ts
|
|
2066
2259
|
var SUBSCRIPTIONS_SCHEMA_SQL = `
|
|
@@ -3047,6 +3240,8 @@ function createOneSubMiddleware(config) {
|
|
|
3047
3240
|
if (entitlementRouter) router.use(entitlementRouter);
|
|
3048
3241
|
const metricsRouter = createMetricsRouter(config, store, purchaseStore);
|
|
3049
3242
|
if (metricsRouter) router.use(metricsRouter);
|
|
3243
|
+
const appleOfferRouter = createAppleOfferRouter(config);
|
|
3244
|
+
if (appleOfferRouter) router.use(appleOfferRouter);
|
|
3050
3245
|
return router;
|
|
3051
3246
|
}
|
|
3052
3247
|
function createOneSubServer(config) {
|
|
@@ -3057,7 +3252,6 @@ function createOneSubServer(config) {
|
|
|
3057
3252
|
app.use(createOneSubMiddleware(config));
|
|
3058
3253
|
return app;
|
|
3059
3254
|
}
|
|
3060
|
-
var index_default = createOneSubMiddleware;
|
|
3061
3255
|
var isMain = typeof process !== "undefined" && process.argv[1] != null && new URL((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs', document.baseURI).href))).pathname.endsWith(process.argv[1].replace(/\\/g, "/"));
|
|
3062
3256
|
if (isMain) {
|
|
3063
3257
|
const config = {
|
|
@@ -3099,14 +3293,15 @@ exports.RedisSubscriptionStore = RedisSubscriptionStore;
|
|
|
3099
3293
|
exports.RedisWebhookEventStore = RedisWebhookEventStore;
|
|
3100
3294
|
exports.createOneSubMiddleware = createOneSubMiddleware;
|
|
3101
3295
|
exports.createOneSubServer = createOneSubServer;
|
|
3102
|
-
exports.default = index_default;
|
|
3103
3296
|
exports.evaluateEntitlement = evaluateEntitlement;
|
|
3104
3297
|
exports.fetchAppleSubscriptionStatus = fetchAppleSubscriptionStatus;
|
|
3298
|
+
exports.fetchAppleTransactionHistory = fetchAppleTransactionHistory;
|
|
3105
3299
|
exports.getDefaultCache = getDefaultCache;
|
|
3106
3300
|
exports.log = log;
|
|
3107
3301
|
exports.openapiHandler = openapiHandler;
|
|
3108
3302
|
exports.setDefaultCache = setDefaultCache;
|
|
3109
3303
|
exports.setLogger = setLogger;
|
|
3304
|
+
exports.signApplePromotionalOffer = signApplePromotionalOffer;
|
|
3110
3305
|
exports.validateAppleReceipt = validateAppleReceipt;
|
|
3111
3306
|
exports.validateGoogleReceipt = validateGoogleReceipt;
|
|
3112
3307
|
exports.withSpan = withSpan;
|