Bỏ qua, tới nội dung
Kiếm tiền
5 phút đọc

Trả tiền rồi mà Settings vẫn ghi Go Premium: màn hình sau paywall

Mua Premium của Lockboxy xong, app vẫn hiện paywall. Cách mình dựng màn hình cho khách đã trả tiền: đọc gói riêng, dòng trạng thái trung thực, test.

Dao Van Thuong
Mobile & Fullstack Engineer
Read in English
Ảnh bìa: khách Premium của Lockboxy thấy gói của mình thay vì paywall
Trong bài này
  1. State có sẵn. Chỉ là không ai render nó.
  2. Chặn quyền và mô tả gói là hai lần đọc khác nhau
  3. App chỉ nói những gì nó biết
  4. Hai màn hình đổi theo state sống
  5. Mười ngôn ngữ, viết cho từng ngôn ngữ
  6. Test cho trạng thái sau khi mua
  7. Rút ra

Bug này do chính mình báo. Mình mua Premium trong app của mình, Lockboxy, quay lại Settings, và cái thẻ trên cùng vẫn ghi Go Premium. Bấm vào thì lại mở đúng trang bán hàng mình vừa trả tiền: các thẻ gói, timeline dùng thử, nút mua.

Phần billing không sai gì cả. Entitlement đang active, tính năng Premium đã mở, RevenueCat có giao dịch. Chỉ là app không có màn hình nào cho người đã gật đầu. Với người vừa trả tiền, app trông như vẫn đang đòi tiền họ, và đó đúng là lúc một khách hàng bối rối đi xin hoàn tiền.

Mình đã viết phiên bản ngắn của bản sửa này ở cuối bài các pattern paywall với RevenueCat. Bài này là bản đầy đủ: vì sao bug tồn tại, ranh giới mình vạch giữa chặn quyền và mô tả gói, các quy tắc về những gì app được phép hứa, và bộ test giữ cho trạng thái "sau khi mua" không biến mất lần nữa. Nó lên Lockboxy 1.2.12 (45) ngày 01/10/2026: một commit, 24 file, thêm 831 dòng.

State có sẵn. Chỉ là không ai render nó.

PremiumScreen vốn đã biết người dùng có Premium hay không. Nó subscribe listener customer info của RevenueCat và luôn cập nhật isPremiumNow:

tsx
useEffect(() => billingPort.onEntitlementChange(setIsPremiumNow), []);

Nhưng giá trị đó chỉ đi tới hai chỗ: analytics, và exit offer (để khách đã trả tiền không bị bật sheet giảm giá lúc thoát). Nó chưa bao giờ quyết định màn hình hiện gì. Thẻ ở Settings còn tệ hơn: lúc nào cũng render chuỗi teaser premiumTeaserTitle và premiumTeaserBody, entitlement nói gì kệ nó.

Bug kiểu này rất dễ viết ra, vì mọi test của paywall đều là test chuyện bán hàng. Gói năm có được chọn sẵn không? Timeline dùng thử có lấy đúng intro period thật không? Exit offer có chỉ hiện một lần không? Chẳng ai, kể cả mình, từng render paywall với một user Premium rồi hỏi: họ nên thấy gì?

Trước và sau: cùng state isPremium, giờ mới quyết định render
Cờ đã có từ trước bản sửa; chỉ là nó chưa bao giờ tới được UI

Chặn quyền và mô tả gói là hai lần đọc khác nhau

Bản sửa bắt đầu từ một quy tắc: isPremium() để chặn quyền, getMembership() để mô tả. Hai hàm trả lời hai câu hỏi khác nhau, và hỏng theo hai kiểu khác nhau.

isPremium() chạy đồng bộ và bắt buộc phải đúng, vì tính năng bị khoá nào cũng hỏi nó. getMembership() chạy bất đồng bộ và chỉ để lấy chữ hiển thị. Nó mà lỗi thì khách chỉ thấy một câu chung chung hơn chút, không bao giờ mất quyền truy cập. Nên mình thêm nó vào billing port như một method riêng, không bao giờ reject:

ts
export interface BillingMembership {
  /** null khi store báo một sản phẩm app không biết (SKU cũ, quà khuyến mãi). */
  readonly productId: BillingProductId | null;
  /** Lúc hết hạn hoặc gia hạn; null với gói trọn đời. */
  readonly expiresAtMs: number | null;
  /** false khi đã tắt tự gia hạn (hoặc gói trọn đời, vốn không gia hạn). */
  readonly willRenew: boolean;
  /** Đang trong đợt dùng thử miễn phí của store. */
  readonly isTrial: boolean;
}

