增加apple内购相关代码

This commit is contained in:
CounterFire2023 2023-07-31 16:58:16 +08:00
parent 520b5ce06f
commit a4e2cb14cd
12 changed files with 1061 additions and 1 deletions

4
.gitignore vendored
View File

@ -6,4 +6,6 @@ dist
tmp
target
boundle.log
google_cloud.json
google_cloud.json
.env.production
.env.development

13
.vscode/launch.json vendored
View File

@ -30,5 +30,18 @@
],
"type": "pwa-node"
},
{
"name": "Debug Test",
"request": "launch",
"runtimeArgs": [
"run-script",
"test"
],
"runtimeExecutable": "npm",
"skipFiles": [
"<node_internals>/**"
],
"type": "pwa-node"
},
]
}

View File

@ -5,6 +5,7 @@
"main": "index.js",
"scripts": {
"dev:api": "ts-node -r tsconfig-paths/register src/api.ts",
"test": "ts-node -r tsconfig-paths/register src/test.ts",
"dev:monitor": "ts-node -r tsconfig-paths/register src/google.monitor.ts",
"build": "tsc",
"prod:api": "NODE_ENV=production NODE_PATH=./dist node dist/api.js",
@ -28,6 +29,7 @@
"fastify-plugin": "^4.2.1",
"google-auth-library": "^8.5.2",
"googleapis": "^120.0.0",
"jose": "^4.14.4",
"mongoose": "^6.6.5",
"mongoose-findorcreate": "^3.0.0",
"nanoid": "^3.1.23",

View File

@ -0,0 +1,233 @@
import fetch from 'node-fetch'
import { v4 as uuidv4 } from 'uuid'
import * as jose from 'jose'
import {
CheckTestNotificationResponse,
ConsumptionRequest,
Environment,
HistoryResponse,
NotificationHistoryQuery,
NotificationHistoryRequest,
NotificationHistoryResponse,
OrderLookupResponse,
SendTestNotificationResponse,
StatusResponse,
SubscriptionStatusesQuery,
TransactionHistoryQuery,
TransactionInfoResponse,
} from './Models'
import { AppStoreError } from './Errors'
type HTTPMethod = 'GET' | 'POST'
interface QueryConvertible {
[key: string]: string | number | boolean | number[]
}
export class AppStoreServerAPI {
// The maximum age that an authentication token is allowed to have, as decided by Apple.
static readonly maxTokenAge: number = 3600 // seconds, = 1 hour
readonly environment: Environment
private readonly baseUrl: string
private readonly key: Promise<jose.KeyLike>
private readonly keyId: string
private readonly issuerId: string
private readonly bundleId: string
private token?: string
private tokenExpiry: Date = new Date(0)
/**
* @param key the key downloaded from App Store Connect in PEM-encoded PKCS8 format.
* @param keyId the id of the key, retrieved from App Store Connect
* @param issuerId your issuer ID, retrieved from App Store Connect
* @param bundleId bundle ID of your app
*/
constructor(key: string, keyId: string, issuerId: string, bundleId: string, environment = Environment.Production) {
this.key = jose.importPKCS8(key, 'ES256')
this.keyId = keyId
this.issuerId = issuerId
this.bundleId = bundleId
this.environment = environment
if (environment === Environment.Sandbox) {
this.baseUrl = 'https://api.storekit-sandbox.itunes.apple.com'
} else {
this.baseUrl = 'https://api.storekit.itunes.apple.com'
}
}
// API Endpoints
/**
* https://developer.apple.com/documentation/appstoreserverapi/get_transaction_history
*/
async getTransactionHistory(transactionId: string, query: TransactionHistoryQuery = {}): Promise<HistoryResponse> {
const path = this.addQuery(`/inApps/v1/history/${transactionId}`, { ...query })
return this.makeRequest('GET', path)
}
/**
* https://developer.apple.com/documentation/appstoreserverapi/get_transaction_info
*/
async getTransactionInfo(transactionId: string): Promise<TransactionInfoResponse> {
return this.makeRequest('GET', `/inApps/v1/transactions/${transactionId}`)
}
/**
* https://developer.apple.com/documentation/appstoreserverapi/get_all_subscription_statuses
*/
async getSubscriptionStatuses(transactionId: string, query: SubscriptionStatusesQuery = {}): Promise<StatusResponse> {
const path = this.addQuery(`/inApps/v1/subscriptions/${transactionId}`, { ...query })
return this.makeRequest('GET', path)
}
/**
* https://developer.apple.com/documentation/appstoreserverapi/look_up_order_id
*/
async lookupOrder(orderId: string): Promise<OrderLookupResponse> {
return this.makeRequest('GET', `/inApps/v1/lookup/${orderId}`)
}
/**
* https://developer.apple.com/documentation/appstoreserverapi/request_a_test_notification
*/
async requestTestNotification(): Promise<SendTestNotificationResponse> {
return this.makeRequest('POST', '/inApps/v1/notifications/test')
}
/**
* https://developer.apple.com/documentation/appstoreserverapi/get_test_notification_status
*/
async getTestNotificationStatus(id: string): Promise<CheckTestNotificationResponse> {
return this.makeRequest('GET', `/inApps/v1/notifications/test/${id}`)
}
/**
* https://developer.apple.com/documentation/appstoreserverapi/get_notification_history
*/
async getNotificationHistory(
request: NotificationHistoryRequest,
query: NotificationHistoryQuery = {},
): Promise<NotificationHistoryResponse> {
const path = this.addQuery('/inApps/v1/notifications/history', { ...query })
return this.makeRequest('POST', path, request)
}
/**
* https://developer.apple.com/documentation/appstoreserverapi/send_consumption_information
*/
async sendConsumption(transactionId: string, request: ConsumptionRequest) {
const path = `/inApps/v1/transactions/consumption/${transactionId}`
return this.makeRequest('POST', path, request)
}
/**
* Performs a network request against the API and handles the result.
*/
private async makeRequest(method: HTTPMethod, path: string, body?: any): Promise<any> {
const token = await this.getToken()
const url = this.baseUrl + path
const serializedBody = body ? JSON.stringify(body) : undefined
const result = await fetch(url, {
method: method,
body: serializedBody,
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
})
if (result.status === 200) {
return result.json()
}
switch (result.status) {
case 400:
case 404:
case 500:
const body = await result.json()
throw new AppStoreError(body.errorCode, body.errorMessage)
case 401:
this.token = undefined
throw new Error('The request is unauthorized; the JSON Web Token (JWT) is invalid.')
default:
throw new Error('An unknown error occurred')
}
}
/**
* Returns an existing authentication token (if its still valid) or generates a new one.
*/
private async getToken(): Promise<string> {
// Reuse previously created token if it hasn't expired.
if (this.token && !this.tokenExpired) return this.token
// Tokens must expire after at most 1 hour.
const now = new Date()
const expiry = new Date(now.getTime() + AppStoreServerAPI.maxTokenAge * 1000)
const expirySeconds = Math.floor(expiry.getTime() / 1000)
const payload = {
bid: this.bundleId,
nonce: uuidv4(),
}
const privateKey = await this.key
const jwt = await new jose.SignJWT(payload)
.setProtectedHeader({ alg: 'ES256', kid: this.keyId, typ: 'JWT' })
.setIssuer(this.issuerId)
.setIssuedAt()
.setExpirationTime(expirySeconds)
.setAudience('appstoreconnect-v1')
.sign(privateKey)
this.token = jwt
this.tokenExpiry = expiry
return jwt
}
/**
* Returns whether the previously generated token can still be used.
*/
private get tokenExpired(): boolean {
// We consider the token to be expired slightly before it actually is to allow for some networking latency.
const headroom = 60 // seconds
const now = new Date()
const cutoff = new Date(now.getTime() - headroom * 1000)
return !this.tokenExpiry || this.tokenExpiry < cutoff
}
/**
* Serializes a query object into a query string and appends it
* the provided path.
*/
private addQuery(path: string, query: QueryConvertible): string {
const params = new URLSearchParams()
for (const [key, value] of Object.entries(query)) {
if (Array.isArray(value)) {
for (const item of value) {
params.append(key, item.toString())
}
} else {
params.set(key, value.toString())
}
}
const queryString = params.toString()
if (queryString === '') {
return path
} else {
return `${path}?${queryString}`
}
}
}

View File

@ -0,0 +1,5 @@
// SHA-256 fingerprint of Apple's "Apple Root CA - G3 Root" certificate.
// The root certificate can be obtained here: https://www.apple.com/certificateauthority/
// This is used to verify that JWS have been signed using a key coming from Apple.
export const APPLE_ROOT_CA_G3_FINGERPRINT =
'63:34:3A:BF:B8:9A:6A:03:EB:B5:7E:9B:3F:5F:A7:BE:7C:4F:5C:75:6F:30:17:B3:A8:C4:88:C3:65:3E:91:79'

102
src/apple/Decoding.ts Normal file
View File

@ -0,0 +1,102 @@
import { X509Certificate } from 'crypto'
import * as jose from 'jose'
import { CertificateValidationError } from './Errors'
import {
DecodedNotificationPayload,
JWSRenewalInfo,
JWSRenewalInfoDecodedPayload,
JWSTransaction,
JWSTransactionDecodedPayload,
} from './Models'
import { APPLE_ROOT_CA_G3_FINGERPRINT } from './AppleRootCertificate'
export async function decodeTransactions(
signedTransactions: JWSTransaction[],
rootCertFingerprint?: string,
): Promise<JWSTransactionDecodedPayload[]> {
return Promise.all(signedTransactions.map(transaction => decodeJWS(transaction, rootCertFingerprint)))
}
export async function decodeTransaction(
transaction: JWSTransaction,
rootCertFingerprint?: string,
): Promise<JWSTransactionDecodedPayload> {
return decodeJWS(transaction, rootCertFingerprint)
}
export async function decodeRenewalInfo(
info: JWSRenewalInfo,
rootCertFingerprint?: string,
): Promise<JWSRenewalInfoDecodedPayload> {
return decodeJWS(info, rootCertFingerprint)
}
export async function decodeNotificationPayload(
payload: string,
rootCertFingerprint?: string,
): Promise<DecodedNotificationPayload> {
return decodeJWS(payload, rootCertFingerprint)
}
/**
* Decodes and verifies an object signed by the App Store according to JWS.
* See: https://developer.apple.com/documentation/appstoreserverapi/jwstransaction
* @param token JWS token
* @param rootCertFingerprint Root certificate to validate against. Defaults to Apple's G3 CA but can be overriden for testing purposes.
*/
async function decodeJWS(token: string, rootCertFingerprint: string = APPLE_ROOT_CA_G3_FINGERPRINT): Promise<any> {
// Extracts the key used to sign the JWS from the header of the token
const getKey: jose.CompactVerifyGetKey = async (protectedHeader, _token) => {
// RC 7515 stipulates that the key used to sign the JWS must be the first in the chain.
// https://datatracker.ietf.org/doc/html/rfc7515#section-4.1.6
// jose will not import the certificate unless it is in a proper PKCS8 format.
const certs = protectedHeader.x5c?.map(c => `-----BEGIN CERTIFICATE-----\n${c}\n-----END CERTIFICATE-----`) ?? []
validateCertificates(certs, rootCertFingerprint)
return jose.importX509(certs[0], 'ES256')
}
const { payload } = await jose.compactVerify(token, getKey)
const decoded = new TextDecoder().decode(payload)
const json = JSON.parse(decoded)
return json
}
/**
* Validates a certificate chain provided in the x5c field of a decoded header of a JWS.
* The certificates must be valid and have been signed by the provided
* @param certificates A chain of certificates
* @param rootCertFingerprint Expected SHA256 signature of the root certificate
* @throws {CertificateValidationError} if any of the validation checks fail
*/
function validateCertificates(certificates: string[], rootCertFingerprint: string) {
if (certificates.length === 0) throw new CertificateValidationError([])
const x509certs = certificates.map(c => new X509Certificate(c))
// Check dates
const now = new Date()
const datesValid = x509certs.every(c => new Date(c.validFrom) < now && now < new Date(c.validTo))
if (!datesValid) throw new CertificateValidationError(certificates)
// Check that each certificate, except for the last, is issued by the subsequent one.
if (certificates.length >= 2) {
for (let i = 0; i < x509certs.length - 1; i++) {
const subject = x509certs[i]
const issuer = x509certs[i + 1]
if (subject.checkIssued(issuer) === false || subject.verify(issuer.publicKey) === false) {
throw new CertificateValidationError(certificates)
}
}
}
// Ensure that the last certificate in the chain is the expected root CA.
if (x509certs[x509certs.length - 1].fingerprint256 !== rootCertFingerprint) {
throw new CertificateValidationError(certificates)
}
}

33
src/apple/Errors.ts Normal file
View File

@ -0,0 +1,33 @@
export class AppStoreError extends Error {
// The following errors indicate that the request can be tried again.
// See https://developer.apple.com/documentation/appstoreserverapi/error_codes
// for a list of all errors.
static readonly RETRYABLE_ERRORS = [
4040002, // AccountNotFoundRetryableError
4040004, // AppNotFoundRetryableError
5000001, // GeneralInternalRetryableError
4040006, // OriginalTransactionIdNotFoundRetryableError
]
errorCode: number
isRetryable: boolean
// https://developer.apple.com/documentation/appstoreserverapi/ratelimitexceedederror
isRateLimitExceeded: boolean
constructor(errorCode: number, errorMessage: string) {
super(errorMessage)
this.errorCode = errorCode
this.isRetryable = AppStoreError.RETRYABLE_ERRORS.includes(errorCode)
this.isRateLimitExceeded = errorCode === 4290000
}
}
export class CertificateValidationError extends Error {
certificates: string[]
constructor(certificates: string[]) {
super('Certificate validation failed')
this.certificates = certificates
}
}

535
src/apple/Models.ts Normal file
View File

@ -0,0 +1,535 @@
export enum Environment {
Production = 'Production',
Sandbox = 'Sandbox',
}
/**
* UNIX timestamp in milliseconds
*/
export type Timestamp = number
/**
* ISO 3166-1 Alpha-3 country code
* https://developer.apple.com/documentation/appstoreservernotifications/storefrontcountrycode
*/
export type StorefrontCountryCode = string
export enum SortParameter {
Ascending = 'ASCENDING',
Descending = 'DESCENDING',
}
export enum ProductTypeParameter {
AutoRenewable = 'AUTO_RENEWABLE',
NonRenewable = 'NON_RENEWABLE',
Consumable = 'CONSUMABLE',
NonConsumable = 'NON_CONSUMABLE',
}
/**
* The query parameters that can be passed to the history endpoint
* to filter results and change sort order.
* https://developer.apple.com/documentation/appstoreserverapi/get_transaction_history
*/
export interface TransactionHistoryQuery {
revision?: string
sort?: SortParameter
startDate?: Timestamp
endDate?: Timestamp
productType?: ProductTypeParameter
productId?: string
subscriptionGroupIdentifier?: string
inAppOwnershipType?: OwnershipType
revoked?: boolean
}
// https://developer.apple.com/documentation/appstoreserverapi/historyresponse
export interface HistoryResponse {
appAppleId: string
bundleId: string
environment: Environment
hasMore: boolean
revision: string
signedTransactions: JWSTransaction[]
}
export interface TransactionInfoResponse {
signedTransactionInfo: JWSTransaction
}
// https://developer.apple.com/documentation/appstoreserverapi/jwstransaction
export type JWSTransaction = string
// https://developer.apple.com/documentation/appstoreserverapi/jwsdecodedheader
export interface JWSDecodedHeader {
alg: string
kid: string
x5c: string[]
}
// https://developer.apple.com/documentation/appstoreserverapi/jwstransactiondecodedpayload
export interface JWSTransactionDecodedPayload {
appAccountToken?: string
bundleId: string
environment: Environment
expiresDate?: Timestamp
inAppOwnershipType: OwnershipType
isUpgraded?: boolean
offerIdentifier?: string
offerType?: OfferType
originalPurchaseDate: Timestamp
originalTransactionId: string
productId: string
purchaseDate: Timestamp
quantity: number
revocationDate?: Timestamp
revocationReason?: number
signedDate: Timestamp
storefront: StorefrontCountryCode
storefrontId: string
subscriptionGroupIdentifier?: string
transactionId: string
transactionReason: TransactionReason
type: TransactionType
webOrderLineItemId: string
}
// https://developer.apple.com/documentation/appstoreserverapi/inappownershiptype
export enum OwnershipType {
Purchased = 'PURCHASED',
FamilyShared = 'FAMILY_SHARED',
}
// https://developer.apple.com/documentation/appstoreserverapi/type
export enum TransactionType {
AutoRenewableSubscription = 'Auto-Renewable Subscription',
NonConsumable = 'Non-Consumable',
Consumable = 'Consumable',
NonRenewingSubscription = 'Non-Renewing Subscription',
}
// https://developer.apple.com/documentation/appstoreservernotifications/transactionreason
export enum TransactionReason {
Purchase = 'PURCHASE',
Renewal = 'RENEWAL',
}
export interface SubscriptionStatusesQuery {
status?: SubscriptionStatus[]
}
// https://developer.apple.com/documentation/appstoreserverapi/statusresponse
export interface StatusResponse {
data: SubscriptionGroupIdentifierItem[]
environment: Environment
appAppleId: string
bundleId: string
}
// https://developer.apple.com/documentation/appstoreserverapi/subscriptiongroupidentifieritem
export interface SubscriptionGroupIdentifierItem {
subscriptionGroupIdentifier: string
lastTransactions: LastTransactionsItem[]
}
// https://developer.apple.com/documentation/appstoreserverapi/lasttransactionsitem
export interface LastTransactionsItem {
originalTransactionId: string
status: SubscriptionStatus
signedRenewalInfo: JWSRenewalInfo
signedTransactionInfo: JWSTransaction
}
// https://developer.apple.com/documentation/appstoreserverapi/jwsrenewalinfo
export type JWSRenewalInfo = string
// https://developer.apple.com/documentation/appstoreserverapi/status
export enum SubscriptionStatus {
Active = 1,
Expired = 2,
InBillingRetry = 3,
InBillingGracePeriod = 4,
Revoked = 5,
}
// https://developer.apple.com/documentation/appstoreserverapi/jwsrenewalinfodecodedpayload
export interface JWSRenewalInfoDecodedPayload {
autoRenewProductId: string
autoRenewStatus: AutoRenewStatus
environment: Environment
expirationIntent?: ExpirationIntent
gracePeriodExpiresDate?: Timestamp
isInBillingRetryPeriod?: boolean
offerIdentifier?: string
offerType?: OfferType
originalTransactionId: string
priceIncreaseStatus?: PriceIncreaseStatus
productId: string
recentSubscriptionStartDate: Timestamp
renewalDate: Timestamp
signedDate: Timestamp
}
// https://developer.apple.com/documentation/appstoreserverapi/autorenewstatus
export enum AutoRenewStatus {
Off = 0,
On = 1,
}
// https://developer.apple.com/documentation/appstoreserverapi/expirationintent
export enum ExpirationIntent {
Canceled = 1,
BillingError = 2,
RejectedPriceIncrease = 3,
ProductUnavailable = 4,
}
// https://developer.apple.com/documentation/appstoreserverapi/offertype
export enum OfferType {
Introductory = 1,
Promotional = 2,
SubscriptionOfferCode = 3,
}
// https://developer.apple.com/documentation/appstoreserverapi/priceincreasestatus
export enum PriceIncreaseStatus {
NoResponse = 0,
Consented = 1,
}
// https://developer.apple.com/documentation/appstoreserverapi/orderlookupresponse
export interface OrderLookupResponse {
status: OrderLookupStatus
signedTransactions: JWSTransaction[]
}
// https://developer.apple.com/documentation/appstoreserverapi/orderlookupstatus
export enum OrderLookupStatus {
Valid = 0,
Invalid = 1,
}
// https://developer.apple.com/documentation/appstoreservernotifications/responsebodyv2decodedpayload
export interface DecodedNotificationPayload {
notificationType: NotificationType
subtype?: NotificationSubtype
notificationUUID: string
version: string
signedDate: Timestamp
data: NotificationData
summary: NotificationSummary
}
// https://developer.apple.com/documentation/appstoreservernotifications/data
export interface NotificationData {
appAppleId: string
bundleId: string
bundleVersion: number
environment: Environment
signedRenewalInfo: JWSRenewalInfo
signedTransactionInfo: JWSTransaction
status?: SubscriptionStatus
}
// https://developer.apple.com/documentation/appstoreservernotifications/summary
export interface NotificationSummary {
requestIdentifier: string
environment: Environment
appAppleId: string
bundleId: string
productId: string
storefrontCountryCodes?: StorefrontCountryCode[]
failedCount: number
succeededCount: number
}
// https://developer.apple.com/documentation/appstoreservernotifications/notificationtype
export enum NotificationType {
ConsumptionRequest = 'CONSUMPTION_REQUEST',
DidChangeRenewalPref = 'DID_CHANGE_RENEWAL_PREF',
DidChangeRenewalStatus = 'DID_CHANGE_RENEWAL_STATUS',
DidFailToRenew = 'DID_FAIL_TO_RENEW',
DidRenew = 'DID_RENEW',
Expired = 'EXPIRED',
GracePeriodExpired = 'GRACE_PERIOD_EXPIRED',
OfferRedeemed = 'OFFER_REDEEMED',
PriceIncrease = 'PRICE_INCREASE',
Refund = 'REFUND',
RefundDeclined = 'REFUND_DECLINED',
RenewalExtended = 'RENEWAL_EXTENDED',
Revoke = 'REVOKE',
Subscribed = 'SUBSCRIBED',
RenewalExtension = 'RENEWAL_EXTENSION',
RefundReversed = 'REFUND_REVERSED',
}
// https://developer.apple.com/documentation/appstoreservernotifications/subtype
export enum NotificationSubtype {
InitialBuy = 'INITIAL_BUY',
Resubscribe = 'RESUBSCRIBE',
Downgrade = 'DOWNGRADE',
Upgrade = 'UPGRADE',
AutoRenewEnabled = 'AUTO_RENEW_ENABLED',
AutoRenewDisabled = 'AUTO_RENEW_DISABLED',
Voluntary = 'VOLUNTARY',
BillingRetry = 'BILLING_RETRY',
PriceIncrease = 'PRICE_INCREASE',
GracePeriod = 'GRACE_PERIOD',
BillingRecovery = 'BILLING_RECOVERY',
Pending = 'PENDING',
Accepted = 'ACCEPTED',
Summary = 'SUMMARY',
Failure = 'FAILURE',
}
// https://developer.apple.com/documentation/appstoreserverapi/sendtestnotificationresponse
export interface SendTestNotificationResponse {
testNotificationToken: string
}
// https://developer.apple.com/documentation/appstoreserverapi/checktestnotificationresponse
export interface CheckTestNotificationResponse {
sendAttempts: SendAttempt[]
signedPayload: string
}
export interface SendAttempt {
attemptDate: Timestamp
sendAttemptResult: SendAttemptResult
}
// https://developer.apple.com/documentation/appstoreserverapi/sendattemptresult
export enum SendAttemptResult {
Success = 'SUCCESS',
TimedOut = 'TIMED_OUT',
TlsIssue = 'TLS_ISSUE',
CircularRedirect = 'CIRCULAR_REDIRECT',
NoResponse = 'NO_RESPONSE',
SocketIssue = 'SOCKET_ISSUE',
UnsupportedCharset = 'UNSUPPORTED_CHARSET',
InvalidResponse = 'INVALID_RESPONSE',
PrematureClose = 'PREMATURE_CLOSE',
Other = 'OTHER',
}
// https://developer.apple.com/documentation/appstoreserverapi/get_notification_history
export interface NotificationHistoryQuery {
paginationToken?: string
}
// https://developer.apple.com/documentation/appstoreserverapi/notificationhistoryrequest
export interface NotificationHistoryRequest {
startDate: Timestamp
endDate: Timestamp
notificationType?: NotificationType
notificationSubtype?: NotificationSubtype
onlyFailures?: boolean
transactionId?: string
}
// https://developer.apple.com/documentation/appstoreserverapi/notificationhistoryresponse
export interface NotificationHistoryResponse {
notificationHistory: NotificationHistoryResponseItem[]
hasMore: boolean
paginationToken: string
}
// https://developer.apple.com/documentation/appstoreserverapi/notificationhistoryresponseitem
export interface NotificationHistoryResponseItem {
sendAttempts: SendAttempt[]
signedPayload: string
}
/**
* The platform on which the customer consumed the in-app purchase.
*
* {@link https://developer.apple.com/documentation/appstoreserverapi/platform platform}
*/
export enum Platform {
UNDECLARED = 0,
APPLE = 1,
NON_APPLE = 2,
}
/**
* A value that indicates the extent to which the customer consumed the in-app purchase.
*
* {@link https://developer.apple.com/documentation/appstoreserverapi/consumptionstatus consumptionStatus}
*/
export enum ConsumptionStatus {
UNDECLARED = 0,
NOT_CONSUMED = 1,
PARTIALLY_CONSUMED = 2,
FULLY_CONSUMED = 3,
}
/**
* A value that indicates whether the app successfully delivered an in-app purchase that works properly.
*
* {@link https://developer.apple.com/documentation/appstoreserverapi/deliverystatus deliveryStatus}
*/
export enum DeliveryStatus {
DELIVERED_AND_WORKING_PROPERLY = 0,
DID_NOT_DELIVER_DUE_TO_QUALITY_ISSUE = 1,
DELIVERED_WRONG_ITEM = 2,
DID_NOT_DELIVER_DUE_TO_SERVER_OUTAGE = 3,
DID_NOT_DELIVER_DUE_TO_IN_GAME_CURRENCY_CHANGE = 4,
DID_NOT_DELIVER_FOR_OTHER_REASON = 5,
}
/**
* The age of the customers account.
*
* {@link https://developer.apple.com/documentation/appstoreserverapi/accounttenure accountTenure}
*/
export enum AccountTenure {
UNDECLARED = 0,
ZERO_TO_THREE_DAYS = 1,
THREE_DAYS_TO_TEN_DAYS = 2,
TEN_DAYS_TO_THIRTY_DAYS = 3,
THIRTY_DAYS_TO_NINETY_DAYS = 4,
NINETY_DAYS_TO_ONE_HUNDRED_EIGHTY_DAYS = 5,
ONE_HUNDRED_EIGHTY_DAYS_TO_THREE_HUNDRED_SIXTY_FIVE_DAYS = 6,
GREATER_THAN_THREE_HUNDRED_SIXTY_FIVE_DAYS = 7,
}
/**
* A value that indicates the amount of time that the customer used the app.
*
* {@link https://developer.apple.com/documentation/appstoreserverapi/playtime playTime}
*/
export enum PlayTime {
UNDECLARED = 0,
ZERO_TO_FIVE_MINUTES = 1,
FIVE_TO_SIXTY_MINUTES = 2,
ONE_TO_SIX_HOURS = 3,
SIX_HOURS_TO_TWENTY_FOUR_HOURS = 4,
ONE_DAY_TO_FOUR_DAYS = 5,
FOUR_DAYS_TO_SIXTEEN_DAYS = 6,
OVER_SIXTEEN_DAYS = 7,
}
/**
* A value that indicates the dollar amount of refunds the customer has received in your app, since purchasing the app, across all platforms.
*
* {@link https://developer.apple.com/documentation/appstoreserverapi/lifetimedollarsrefunded lifetimeDollarsRefunded}
*/
export enum LifetimeDollarsRefunded {
UNDECLARED = 0,
ZERO_DOLLARS = 1,
ONE_CENT_TO_FORTY_NINE_DOLLARS_AND_NINETY_NINE_CENTS = 2,
FIFTY_DOLLARS_TO_NINETY_NINE_DOLLARS_AND_NINETY_NINE_CENTS = 3,
ONE_HUNDRED_DOLLARS_TO_FOUR_HUNDRED_NINETY_NINE_DOLLARS_AND_NINETY_NINE_CENTS = 4,
FIVE_HUNDRED_DOLLARS_TO_NINE_HUNDRED_NINETY_NINE_DOLLARS_AND_NINETY_NINE_CENTS = 5,
ONE_THOUSAND_DOLLARS_TO_ONE_THOUSAND_NINE_HUNDRED_NINETY_NINE_DOLLARS_AND_NINETY_NINE_CENTS = 6,
TWO_THOUSAND_DOLLARS_OR_GREATER = 7,
}
/**
* A value that indicates the total amount, in USD, of in-app purchases the customer has made in your app, across all platforms.
*
* {@link https://developer.apple.com/documentation/appstoreserverapi/lifetimedollarspurchased lifetimeDollarsPurchased}
*/
export enum LifetimeDollarsPurchased {
UNDECLARED = 0,
ZERO_DOLLARS = 1,
ONE_CENT_TO_FORTY_NINE_DOLLARS_AND_NINETY_NINE_CENTS = 2,
FIFTY_DOLLARS_TO_NINETY_NINE_DOLLARS_AND_NINETY_NINE_CENTS = 3,
ONE_HUNDRED_DOLLARS_TO_FOUR_HUNDRED_NINETY_NINE_DOLLARS_AND_NINETY_NINE_CENTS = 4,
FIVE_HUNDRED_DOLLARS_TO_NINE_HUNDRED_NINETY_NINE_DOLLARS_AND_NINETY_NINE_CENTS = 5,
ONE_THOUSAND_DOLLARS_TO_ONE_THOUSAND_NINE_HUNDRED_NINETY_NINE_DOLLARS_AND_NINETY_NINE_CENTS = 6,
TWO_THOUSAND_DOLLARS_OR_GREATER = 7,
}
/**
* The status of a customers account within your app.
*
* {@link https://developer.apple.com/documentation/appstoreserverapi/userstatus userStatus}
*/
export enum UserStatus {
UNDECLARED = 0,
ACTIVE = 1,
SUSPENDED = 2,
TERMINATED = 3,
LIMITED_ACCESS = 4,
}
// https://developer.apple.com/documentation/appstoreserverapi/consumptionrequest
export interface ConsumptionRequest {
/**
* A Boolean value that indicates whether the customer consented to provide consumption data to the App Store.
*
* {@link https://developer.apple.com/documentation/appstoreserverapi/customerconsented customerConsented}
**/
customerConsented?: boolean
/**
* A value that indicates the extent to which the customer consumed the in-app purchase.
*
* {@link https://developer.apple.com/documentation/appstoreserverapi/consumptionstatus consumptionStatus}
**/
consumptionStatus?: ConsumptionStatus
/**
* A value that indicates the platform on which the customer consumed the in-app purchase.
*
* {@link https://developer.apple.com/documentation/appstoreserverapi/platform platform}
**/
platform?: Platform
/**
* A Boolean value that indicates whether you provided, prior to its purchase, a free sample or trial of the content, or information about its functionality.
*
* {@link https://developer.apple.com/documentation/appstoreserverapi/samplecontentprovided sampleContentProvided}
**/
sampleContentProvided?: boolean
/**
* A value that indicates whether the app successfully delivered an in-app purchase that works properly.
*
* {@link https://developer.apple.com/documentation/appstoreserverapi/deliverystatus deliveryStatus}
**/
deliveryStatus?: DeliveryStatus
/**
* The UUID that an app optionally generates to map a customers in-app purchase with its resulting App Store transaction.
*
* {@link https://developer.apple.com/documentation/appstoreserverapi/appaccounttoken appAccountToken}
**/
appAccountToken?: string
/**
* The age of the customers account.
*
* {@link https://developer.apple.com/documentation/appstoreserverapi/accounttenure accountTenure}
**/
accountTenure?: AccountTenure
/**
* A value that indicates the amount of time that the customer used the app.
*
* {@link https://developer.apple.com/documentation/appstoreserverapi/consumptionrequest ConsumptionRequest}
**/
playTime?: PlayTime
/**
* A value that indicates the total amount, in USD, of refunds the customer has received, in your app, across all platforms.
*
* {@link https://developer.apple.com/documentation/appstoreserverapi/lifetimedollarsrefunded lifetimeDollarsRefunded}
**/
lifetimeDollarsRefunded?: LifetimeDollarsRefunded
/**
* A value that indicates the total amount, in USD, of in-app purchases the customer has made in your app, across all platforms.
*
* {@link https://developer.apple.com/documentation/appstoreserverapi/lifetimedollarspurchased lifetimeDollarsPurchased}
**/
lifetimeDollarsPurchased?: LifetimeDollarsPurchased
/**
* The status of the customers account.
*
* {@link https://developer.apple.com/documentation/appstoreserverapi/userstatus userStatus}
**/
userStatus?: UserStatus
}

View File

@ -0,0 +1,38 @@
import { decodeNotificationPayload } from 'apple/Decoding'
import { DecodedNotificationPayload } from 'apple/Models'
import BaseController, { ROLE_ANON } from 'common/base.controller'
import { role, router } from 'decorators/router'
import logger from 'logger/logger'
import { IosPaySvr } from 'service/iospay.svr'
class ApplePayController extends BaseController {
@role(ROLE_ANON)
@router('post /pay/apple/verify')
async verifyApplePay(req, res) {
// const user = req.user
const { receipt } = req.params
let transaction = await new IosPaySvr().getTransactionInfo(receipt)
logger.info('getTransactionInfo:: ', transaction)
let results = await new IosPaySvr().getTransactionHistory(receipt)
logger.info('getTransactionHistory:: length: ', results.length)
for (let result of results) {
logger.info(result)
}
await new IosPaySvr().queryNotificationHistory()
return results
}
/**
* apple in-app purchase server-to-server notification
* use notification v2
* https://developer.apple.com/documentation/appstoreservernotifications/app_store_server_notifications_v2
*/
@role(ROLE_ANON)
@router('post /pay/out/apple/notify')
async notifyApplePay(req, res) {
let { signedPayload } = req.params
const decodedNotificationPayload: DecodedNotificationPayload = await decodeNotificationPayload(signedPayload)
logger.info('apple notification:: ', JSON.stringify(decodedNotificationPayload))
return res.send('ok')
}
}

View File

@ -0,0 +1,16 @@
import BaseController, { ROLE_ANON } from 'common/base.controller'
import { ZError } from 'common/ZError'
import { role, router } from 'decorators/router'
import logger from 'logger/logger'
class PurchaseController extends BaseController {
/**
* //TODO::供内部二次确认订单信息使用
*/
@role(ROLE_ANON)
@router('get /inner/purchase/:id')
async getPurchaseRecord(req, res) {
let { id, sign } = req.params
return {}
}
}

76
src/service/iospay.svr.ts Normal file
View File

@ -0,0 +1,76 @@
import { AppStoreServerAPI } from 'apple/AppStoreServerAPI'
import { decodeTransaction, decodeTransactions } from 'apple/Decoding'
import { Environment, OrderLookupStatus, ProductTypeParameter, SortParameter } from 'apple/Models'
import { singleton } from 'decorators/singleton'
import logger from 'logger/logger'
const env = process.env.NODE_ENV || 'development'
const sandbox = env === 'development' ? true : false
const KEY = `-----BEGIN PRIVATE KEY-----
${process.env.IOS_PRIVATE_KEY}
-----END PRIVATE KEY-----`
const KEY_ID = process.env.IOS_KEY_ID
const ISSUER_ID = process.env.IOS_ISSUER_ID
const APP_BUNDLE_ID = process.env.IOS_APP_BUNDLE_ID
const environment = sandbox ? Environment.Sandbox : Environment.Production
const api = new AppStoreServerAPI(KEY, KEY_ID, ISSUER_ID, APP_BUNDLE_ID, environment)
@singleton
export class IosPaySvr {
public async iosPayVerify(orderId: string) {
logger.info('iosPayVerify: ', orderId)
const response = await api.lookupOrder(orderId)
if (response.status === OrderLookupStatus.Valid) {
const transactions = await decodeTransactions(response.signedTransactions)
logger.info('transactions: ', transactions)
}
}
public async getTransactionHistory(originalTransactionId: string, rversion: string = '') {
let reqData = {
productType: ProductTypeParameter.Consumable,
sort: SortParameter.Descending,
}
if (rversion) {
reqData['revision'] = rversion
}
const response = await api.getTransactionHistory(originalTransactionId, reqData)
let results: any = []
// Decoding not only reveals the contents of the transactions but also verifies that they were signed by Apple.
const transactions = await decodeTransactions(response.signedTransactions)
for (let transaction of transactions) {
// Do something with your transactions...
results.push(transaction)
}
// The response contains at most 20 entries. You can check to see if there are more.
if (response.hasMore) {
let sub = await this.getTransactionHistory(originalTransactionId, response.revision)
results = results.concat(sub)
}
return results
}
public async getTransactionInfo(originalTransactionId: string) {
const response = await api.getTransactionInfo(originalTransactionId)
const transaction = await decodeTransaction(response.signedTransactionInfo)
return transaction
}
public async queryNotificationHistory() {
// Start and end date are required.
// The earliest supported start date is June 6th (the start of WWDC 2022).
const response = await api.getNotificationHistory({
startDate: 1690175687060, // June 6th 2022
endDate: new Date().getTime(),
})
logger.info('queryNotificationHistory response: ', response)
// Check if there are more items.
if (response.hasMore) {
// Use history.paginationToken to fetch additional items.
}
}
}

View File

@ -1466,6 +1466,11 @@ jose@^2.0.6:
dependencies:
"@panva/asn1.js" "^1.0.0"
jose@^4.14.4:
version "4.14.4"
resolved "https://registry.yarnpkg.com/jose/-/jose-4.14.4.tgz#59e09204e2670c3164ee24cbfe7115c6f8bff9ca"
integrity sha512-j8GhLiKmUAh+dsFXlX1aJCbt5KMibuKb+d7j1JaOJG6s2UjX1PQlW+OKB/sD4a/5ZYF4RcmYmLSndOoU3Lt/3g==
js-sdsl@^4.1.4:
version "4.1.5"
resolved "https://registry.npmmirror.com/js-sdsl/-/js-sdsl-4.1.5.tgz#1ff1645e6b4d1b028cd3f862db88c9d887f26e2a"