From a4e2cb14cda46b42195f8a3eb9fa32e5453a4350 Mon Sep 17 00:00:00 2001 From: CounterFire2023 <136581895+CounterFire2023@users.noreply.github.com> Date: Mon, 31 Jul 2023 16:58:16 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0apple=E5=86=85=E8=B4=AD?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 +- .vscode/launch.json | 13 + package.json | 2 + src/apple/AppStoreServerAPI.ts | 233 +++++++++++ src/apple/AppleRootCertificate.ts | 5 + src/apple/Decoding.ts | 102 +++++ src/apple/Errors.ts | 33 ++ src/apple/Models.ts | 535 +++++++++++++++++++++++++ src/controllers/applepay.controller.ts | 38 ++ src/controllers/purchase.controller.ts | 16 + src/service/iospay.svr.ts | 76 ++++ yarn.lock | 5 + 12 files changed, 1061 insertions(+), 1 deletion(-) create mode 100644 src/apple/AppStoreServerAPI.ts create mode 100644 src/apple/AppleRootCertificate.ts create mode 100644 src/apple/Decoding.ts create mode 100644 src/apple/Errors.ts create mode 100644 src/apple/Models.ts create mode 100644 src/controllers/applepay.controller.ts create mode 100644 src/controllers/purchase.controller.ts create mode 100644 src/service/iospay.svr.ts diff --git a/.gitignore b/.gitignore index 2a7b08e..66b9505 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,6 @@ dist tmp target boundle.log -google_cloud.json \ No newline at end of file +google_cloud.json +.env.production +.env.development \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index a607322..575c943 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -30,5 +30,18 @@ ], "type": "pwa-node" }, + { + "name": "Debug Test", + "request": "launch", + "runtimeArgs": [ + "run-script", + "test" + ], + "runtimeExecutable": "npm", + "skipFiles": [ + "/**" + ], + "type": "pwa-node" + }, ] } \ No newline at end of file diff --git a/package.json b/package.json index 95d1bf2..a8a0e00 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/apple/AppStoreServerAPI.ts b/src/apple/AppStoreServerAPI.ts new file mode 100644 index 0000000..a7120e0 --- /dev/null +++ b/src/apple/AppStoreServerAPI.ts @@ -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 + 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 { + 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 { + 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 { + 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 { + return this.makeRequest('GET', `/inApps/v1/lookup/${orderId}`) + } + + /** + * https://developer.apple.com/documentation/appstoreserverapi/request_a_test_notification + */ + async requestTestNotification(): Promise { + return this.makeRequest('POST', '/inApps/v1/notifications/test') + } + + /** + * https://developer.apple.com/documentation/appstoreserverapi/get_test_notification_status + */ + async getTestNotificationStatus(id: string): Promise { + 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 { + 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 { + 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 { + // 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}` + } + } +} diff --git a/src/apple/AppleRootCertificate.ts b/src/apple/AppleRootCertificate.ts new file mode 100644 index 0000000..e11ac02 --- /dev/null +++ b/src/apple/AppleRootCertificate.ts @@ -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' diff --git a/src/apple/Decoding.ts b/src/apple/Decoding.ts new file mode 100644 index 0000000..f445e02 --- /dev/null +++ b/src/apple/Decoding.ts @@ -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 { + return Promise.all(signedTransactions.map(transaction => decodeJWS(transaction, rootCertFingerprint))) +} + +export async function decodeTransaction( + transaction: JWSTransaction, + rootCertFingerprint?: string, +): Promise { + return decodeJWS(transaction, rootCertFingerprint) +} + +export async function decodeRenewalInfo( + info: JWSRenewalInfo, + rootCertFingerprint?: string, +): Promise { + return decodeJWS(info, rootCertFingerprint) +} + +export async function decodeNotificationPayload( + payload: string, + rootCertFingerprint?: string, +): Promise { + 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 { + // 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) + } +} diff --git a/src/apple/Errors.ts b/src/apple/Errors.ts new file mode 100644 index 0000000..8228f83 --- /dev/null +++ b/src/apple/Errors.ts @@ -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 + } +} diff --git a/src/apple/Models.ts b/src/apple/Models.ts new file mode 100644 index 0000000..f1ec8a7 --- /dev/null +++ b/src/apple/Models.ts @@ -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 customer’s 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 customer’s 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 customer’s in-app purchase with its resulting App Store transaction. + * + * {@link https://developer.apple.com/documentation/appstoreserverapi/appaccounttoken appAccountToken} + **/ + appAccountToken?: string + + /** + * The age of the customer’s 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 customer’s account. + * + * {@link https://developer.apple.com/documentation/appstoreserverapi/userstatus userStatus} + **/ + userStatus?: UserStatus +} diff --git a/src/controllers/applepay.controller.ts b/src/controllers/applepay.controller.ts new file mode 100644 index 0000000..47c28c0 --- /dev/null +++ b/src/controllers/applepay.controller.ts @@ -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') + } +} diff --git a/src/controllers/purchase.controller.ts b/src/controllers/purchase.controller.ts new file mode 100644 index 0000000..7ebd0cd --- /dev/null +++ b/src/controllers/purchase.controller.ts @@ -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 {} + } +} diff --git a/src/service/iospay.svr.ts b/src/service/iospay.svr.ts new file mode 100644 index 0000000..6b3e6af --- /dev/null +++ b/src/service/iospay.svr.ts @@ -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. + } + } +} diff --git a/yarn.lock b/yarn.lock index 54e5e5e..33789d8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"