Skip to content

Commit

Permalink
Payment / CompleteByMobileUser API 확장 - IOS 지원 (#680)
Browse files Browse the repository at this point in the history
* feat: implement AppleReceiptValidator

* feat: implement Apple Shared Secret (env)

* fix: add isSendbox bodyParam to CompleteByMobileUser (ios)

* feat: add iap errors

* feat: implement IOS payment validation
  • Loading branch information
jessie129j committed Aug 3, 2024
1 parent 1fa1550 commit a16f49f
Show file tree
Hide file tree
Showing 8 changed files with 189 additions and 3 deletions.
2 changes: 1 addition & 1 deletion server/src/api/routes/payments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ router.post(
}

case InAppPlatform.IOS: {
for (let field of ["payload"]) {
for (let field of ["payload", "isSendbox"]) {
if (!(field in body)) throw new FieldRequiredError(field);
}

Expand Down
1 change: 1 addition & 0 deletions server/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ const config: configType = {
private_key: process.env.GOOGLE_CREDENTIALS_PRIVATE_KEY,
};
})(),
APPLE_SHARED_SECRET: process.env.APPLE_SHARED_SECRET ?? "",
};

console.log(`✅ config is set; `, config);
Expand Down
1 change: 1 addition & 0 deletions server/src/config/type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ type configType = {

GOOGLE_CREDENTIALS: Pick<JWTInput, "client_email" | "private_key">;

APPLE_SHARED_SECRET: string;
// SERVER_URL: string;

// CLIENT: string;
Expand Down
6 changes: 6 additions & 0 deletions server/src/errors/CustomError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,10 @@ export class CustomError extends Error {
super();
this.name = "Error";
}

protected logError(debug?: Object) {
if (debug) {
console.log("[ERROR]", this.message, debug);
}
}
}
30 changes: 30 additions & 0 deletions server/src/errors/PaymentError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,33 @@ export class FakePaymentAttemptError extends PaymentError {
this.status = 409;
}
}

export class PaymentValidationFailedError extends PaymentError {
constructor(debug?: Object) {
super();
this.message = "PAYMENT_VALIDATION_FAILED";
this.status = 409;

this.logError(debug);
}
}

export class PaymentItemNotMatchError extends PaymentError {
constructor(debug?: Object) {
super();
this.message = "PAYMENT_ITEM_NOT_MATCH";
this.status = 409;

this.logError(debug);
}
}

export class PaymentUIDDuplicatedError extends PaymentError {
constructor(debug?: Object) {
super();
this.message = "PAYMENT_UID_DUPLICATED";
this.status = 409;

this.logError(debug);
}
}
98 changes: 98 additions & 0 deletions server/src/lib/IAPHelper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import axios from "axios";

// 애플 영수증 검증 URL
const APPLE_PRODUCTION_URL = "https://buy.itunes.apple.com/verifyReceipt";
const APPLE_SANDBOX_URL = "https://sandbox.itunes.apple.com/verifyReceipt";

// NOTE: https://developer.apple.com/documentation/appstorereceipts/responsebody/receipt/in_app
type AppleReceiptInfo = {
quantity: string; // ex) "1";
product_id: string; // ex) "bunny"
transaction_id: string; // A unique identifier for a transaction such as a purchase, restore, or renewal.
original_transaction_id: string; // ex) "2000000651969443";
purchase_date: string; // ex) "2024-07-10 02:28:41 Etc/GMT";
purchase_date_ms: string; // ex) "1720578521000";
purchase_date_pst: string; // ex) "2024-07-09 19:28:41 America/Los_Angeles";
original_purchase_date: string; // ex) "2024-07-10 02:28:41 Etc/GMT";
original_purchase_date_ms: string; // ex) "1720578521000";
original_purchase_date_pst: string; // ex) "2024-07-09 19:28:41 America/Los_Angeles";
is_trial_period: string; // ex) "false";
};

// NOTE: https://developer.apple.com/documentation/appstorereceipts/responsebody/receipt
type AppleReceipt = {
receipt_type: string;
adam_id: string;
app_item_id: string;
bundle_id: string;
application_version: string;
download_id: string;
version_external_identifier: string;
receipt_creation_date: string;
receipt_creation_date_ms: string;
receipt_creation_date_pst: string;
request_date: string;
request_date_ms: string;
request_date_pst: string;
original_purchase_date: string;
original_purchase_date_ms: string;
original_purchase_date_pst: string;
original_application_version: string;
in_app: Array<AppleReceiptInfo>;
};