/** Gói Premium đang active, hoặc null nếu không phải Premium. Không bao giờ reject. */
getMembership(): Promise<BillingMembership | null>;

Adapter RevenueCat lấy dữ liệu từ getCustomerInfo() và entitlement premium đang active: productIdentifier, expirationDate, willRenew, và periodType === 'TRIAL'. Có hai chi tiết quan trọng:

  • Product ID đi qua đúng hàm map mà paywall dùng, vì Google Play báo subscription dưới dạng subscriptionId:basePlanId. ID nào app không nhận ra thì thành null, không đoán.
  • Date.parse của ngày hết hạn được kiểm bằng Number.isFinite. Ngày hỏng thành null, chứ không phải NaN để rồi hiện lên màn hình thành "Invalid Date".

Exception nào cũng trả về null luôn. Comment trên type nói thẳng: cái này "để cho khách đã trả tiền thấy họ có gì, không bao giờ dùng để chặn quyền".

App chỉ nói những gì nó biết

Câu chữ nằm trong một hàm thuần, membershipStatus(), để test được mọi nhánh mà không phải render gì. Nó chọn một key dưới vault.premium.membership.status:

ts
export function membershipStatus(m: BillingMembership | null): MembershipStatus {
  if (!m) return { key: 'active' };
  if (m.productId === 'lifetime' || m.expiresAtMs === null) {
    // Subscription mà store báo không có ngày kết thúc vẫn không phải
    // trọn đời; chỉ nói là đang active, đừng hứa "mãi mãi".
    return m.productId === 'lifetime' ? { key: 'lifetime' } : { key: 'active' };
  }
  const dateMs = m.expiresAtMs;
  if (m.isTrial) return { key: m.willRenew ? 'trialRenews' : 'trialEnds', dateMs };
  return { key: m.willRenew ? 'renews' : 'ends', dateMs };
}

Các quy tắc đằng sau:

  • Chỉ nói trọn đời khi sản phẩm đúng là trọn đời. Subscription thiếu ngày kết thúc không phải "dùng mãi mãi". Nó chỉ là "Đang hoạt động".
  • Không có ngày thì không hứa ngày. Không biết ngày hết hạn thì app không tự bịa ra ngày gia hạn.
  • Sản phẩm lạ thì để tên trơn. Tên gói chỉ còn "Premium" thay vì đoán là gói tháng hay gói năm.
  • Dùng thử phải nói nó đang đi về đâu. "Dùng thử miễn phí · chuyển sang gói năm ngày …" và "Dùng thử miễn phí · kết thúc ngày …" là hai lời hứa khác nhau. Khách đã tắt tự gia hạn trong lúc dùng thử phải thấy câu thứ hai.
Logic quyết định từng dòng trạng thái
Sáu câu, và câu dự phòng luôn là câu khiêm tốn nhất

Ngày được format theo locale của chính người đọc bằng toLocaleDateString(locale, { day: 'numeric', month: 'long', year: 'numeric' }), nên người dùng Hàn và người dùng Đức thấy ngày theo đúng thứ tự quen của họ.

Hai màn hình đổi theo state sống

Màn hình Premium giờ có một nhánh return sớm. Khách Premium nhận PremiumActiveView: tiêu đề "Bạn đang dùng Premium", một thẻ ghi gói và dòng trạng thái, mọi tính năng gói của họ có, và nút Quản lý gói đăng ký.

tsx
// Khách đã trả tiền thấy gói của họ, không phải trang bán hàng; exit sheet
// vẫn render bên dưới để một mã ưu đãi vừa đổi có thể tự đóng nó.
if (isPremiumNow && !exitSheetVisible) {
  return <PremiumActiveView membership={membership} colors={colors} />;
}

Phần !exitSheetVisible có mặt vì một luồng. Exit offer của Lockboxy có thể đưa ra một mã ưu đãi. Khi mã được đổi, Premium bật lên trong lúc sheet vẫn đang mở, và sheet cần tự đóng xong trước khi màn hình chuyển.

Quản lý gói đăng ký mở trang của chính Apple, https://apps.apple.com/account/subscriptions (hoặc trang tương đương của Google Play trên Android). Huỷ, nâng cấp, đổi gói là việc của store, và trang của store thì luôn đúng. Nút này bị ẩn với gói trọn đời, vì chẳng có gì để quản lý.

