first commit
This commit is contained in:
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* PaymentManager.js
|
||||
* Simplified payment manager for monetization-lite.
|
||||
* Only 3 products: Ad-Free (¥18), Gold Pack (¥6=1000g), Newcomer Pack (¥1=500g).
|
||||
* Handles WeChat payment, order recovery, and newcomer pack 24h timer.
|
||||
*/
|
||||
|
||||
/** Product definitions. */
|
||||
const PRODUCTS = {
|
||||
AD_FREE: {
|
||||
id: 'ad_free',
|
||||
price: 18, // ¥18
|
||||
name: 'Remove Ads (Permanent)',
|
||||
type: 'permanent',
|
||||
},
|
||||
GOLD_PACK: {
|
||||
id: 'gold_pack',
|
||||
price: 6, // ¥6
|
||||
goldAmount: 1000,
|
||||
name: 'Gold Pack',
|
||||
type: 'consumable',
|
||||
},
|
||||
NEWCOMER_PACK: {
|
||||
id: 'newcomer_pack',
|
||||
price: 1, // ¥1
|
||||
goldAmount: 500,
|
||||
name: 'Newcomer Pack',
|
||||
type: 'one_time',
|
||||
},
|
||||
};
|
||||
|
||||
/** Newcomer pack availability window in milliseconds (24 hours). */
|
||||
const NEWCOMER_WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
class PaymentManager {
|
||||
constructor() {
|
||||
this._adFreePurchased = false;
|
||||
this._newcomerPackPurchased = false;
|
||||
this._newcomerPackStartTime = 0; // timestamp when user first entered game
|
||||
this._pendingOrders = [];
|
||||
|
||||
this._load();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Persistence
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Load payment state from StorageManager.
|
||||
* @private
|
||||
*/
|
||||
_load() {
|
||||
try {
|
||||
if (GameGlobal && GameGlobal.storageManager) {
|
||||
const data = GameGlobal.storageManager.get('payment', null);
|
||||
if (data) {
|
||||
this._adFreePurchased = data.adFreePurchased || false;
|
||||
this._newcomerPackPurchased = data.newcomerPackPurchased || false;
|
||||
this._newcomerPackStartTime = data.newcomerPackStartTime || 0;
|
||||
this._pendingOrders = data.pendingOrders || [];
|
||||
}
|
||||
|
||||
// Initialize newcomer pack timer on first load
|
||||
if (this._newcomerPackStartTime === 0) {
|
||||
this._newcomerPackStartTime = Date.now();
|
||||
this._save();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[PaymentManager] Failed to load payment data:', e);
|
||||
}
|
||||
|
||||
// Try to recover any pending orders
|
||||
this._recoverPendingOrders();
|
||||
}
|
||||
|
||||
/**
|
||||
* Save payment state to StorageManager.
|
||||
* @private
|
||||
*/
|
||||
_save() {
|
||||
try {
|
||||
if (GameGlobal && GameGlobal.storageManager) {
|
||||
GameGlobal.storageManager.set('payment', {
|
||||
adFreePurchased: this._adFreePurchased,
|
||||
newcomerPackPurchased: this._newcomerPackPurchased,
|
||||
newcomerPackStartTime: this._newcomerPackStartTime,
|
||||
pendingOrders: this._pendingOrders,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[PaymentManager] Failed to save payment data:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Purchase API
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Purchase the ad-free privilege (¥18, permanent).
|
||||
* @param {Function} callback - Called with { success: boolean, error?: string }.
|
||||
*/
|
||||
purchaseAdFree(callback) {
|
||||
if (this._adFreePurchased) {
|
||||
if (callback) callback({ success: false, error: 'Already purchased' });
|
||||
return;
|
||||
}
|
||||
|
||||
this._requestPayment(PRODUCTS.AD_FREE, (result) => {
|
||||
if (result.success) {
|
||||
this._adFreePurchased = true;
|
||||
this._save();
|
||||
|
||||
// Enable ad-free in AdManager
|
||||
if (GameGlobal.adManager) {
|
||||
GameGlobal.adManager.enableAdFree();
|
||||
}
|
||||
|
||||
// Emit event
|
||||
this._emitPurchaseEvent('ad_free');
|
||||
}
|
||||
if (callback) callback(result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Purchase a gold pack (¥6 = 1000 gold).
|
||||
* @param {Function} callback - Called with { success: boolean, error?: string }.
|
||||
*/
|
||||
purchaseGoldPack(callback) {
|
||||
this._requestPayment(PRODUCTS.GOLD_PACK, (result) => {
|
||||
if (result.success) {
|
||||
// Award gold
|
||||
if (GameGlobal.currencyManager) {
|
||||
GameGlobal.currencyManager.addGold(PRODUCTS.GOLD_PACK.goldAmount);
|
||||
}
|
||||
this._emitPurchaseEvent('gold_pack');
|
||||
}
|
||||
if (callback) callback(result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Purchase the newcomer pack (¥1 = 500 gold, one-time only).
|
||||
* @param {Function} callback - Called with { success: boolean, error?: string }.
|
||||
*/
|
||||
purchaseNewcomerPack(callback) {
|
||||
if (this._newcomerPackPurchased) {
|
||||
if (callback) callback({ success: false, error: 'Already purchased' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.isNewcomerPackAvailable()) {
|
||||
if (callback) callback({ success: false, error: 'Newcomer pack expired' });
|
||||
return;
|
||||
}
|
||||
|
||||
this._requestPayment(PRODUCTS.NEWCOMER_PACK, (result) => {
|
||||
if (result.success) {
|
||||
this._newcomerPackPurchased = true;
|
||||
this._save();
|
||||
|
||||
// Award gold
|
||||
if (GameGlobal.currencyManager) {
|
||||
GameGlobal.currencyManager.addGold(PRODUCTS.NEWCOMER_PACK.goldAmount);
|
||||
}
|
||||
this._emitPurchaseEvent('newcomer_pack');
|
||||
}
|
||||
if (callback) callback(result);
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Status Queries
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Whether ad-free has been purchased.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isAdFreePurchased() {
|
||||
return this._adFreePurchased;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the newcomer pack is still available (within 24h and not purchased).
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isNewcomerPackAvailable() {
|
||||
if (this._newcomerPackPurchased) return false;
|
||||
const elapsed = Date.now() - this._newcomerPackStartTime;
|
||||
return elapsed < NEWCOMER_WINDOW_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get remaining time for newcomer pack in milliseconds.
|
||||
* @returns {number} Remaining ms, or 0 if expired.
|
||||
*/
|
||||
getNewcomerPackRemainingMs() {
|
||||
if (this._newcomerPackPurchased) return 0;
|
||||
const remaining = NEWCOMER_WINDOW_MS - (Date.now() - this._newcomerPackStartTime);
|
||||
return Math.max(0, remaining);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get product definitions.
|
||||
* @returns {object}
|
||||
*/
|
||||
getProducts() {
|
||||
return PRODUCTS;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// WeChat Payment
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Request payment via WeChat Midas payment.
|
||||
* @param {object} product - Product definition.
|
||||
* @param {Function} callback - Called with { success: boolean, error?: string }.
|
||||
* @private
|
||||
*/
|
||||
_requestPayment(product, callback) {
|
||||
// Add to pending orders for recovery
|
||||
const orderId = `${product.id}_${Date.now()}`;
|
||||
this._pendingOrders.push({ orderId, productId: product.id, timestamp: Date.now() });
|
||||
this._save();
|
||||
|
||||
try {
|
||||
if (typeof wx === 'undefined' || typeof wx.requestMidasPayment !== 'function') {
|
||||
// Dev environment: simulate success
|
||||
console.log(`[PaymentManager] Dev mode: simulating purchase of ${product.id}`);
|
||||
this._removePendingOrder(orderId);
|
||||
if (callback) callback({ success: true });
|
||||
return;
|
||||
}
|
||||
|
||||
wx.requestMidasPayment({
|
||||
mode: 'game',
|
||||
env: 0, // 0 = production, 1 = sandbox
|
||||
offerId: '', // Replace with actual offer ID
|
||||
currencyType: 'CNY',
|
||||
buyQuantity: product.price * 10, // Midas uses 1/10 yuan units
|
||||
success: () => {
|
||||
console.log(`[PaymentManager] Purchase successful: ${product.id}`);
|
||||
this._removePendingOrder(orderId);
|
||||
if (callback) callback({ success: true });
|
||||
},
|
||||
fail: (err) => {
|
||||
console.warn(`[PaymentManager] Purchase failed: ${product.id}`, err);
|
||||
this._removePendingOrder(orderId);
|
||||
if (callback) callback({ success: false, error: err.errMsg || 'Payment failed' });
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[PaymentManager] Payment request error:', e);
|
||||
this._removePendingOrder(orderId);
|
||||
if (callback) callback({ success: false, error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a pending order after completion.
|
||||
* @param {string} orderId
|
||||
* @private
|
||||
*/
|
||||
_removePendingOrder(orderId) {
|
||||
this._pendingOrders = this._pendingOrders.filter(o => o.orderId !== orderId);
|
||||
this._save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to recover pending orders (e.g., after network interruption).
|
||||
* @private
|
||||
*/
|
||||
_recoverPendingOrders() {
|
||||
if (this._pendingOrders.length === 0) return;
|
||||
|
||||
// Remove orders older than 1 hour (stale)
|
||||
const oneHourAgo = Date.now() - 60 * 60 * 1000;
|
||||
this._pendingOrders = this._pendingOrders.filter(o => o.timestamp > oneHourAgo);
|
||||
this._save();
|
||||
|
||||
// In production, query server for order status and deliver items
|
||||
// For now, just log
|
||||
if (this._pendingOrders.length > 0) {
|
||||
console.log(`[PaymentManager] ${this._pendingOrders.length} pending orders to recover`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a purchase completed event.
|
||||
* @param {string} productId
|
||||
* @private
|
||||
*/
|
||||
_emitPurchaseEvent(productId) {
|
||||
try {
|
||||
if (GameGlobal && GameGlobal.eventBus) {
|
||||
GameGlobal.eventBus.emit('purchase:completed', { productId });
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Cloud Sync
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Get payment data for cloud sync.
|
||||
* @returns {object}
|
||||
*/
|
||||
getCloudSyncData() {
|
||||
return {
|
||||
adFreePurchased: this._adFreePurchased,
|
||||
newcomerPackPurchased: this._newcomerPackPurchased,
|
||||
newcomerPackStartTime: this._newcomerPackStartTime,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore payment data from cloud.
|
||||
* @param {object} cloudData
|
||||
*/
|
||||
restoreFromCloud(cloudData) {
|
||||
if (!cloudData) return;
|
||||
let changed = false;
|
||||
|
||||
// Ad-free is permanent — if cloud says purchased, trust it
|
||||
if (cloudData.adFreePurchased && !this._adFreePurchased) {
|
||||
this._adFreePurchased = true;
|
||||
if (GameGlobal.adManager) {
|
||||
GameGlobal.adManager.enableAdFree();
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (cloudData.newcomerPackPurchased && !this._newcomerPackPurchased) {
|
||||
this._newcomerPackPurchased = true;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
this._save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PaymentManager.PRODUCTS = PRODUCTS;
|
||||
|
||||
module.exports = PaymentManager;
|
||||
Reference in New Issue
Block a user