export enum AppleVerifyReceiptResultStatus {
Success = 0,
Error000 = 21000, // The request to the App Store didn’t use the HTTP POST request method.
Error001 = 21001, // The App Store no longer sends this status code.
Error002 = 21002, // The data in the receipt-data property is malformed or the service experienced a temporary issue. Try again.
Error003 = 21003, // The system couldn’t authenticate the receipt.
Error004 = 21004, // The shared secret you provided doesn’t match the shared secret on file for your account.
Error005 = 21005, // The receipt server was temporarily unable to provide the receipt. Try again.
Error006 = 21006, // This receipt is valid, but the subscription is in an expired state. When your server receives this status code, the system also decodes and returns receipt data as part of the response. This status only returns for iOS 6-style transaction receipts for auto-renewable subscriptions.
Error007 = 21007, // This receipt is from the test environment, but you sent it to the production environment for verification.
Error008 = 21008, // This receipt is from the production environment, but you sent it to the test environment for verification.
Error009 = 21009, // Internal data access error. Try again later.
Error010 = 21010, // The system can’t find the user account or the user account has been deleted.
}

// NOTE: https://developer.apple.com/documentation/appstorereceipts/responsebody
type AppleVerifyReceiptResult = {
receipt: AppleReceipt;
environment: "Sendbox" | "Production";
latest_receipt_info: Array<AppleReceiptInfo>;
// latest_receipt
status: AppleVerifyReceiptResultStatus;
};

class AppleReceiptVerifier {
static async validate(
receipt: string,
isSendbox: boolean = true
): Promise<{
status: AppleVerifyReceiptResultStatus;
rawPaymentData: AppleReceiptInfo;
}> {
const url = isSendbox ? APPLE_SANDBOX_URL : APPLE_PRODUCTION_URL;

try {
const response = await axios.post(url, {
"receipt-data": receipt,
password: process.env.APPLE_SHARED_SECRET, // 앱의 공유 비밀 키
});

const responseData = response.data as AppleVerifyReceiptResult;

return {
status: responseData.status,
rawPaymentData: responseData.latest_receipt_info[0],
};
} catch (error: any) {
throw new Error(`[AppleReceiptVerifier.validate]: ${error.message}`);
}
}
}

export class IAPHelper {
public static IOS = AppleReceiptVerifier;
}
53 changes: 51 additions & 2 deletions server/src/services/payments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ import {
FetchingPaymentFailedError,
PaiedAlreadyError,
PaymentIsNotPaidError,
PaymentItemNotMatchError,
PaymentUIDDuplicatedError,
PaymentValidationFailedError,
} from "src/errors/PaymentError";
import { HydratedDocument, Types } from "mongoose";
import {
Expand All @@ -32,6 +35,7 @@ import {
InAppPlatform,
} from "src/types/payment";
import { RawPaymentDataByPlatform } from "src/types/payment.type";
import { AppleVerifyReceiptResultStatus, IAPHelper } from "src/lib/IAPHelper";

/**
* @function getAccessToken
Expand Down Expand Up @@ -333,9 +337,54 @@ export const completePaymentByMobileUser = async (
}

case InAppPlatform.IOS: {
throw new Error(
"Unexpected Error; IOS receipt validation is not available now"
const { payload, isSendbox } = req;

const { status, rawPaymentData } = await IAPHelper.IOS.validate(
payload.transactionReceipt,
isSendbox
);

if (status !== AppleVerifyReceiptResultStatus.Success) {
throw new PaymentValidationFailedError({
status,
rawPaymentData,
});
}

const { product_id: productId, transaction_id: transactionId } =
rawPaymentData;

if (itemRecord.title !== productId) {
throw new PaymentItemNotMatchError({
itemTitle: itemRecord.title,
productId,
});
}

const isUidDuplicated = !!(await PaymentModel.findOne({
platform: Platform.IOS,
uid: transactionId,
isDestroyed: false,
}));

if (isUidDuplicated) {
throw new PaymentUIDDuplicatedError({ uid: transactionId });
}

// DB에 결제 정보 생성
const paymentRecord = await PaymentModel.create({
userId: userRecord._id,
itemId: itemRecord._id,
itemType: itemRecord.type,
itemTitle: itemRecord.title,
amount: itemRecord.price,
status: PaymentStatus.Paid,
rawPaymentData: rawPaymentData,
platform: Platform.IOS,
uid: transactionId,
});

return paymentRecord;
}

default: {
Expand Down
1 change: 1 addition & 0 deletions server/src/types/payment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export function validateIOSPayload(
export type CompletePaymentByIOSReq = {
platform: InAppPlatform.IOS;
payload: IOSPayload;
isSendbox: boolean;
};

export type CompletePaymentByMobileUserReq =
Expand Down

0 comments on commit a16f49f

Please sign in to comment.