Settings giữ nguyên cái thẻ để layout không nhảy, nhưng đổi chữ khi đã là Premium: tên gói làm tiêu đề, dòng trạng thái ở dưới. Bấm vào vẫn mở màn hình Premium, giờ hiện phần thông tin gói.

Cả hai màn hình đều đi theo listener entitlement. Một hook nhỏ, useMembershipDetails(isPremium), lấy lại thông tin gói mỗi lần Premium bật và bỏ nó đi khi Premium tắt, có cờ cancelled để một response chậm của state cũ không ghi đè lên state mới. Mua, khôi phục, đổi mã hay hết hạn đều làm UI đổi ngay mà không phải rời màn hình.

Mười ngôn ngữ, viết cho từng ngôn ngữ

Lockboxy có mười ngôn ngữ: Anh, Đức, Tây Ban Nha, Pháp, Ý, Nhật, Hàn, Bồ Đào Nha (Brazil), Việt và Trung giản thể. Chuỗi mới vào đủ mười file common.json trong cùng một commit, mỗi file 19 dòng. Bộ tiếng Việt:

json
"status": {
  "lifetime": "Đã mua một lần, dùng mãi mãi.",
  "trialRenews": "Dùng thử miễn phí · chuyển sang gói năm ngày {{date}}",
  "trialEnds": "Dùng thử miễn phí · kết thúc ngày {{date}}",
  "renews": "Tự gia hạn ngày {{date}}",
  "ends": "Hết hạn ngày {{date}} · đã tắt tự gia hạn",
  "active": "Đang hoạt động"
}

Một câu báo cho người ta biết quyền truy cập của họ sắp hết thì phải đúng tuyệt đối ở mọi ngôn ngữ. Dịch sai ở đây sinh ra một email hỗ trợ, chứ không chỉ là một màn hình đọc hơi kỳ.

Test cho trạng thái sau khi mua

Bộ test giờ có hẳn một block "a paying customer". Tên các test đọc như bản báo bug bị lộn trái:

txt
a paying customer
  ✓ sees their plan, its renewal date and a way to manage it — not the sales page
  ✓ a trial that was cancelled says when it ends
  ✓ lifetime has nothing to manage in the App Store
  ✓ a purchase turns the paywall into the membership view at once
Settings
  ✓ a Premium user sees their plan and when it renews, not "Go Premium"
  ✓ a lifetime purchase says so instead of a renewal date

Test đầu tiên còn kiểm một điều phủ định: thẻ gói năm không được có trên màn hình. Test mua hàng bắt đầu từ paywall thật, bấm gói năm, bấm nút mua, và chờ phần thông tin gói hiện ra mà không cần remount. Logic câu chữ thuần có test riêng, trong đó có "unknown details fall back to a plain 'active', never a guessed promise".

Fake billing port dùng chung cho test của cả app cũng có getMembership(), trả null khi user giả không phải Premium và một gói năm khi phải. Hook gọi nó bằng billingPort.getMembership?.() và nuốt luôn lỗi reject, nên một test cũ với mock thiếu method không làm sập một màn hình vốn chẳng quan tâm tới gói.

Rút ra

  • Paywall cần có trạng thái "sau khi mua". Khách đã trả tiền mở nó ra thì thấy gì? Viết màn hình đó trước khi ship paywall.
  • Tách chặn quyền (isPremium(), đồng bộ, phải đúng) khỏi mô tả (getMembership(), bất đồng bộ, được phép chung chung). Mô tả lỗi không bao giờ được làm mất quyền.
  • Đọc gói, ngày hết hạn, tự gia hạn và dùng thử từ entitlement đang active, và map giá trị lạ thành null thay vì đoán.
  • Đặt câu chữ trong một hàm thuần, và để câu dự phòng luôn là câu khiêm tốn nhất: "Đang hoạt động", không phải "mãi mãi".
  • Cho nút "Quản lý gói đăng ký" mở trang của chính store, và ẩn nó với gói trọn đời.
  • Dịch dòng trạng thái cho mọi ngôn ngữ bạn ship, và test màn hình của khách đã trả tiền kỹ như màn hình bán hàng.

Lockboxy ở lockboxy.io.vn, các app còn lại của mình ở apps.vanthuongdao.id.vn.

  • #Lockboxy
  • #RevenueCat
  • #React Native
  • #Subscriptions
Chia sẻXLinkedInFacebook
Dao Van Thuong

Kỹ sư mobile và fullstack ở TP. Hồ Chí Minh. Mình tự xây và phát hành các app iOS indie — Lockboxy, Linkeeper, Minivid, Ringsy, Talkzy, Baton và Stampzy.