Last active
June 10, 2023 01:12
-
-
Save ezioruan/2073161332bc87846e27403f4a995346 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
1: model 数据库设计 | |
``` | |
module.exports = function(sequelize, DataTypes) { | |
return sequelize.define('payment', { | |
id: { | |
type: DataTypes.INTEGER(10).UNSIGNED, | |
allowNull: false, | |
primaryKey: true, | |
autoIncrement: true | |
}, | |
userId: { | |
type: DataTypes.INTEGER(10).UNSIGNED, | |
allowNull: false | |
}, | |
productId: { | |
type: DataTypes.STRING(255), | |
allowNull: false | |
}, | |
transactionId: { | |
type: DataTypes.STRING(255), | |
allowNull: false | |
}, | |
originalTransactionId: { | |
type: DataTypes.STRING(255), | |
allowNull: true | |
}, | |
receipt: { | |
type: DataTypes.TEXT, | |
allowNull: false | |
}, | |
receiptData: { | |
type: DataTypes.JSON, | |
allowNull: true | |
}, | |
expireDate: { | |
type: DataTypes.DATE, | |
allowNull: false | |
}, | |
cancelDate: { | |
type: DataTypes.DATE, | |
allowNull: true | |
}, | |
cancelReason: { | |
type: DataTypes.STRING(64), | |
allowNull: true | |
}, | |
paymentType: { | |
type: DataTypes.STRING(64), | |
allowNull: true | |
}, | |
createdAt: { | |
type: DataTypes.DATE, | |
allowNull: true | |
}, | |
updatedAt: { | |
type: DataTypes.DATE, | |
allowNull: true | |
} | |
}, { | |
tableName: 'payment', | |
timestamps: true, | |
underscored: false | |
}); | |
}; | |
``` | |
2: pay service, 处理订阅和插入表数据 | |
``` | |
import logger from '../logger' | |
import { panic } from '../error' | |
import * as db from '../db' | |
import _ from 'lodash' | |
import config from 'config' | |
import moment from 'moment' | |
import uuid from 'node-uuid' | |
import util from 'util' | |
import iap from 'iap' | |
import userServices from './user' | |
const verifyPayment = util.promisify(iap.verifyPayment) | |
const Apple = "apple" | |
const Google = "google" | |
class PayServices { | |
constructor() {} | |
async getProducts(platform) { | |
return await db.models.Product.findAll({ | |
where: { | |
platform, | |
}, | |
order: [ | |
['price', 'desc'] | |
] | |
}) | |
} | |
async getProduct(platform, productId) { | |
return await db.models.Product.findOne({ | |
where: { | |
productId, | |
platform, | |
} | |
}) | |
} | |
async getPaymentByTranscationId(transactionId) { | |
return await db.models.Payment.findOne({ where: { transactionId } }) | |
} | |
async getActivePayment(userId) { | |
const sql = ` | |
select * from payment where | |
userId = ? and | |
expireDate > now() | |
order by createdAt desc limit 1 | |
` | |
return await db.selectOne(sql, [userId]) | |
} | |
async applePurchase(userId, productId, receipt) { | |
const product = await this.getProduct(Apple, productId) | |
if (!product) { | |
panic(50000, 'product not found') | |
} | |
let result | |
try { | |
result = await verifyPayment(Apple, { | |
receipt, | |
secret: config.iap.applePassword, | |
// excludeOldTransactions: true, | |
}) | |
logger.debug("result", result) | |
} catch (err) { | |
logger.error("err", err) | |
panic(50001, `apple purchase validate error: ${err.message}`) | |
} | |
// get current subscription from in app | |
// find the most recently purchased product | |
const { in_app } = result.receipt | |
if (result.productId !== productId) { | |
panic(50002, 'productId not match') | |
} | |
const paymentsByProduct = _.filter(in_app, purchase => purchase.product_id === productId) | |
const subscription = _.first(_.sortBy(paymentsByProduct, purchase => -purchase.purchase_date_ms)) | |
if (!subscription) { | |
panic(50003, 'subscription not found!') | |
} | |
logger.debug("subscription ", subscription) | |
let expireDate = moment(Number(subscription.expires_date_ms)) | |
if (expireDate.isBefore(moment())) { | |
panic(50004, 'subscription has expired') | |
} | |
if (await this.getPaymentByTranscationId(result.transactionId)) { | |
panic(50005, 'subscription has been processed') | |
} | |
const activePayment = await this.getActivePayment(userId) | |
try { | |
if (activePayment) { | |
logger.error("get activityPayment", activePayment) | |
const activitePaymentInApp = _.first(_.filter(in_app, purchase => purchase.transaction_id === activePayment.transactionId)) | |
logger.error("activitePaymentInApp", activitePaymentInApp) | |
const activePaymentExpiredDate = moment(Number(activitePaymentInApp.expires_date_ms)) | |
if (activitePaymentInApp) { | |
if (activitePaymentInApp.is_trial_period) { | |
logger.debug("update activePayment to expired in trial period") | |
await db.models.Payment.update({ | |
expireDate: moment(), | |
}, { | |
where: { | |
id: activePayment.id | |
} | |
}) | |
} else if (activePaymentExpiredDate.isBefore(moment())) { | |
logger.debug("update activePayment to expired") | |
await db.models.Payment.update({ | |
expireDate: activePaymentExpiredDate, | |
}, { | |
where: { | |
id: activePayment.id | |
} | |
}) | |
} else { | |
panic(50006, 'you already have an active subscription') | |
} | |
} | |
} | |
} catch (err) { | |
logger.error("err on processe activePayment", err) | |
} | |
if (result.environment === 'sandbox') { | |
expireDate.add(config.iap.extraExpireUnit, 'minutes') | |
} else { | |
expireDate.add(config.iap.extraExpireUnit, 'days') | |
} | |
const payment = await db.models.Payment.create({ | |
userId, | |
productId, | |
transactionId: result.transactionId, | |
originalTransactionId: subscription.original_transaction_id || result.transactionId, | |
expireDate, | |
receipt: receipt, | |
receiptData: result, | |
paymentType: 'purchase', | |
}) | |
return payment | |
} | |
async googlePurchase(userId, productId, purchaseToken, orderId) { | |
const product = await this.getProduct(Google, productId) | |
if (!product) { | |
panic(50000, 'product not found') | |
} | |
let result | |
if (purchaseToken === "purchaseTokenTest") { | |
// test | |
result = { | |
receipt: { | |
kind: "androidpublisher#productPurchase", | |
purchaseTimeMillis: "1410835105408", | |
purchaseState: 0, | |
consumptionState: 1, | |
developerPayload: orderId, | |
packageName: config.iap.googlePackageName, | |
expiryTimeMillis: '1527152141520' | |
}, | |
transactionId: "ghbbkjheodjokkipdmlkjajn.AO-J1OwfrtpJd2fkzzZqv7i107yPmaUD9Vauf9g5evoqbIVzdOGYyJTSEMhSTGFkCOzGtWccxe17dtbS1c16M2OryJZPJ3z-eYhEJYiSLHxEZLnUJ8yfBmI", | |
productId: "vibe.monthly", | |
platform: "google", | |
expireDate: "2018-06-16 09:01:01" | |
} | |
} else { | |
try { | |
logger.debug("config.iap.googleKeyObject:", config.iap.googleKeyObject) | |
result = await verifyPayment(Google, { | |
receipt: purchaseToken, | |
productId, | |
packageName: config.iap.googlePackageName, | |
subscription: true, | |
keyObject: config.iap.googleKeyObject, | |
}) | |
logger.debug("result", result) | |
} catch (err) { | |
logger.error("err", err) | |
panic(50001, `google purchase validate error: ${err.message}`) | |
} | |
} | |
if (result.productId !== productId) { | |
panic(50002, 'productId not match') | |
} | |
// orderId from front end | |
let transactionId = result.receipt.orderId || orderId | |
let originalTransactionId | |
if (await this.getPaymentByTranscationId(transactionId)) { | |
panic(50005, 'subscription has been processed') | |
} | |
logger.info("userId!!!", userId) | |
const activePayment = await this.getActivePayment(userId) | |
logger.info("activePayment", activePayment) | |
if (activePayment && activePayment.originalTransactionId) { | |
originalTransactionId = activePayment.originalTransactionId | |
} else { | |
originalTransactionId = transactionId | |
} | |
logger.info("transactionId", transactionId) | |
logger.info("originalTransactionId", originalTransactionId) | |
let expireDate = moment(Number(result.receipt.expiryTimeMillis)) | |
logger.debug("expireDate:", expireDate) | |
const payment = await db.models.Payment.create({ | |
userId, | |
productId, | |
transactionId: transactionId, | |
originalTransactionId: originalTransactionId, | |
expireDate: expireDate, | |
receipt: purchaseToken, | |
receiptData: result, | |
paymentType: 'purchase', | |
}) | |
logger.info("payment", payment) | |
try { | |
// Save the default schedule (every 3 hours) scheduleId = 9 | |
logger.debug("saveReminderSchedule: userId", userId) | |
await userServices.saveReminderSchedule(userId, 9) | |
} catch (err) { | |
logger.error("saveReminderSchedule error:", err) | |
} | |
return payment | |
} | |
async getUserVibeFreeSubscriotion(userId) { | |
const transactionId = uuid.v4() | |
const [subscription, ] = await db.models.Payment.findOrCreate({ | |
where: { | |
userId, | |
productId: 'vibe', | |
}, | |
defaults: { | |
userId, | |
productId: 'vibe', | |
transactionId, | |
originalTransactionId: transactionId, | |
expireDate: moment(), | |
receipt: '', | |
receiptData: {}, | |
paymentType: 'purchase', | |
} | |
}) | |
return subscription | |
} | |
async processVibeFreeSubscription(email, expireDate) { | |
const user = await userServices.getUserByEmail(email) | |
expireDate = moment(expireDate) | |
if (!expireDate.isValid()) { | |
panic(50006, 'expireDate is not a valid date') | |
} | |
const vibeFreeSubscription = await this.getUserVibeFreeSubscriotion(user.id) | |
await vibeFreeSubscription.update({ | |
expireDate, | |
}) | |
return vibeFreeSubscription | |
} | |
} | |
const payServices = new PayServices() | |
export default payServices | |
``` | |
3: 定时任务逻辑, 处理丢单和refund | |
``` | |
const config = require('config') | |
// const { Op } = require('sequelize') | |
const { colorConsole } = require('tracer') | |
const moment = require('moment'); | |
const iap = require('iap') | |
const util = require('util') | |
const _ = require('lodash') | |
const models = require('./models') | |
const verifyPayment = util.promisify(iap.verifyPayment) | |
const logger = colorConsole('debug') | |
const getProducts = async() => { | |
return await models.Product.findAll() | |
} | |
const checkRefund = async() => { | |
const payments = await models.Payment.findAll({ | |
where: { | |
cancelDate: null, | |
} | |
}) | |
for (const payment of payments) { | |
try { | |
const result = await verifyPayment('apple', { | |
receipt: payment.receipt, | |
productId: payment.productId, | |
secret: config.iap.applePassword, | |
}) | |
const { latestReceiptInfo } = result | |
const latestPurchase = _.last(latestReceiptInfo); | |
const cancellation_date_ms = _.get(latestPurchase, 'cancellation_date_ms', null); | |
if (!cancellation_date_ms) { | |
continue | |
} | |
const cancelDate = moment(Number(cancellation_date_ms)).format(); | |
await payment.update({ | |
cancelDate, | |
cancelReason: latestPurchase.cancellation_reason | |
}) | |
} catch (err) { | |
logger.error("err", err) | |
} | |
} | |
} | |
const hasTransactionIdExists = async transactionId => { | |
const payment = await models.Payment.findOne({ | |
where: { transactionId } | |
}) | |
return payment !== null | |
} | |
const checkRenew = async() => { | |
const payments = await models.Payment.findAll({ | |
where: { | |
cancelDate: null, | |
// transactionId: 1000000388988732, | |
// expireDate: { | |
// [Op.between]: [moment().subtract('3', 'days').format(), moment().format()] | |
// }, | |
paymentType: 'purchase' | |
} | |
}) | |
const products = await getProducts(); | |
for (const payment of payments) { | |
let platform | |
for (let product of products) { | |
if (product.productId === payment.productId) { | |
platform = product.platform | |
break | |
} | |
} | |
logger.debug('platform', platform) | |
if (platform === 'apple') { | |
try { | |
const result = await verifyPayment('apple', { | |
receipt: payment.receipt, | |
secret: config.iap.applePassword, | |
excludeOldTransactions: true, | |
}) | |
const { latestReceiptInfo } = result | |
const latestPurchase = _.last(latestReceiptInfo); | |
let expireDate = _.get(latestPurchase, 'expires_date_ms', null); | |
if (!expireDate) { | |
continue | |
} | |
expireDate = moment(Number(expireDate)) | |
const transactionIdExists = await hasTransactionIdExists(latestPurchase.transaction_id) | |
if (!transactionIdExists) { | |
if (result.environment === 'sandbox') { | |
expireDate.add(config.iap.extraExpireUnit, 'minutes') | |
} else { | |
expireDate.add(config.iap.extraExpireUnit, 'days') | |
} | |
const newPayment = await models.Payment.create({ | |
userId: payment.userId, | |
productId: latestPurchase.product_id, | |
transactionId: latestPurchase.transaction_id, | |
originalTransactionId: latestPurchase.original_transaction_id, | |
expireDate, | |
receipt: payment.receipt, | |
receiptData: result, | |
paymentType: 'renew', | |
}) | |
logger.debug("renew", newPayment.id) | |
} | |
} catch (err) { | |
logger.error("err", err) | |
} | |
} else if (platform === 'google') { | |
try { | |
const result = await verifyPayment('google', { | |
receipt: payment.receipt, | |
productId: payment.productId, | |
packageName: config.iap.googlePackageName, | |
subscription: true, | |
keyObject: config.iap.googleKeyObject, | |
}) | |
// renew | |
if (result.receipt.autoRenewing) { | |
const expireDate = moment(Number(result.receipt.expiryTimeMillis)) | |
if (expireDate.isAfter(payment.expireDate)) { | |
if (config.util.getEnv('NODE_ENV') === 'production') { | |
expireDate.add(config.iap.extraExpireUnit, 'days') | |
} else { | |
expireDate.add(config.iap.extraExpireUnit, 'minutes') | |
} | |
let transactionId = result.receipt.orderId | |
if (transactionId === payment.originalTransactionId) { | |
// new transactionId | |
transactionId = `${transactionId}-${result.receipt.expiryTimeMillis}` | |
} | |
logger.info("transactionId", transactionId) | |
logger.info("result.receipt.orderId", result.receipt.orderId) | |
let now = moment() | |
logger.info("update Payment expireDate") | |
await models.Payment.update({ | |
expireDate, | |
updatedAt: now | |
}, { | |
where: { | |
id: payment.id | |
} | |
}) | |
const newPayment = await models.Payment.create({ | |
userId: payment.userId, | |
productId: payment.productId, | |
transactionId: transactionId, | |
originalTransactionId: payment.originalTransactionId, | |
expireDate, | |
receipt: payment.receipt, | |
receiptData: result, | |
paymentType: 'renew', | |
}) | |
logger.info("newPayment", newPayment) | |
} | |
} | |
// refund | |
if (result.receipt.userCancellationTimeMillis) { | |
const cancelDate = moment(Number(result.receipt.userCancellationTimeMillis)) | |
logger.info("update Payment expireDate payment.originalTransactionId", payment.originalTransactionId) | |
let now = moment() | |
await models.Payment.update({ | |
expireDate: cancelDate, | |
cancelDate, | |
cancelReason: result.receipt.cancelReason, | |
updatedAt: now | |
}, { | |
where: { | |
originalTransactionId: payment.originalTransactionId | |
} | |
}) | |
} | |
} catch (err) { | |
logger.error("err", err) | |
} | |
} | |
} | |
} | |
const run = async() => { | |
// await checkRefund() | |
await checkRenew() | |
logger.info("process done") | |
process.exit() | |
} | |
run() | |
``` |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment