Skip to content

Bab 36 — Langganan: Stripe & PayPal

Menerima pembayaran adalah fitur yang paling tidak boleh salah. Kesalahan di sini bukan sekadar bug — ia berarti pengguna membayar tanpa mendapat apa-apa, atau mendapat sesuatu tanpa membayar.

Bab ini membahas prinsip yang menjaganya tetap benar, lalu implementasinya dengan Stripe dan PayPal.

Aturan yang tidak boleh dilanggar

Sebelum satu baris kode pun, tiga prinsip ini menentukan apakah sistem pembayaranmu aman atau tidak.

1. Aplikasi tidak pernah memutuskan status pembayaran.

dart
// ❌ BERBAHAYA — aplikasi bisa dibongkar dan nilai ini diubah
if (pembayaranBerhasil) {
  await _db.collection('users').doc(uid).update({'premium': true});
}

Siapa pun yang membongkar aplikasimu bisa memanggil baris itu langsung. Status premium hanya boleh ditulis oleh server setelah memverifikasi pembayaran ke penyedia.

2. Kunci rahasia tidak pernah ada di aplikasi.

Stripe punya dua jenis kunci: publishable key (pk_...) yang aman ada di aplikasi, dan secret key (sk_...) yang tidak pernah boleh keluar dari server. Kunci rahasia bisa dipakai untuk mengembalikan dana, membaca data pelanggan, dan membuat tagihan.

3. Webhook adalah sumber kebenaran.

Pengguna bisa menutup aplikasi tepat setelah membayar. Kalau aplikasimu yang bertugas melaporkan keberhasilan, pembayaran itu hilang. Webhook dari penyedia ke servermu terjadi terlepas dari apa yang dilakukan aplikasi.

   ❌ ALUR BERBAHAYA
   Aplikasi → bayar → aplikasi bilang "berhasil" → Firestore
   (bisa dipalsukan, dan hilang kalau aplikasi ditutup)

   ✅ ALUR AMAN
   Aplikasi → bayar → Stripe → webhook → Cloud Function → Firestore

                                    memverifikasi tanda tangan
                                    sebelum menulis apa pun

Aturan toko aplikasi

Google Play dan App Store mewajibkan pembayaran untuk konten digital melalui sistem penagihan mereka sendiri — dengan komisi 15–30%.

Stripe dan PayPal hanya boleh dipakai untuk:

  • Barang atau jasa fisik.
  • Layanan yang dikonsumsi di luar aplikasi.
  • Aplikasi di luar toko (web, distribusi langsung).

Untuk langganan konten digital di dalam aplikasi, kamu wajib memakai in_app_purchase atau RevenueCat. Melanggar ini menyebabkan aplikasimu ditolak — atau dihapus setelah sempat terbit.

Bab ini tetap membahas Stripe dan PayPal karena keduanya relevan untuk banyak model bisnis, dan karena prinsip keamanannya berlaku universal.

Model data langganan

dart
// lib/data/models/langganan.dart
import 'package:cloud_firestore/cloud_firestore.dart';

enum TingkatLangganan { gratis, pro, bisnis }

enum StatusLangganan { aktif, tertunda, kedaluwarsa, dibatalkan }

class Langganan {
  const Langganan({
    required this.tingkat,
    required this.status,
    this.berlakuSampai,
    this.idPelanggan,
    this.idLangganan,
    this.penyedia,
  });

  final TingkatLangganan tingkat;
  final StatusLangganan status;
  final DateTime? berlakuSampai;
  final String? idPelanggan;    // customer id di Stripe
  final String? idLangganan;    // subscription id
  final String? penyedia;       // 'stripe' atau 'paypal'

  static const gratis = Langganan(
    tingkat: TingkatLangganan.gratis,
    status: StatusLangganan.aktif,
  );

  bool get aktif {
    if (tingkat == TingkatLangganan.gratis) return true;
    if (status != StatusLangganan.aktif) return false;
    final sampai = berlakuSampai;
    if (sampai == null) return false;
    return sampai.isAfter(DateTime.now());
  }

  bool get bebasIklan => aktif && tingkat != TingkatLangganan.gratis;

  int get batasPollPerHari => switch (tingkat) {
        TingkatLangganan.gratis => 3,
        TingkatLangganan.pro => 25,
        TingkatLangganan.bisnis => 999,
      };

  bool get bolehPollUnggulan => tingkat != TingkatLangganan.gratis;

  factory Langganan.dariMap(Map<String, dynamic>? data) {
    if (data == null) return gratis;

    return Langganan(
      tingkat: TingkatLangganan.values.firstWhere(
        (t) => t.name == data['tingkat'],
        orElse: () => TingkatLangganan.gratis,
      ),
      status: StatusLangganan.values.firstWhere(
        (s) => s.name == data['status'],
        orElse: () => StatusLangganan.kedaluwarsa,
      ),
      berlakuSampai: (data['berlakuSampai'] as Timestamp?)?.toDate(),
      idPelanggan: data['idPelanggan'] as String?,
      idLangganan: data['idLangganan'] as String?,
      penyedia: data['penyedia'] as String?,
    );
  }
}

Perhatikan bahwa aktif memeriksa tanggal kedaluwarsa, bukan hanya status. Ini penting: kalau webhook pembatalan gagal sampai, langganan tetap berakhir pada waktunya.

Aturan keamanan Firestore

Ini yang menegakkan prinsip pertama secara struktural.

javascript
match /users/{userId} {
  allow read: if request.auth != null;

  allow update: if request.auth != null
                && request.auth.uid == userId
                // Pengguna TIDAK BOLEH mengubah field langganan
                && !request.resource.data.diff(resource.data)
                     .affectedKeys()
                     .hasAny(['langganan', 'tingkat', 'berlakuSampai']);
}

// Dokumen langganan hanya bisa dibaca, tidak pernah ditulis klien
match /subscriptions/{userId} {
  allow read: if request.auth != null && request.auth.uid == userId;
  allow write: if false;   // hanya Admin SDK dari Cloud Function
}

allow write: if false terlihat drastis, dan memang begitu maksudnya. Cloud Function memakai Admin SDK yang melewati aturan keamanan, sehingga tetap bisa menulis. Klien tidak bisa, apa pun yang terjadi.

Stripe

Menyiapkan

yaml
# pubspec.yaml
dependencies:
  flutter_stripe: ^11.3.0
  cloud_functions: ^5.1.5
dart
// lib/main.dart
import 'package:flutter_stripe/flutter_stripe.dart';

const _kunciPublik = String.fromEnvironment('STRIPE_PUBLISHABLE_KEY');

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  Stripe.publishableKey = _kunciPublik;   // pk_... — aman di aplikasi
  Stripe.merchantIdentifier = 'merchant.com.contoh.aplikasi';
  await Stripe.instance.applySettings();

  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );

  runApp(const AplikasiSaya());
}

Android butuh minSdkVersion 21 dan tema yang kompatibel:

xml
<!-- android/app/src/main/res/values/styles.xml -->
<style name="LaunchTheme" parent="Theme.MaterialComponents.DayNight.NoActionBar">

Cloud Function: membuat sesi pembayaran

javascript
// functions/index.js
const { onCall, HttpsError } = require('firebase-functions/v2/https');
const { defineSecret } = require('firebase-functions/params');
const { getFirestore, Timestamp } = require('firebase-admin/firestore');
const { initializeApp } = require('firebase-admin/app');

initializeApp();
const db = getFirestore();

const stripeSecret = defineSecret('STRIPE_SECRET_KEY');

const HARGA = {
  pro: 'price_1AbCdEfGhIjKlMnO',
  bisnis: 'price_1XyZaBcDeFgHiJkL',
};

exports.buatPaymentSheet = onCall(
  { secrets: [stripeSecret], region: 'asia-southeast1' },
  async (request) => {
    if (!request.auth) {
      throw new HttpsError('unauthenticated', 'Silakan masuk terlebih dahulu.');
    }

    const uid = request.auth.uid;
    const tingkat = request.data?.tingkat;

    if (!HARGA[tingkat]) {
      throw new HttpsError('invalid-argument', 'Paket tidak dikenal.');
    }

    const stripe = require('stripe')(stripeSecret.value());

    // Ambil atau buat customer — jangan buat ganda
    const refPengguna = db.doc(`users/${uid}`);
    const snap = await refPengguna.get();
    let idPelanggan = snap.data()?.stripeCustomerId;

    if (!idPelanggan) {
      const pelanggan = await stripe.customers.create({
        email: request.auth.token.email,
        metadata: { firebaseUid: uid },   // penting untuk webhook
      });
      idPelanggan = pelanggan.id;
      await refPengguna.set({ stripeCustomerId: idPelanggan }, { merge: true });
    }

    // Kunci efemeral agar aplikasi bisa menampilkan metode pembayaran tersimpan
    const ephemeralKey = await stripe.ephemeralKeys.create(
      { customer: idPelanggan },
      { apiVersion: '2024-06-20' }
    );

    const langganan = await stripe.subscriptions.create({
      customer: idPelanggan,
      items: [{ price: HARGA[tingkat] }],
      payment_behavior: 'default_incomplete',
      payment_settings: { save_default_payment_method: 'on_subscription' },
      expand: ['latest_invoice.payment_intent'],
      metadata: { firebaseUid: uid, tingkat },
    });

    return {
      clientSecret: langganan.latest_invoice.payment_intent.client_secret,
      ephemeralKey: ephemeralKey.secret,
      customerId: idPelanggan,
      subscriptionId: langganan.id,
    };
  }
);

metadata.firebaseUid adalah detail penting: ia menghubungkan pelanggan Stripe dengan pengguna Firebase, sehingga webhook nanti tahu dokumen mana yang harus diperbarui.

Cloud Function: webhook

Inilah yang benar-benar memberi akses premium.

javascript
// functions/index.js
const { onRequest } = require('firebase-functions/v2/https');

const webhookSecret = defineSecret('STRIPE_WEBHOOK_SECRET');

exports.stripeWebhook = onRequest(
  { secrets: [stripeSecret, webhookSecret], region: 'asia-southeast1' },
  async (req, res) => {
    const stripe = require('stripe')(stripeSecret.value());

    let event;
    try {
      // ⚠️ WAJIB — memverifikasi bahwa permintaan benar dari Stripe
      event = stripe.webhooks.constructEvent(
        req.rawBody,
        req.headers['stripe-signature'],
        webhookSecret.value()
      );
    } catch (err) {
      console.error('Tanda tangan webhook tidak valid:', err.message);
      return res.status(400).send('Tanda tangan tidak valid');
    }

    try {
      switch (event.type) {
        case 'customer.subscription.created':
        case 'customer.subscription.updated': {
          const sub = event.data.object;
          const uid = sub.metadata?.firebaseUid;
          if (!uid) break;

          const aktif = ['active', 'trialing'].includes(sub.status);

          await db.doc(`subscriptions/${uid}`).set(
            {
              tingkat: aktif ? (sub.metadata.tingkat ?? 'pro') : 'gratis',
              status: aktif ? 'aktif' : 'kedaluwarsa',
              berlakuSampai: Timestamp.fromMillis(
                sub.current_period_end * 1000
              ),
              idPelanggan: sub.customer,
              idLangganan: sub.id,
              penyedia: 'stripe',
              diperbaruiPada: Timestamp.now(),
            },
            { merge: true }
          );
          break;
        }

        case 'customer.subscription.deleted': {
          const sub = event.data.object;
          const uid = sub.metadata?.firebaseUid;
          if (!uid) break;

          await db.doc(`subscriptions/${uid}`).set(
            {
              tingkat: 'gratis',
              status: 'dibatalkan',
              diperbaruiPada: Timestamp.now(),
            },
            { merge: true }
          );
          break;
        }

        case 'invoice.payment_failed': {
          const invoice = event.data.object;
          const pelanggan = await stripe.customers.retrieve(invoice.customer);
          const uid = pelanggan.metadata?.firebaseUid;
          if (!uid) break;

          await db.doc(`subscriptions/${uid}`).set(
            { status: 'tertunda', diperbaruiPada: Timestamp.now() },
            { merge: true }
          );
          break;
        }
      }

      // Selalu balas 200, atau Stripe akan mengirim ulang terus-menerus
      res.status(200).send({ received: true });
    } catch (err) {
      console.error('Gagal memproses webhook:', err);
      res.status(500).send('Kesalahan internal');
    }
  }
);

Verifikasi tanda tangan tidak boleh dilewati

Tanpa constructEvent, siapa pun yang tahu URL webhook-mu bisa mengirim permintaan palsu dan memberi dirinya sendiri langganan premium seumur hidup.

req.rawBody juga penting — verifikasi memakai isi mentah, bukan JSON yang sudah diurai. Firebase Functions menyediakannya secara otomatis.

Mendaftarkan webhook: Stripe Dashboard → DevelopersWebhooksAdd endpoint, arahkan ke URL fungsimu, dan pilih event yang ditangani di atas.

Sisi Flutter

dart
// lib/data/services/layanan_stripe.dart
import 'package:cloud_functions/cloud_functions.dart';
import 'package:flutter_stripe/flutter_stripe.dart';

class KesalahanPembayaran implements Exception {
  KesalahanPembayaran(this.pesan, {this.dibatalkan = false});

  final String pesan;
  final bool dibatalkan;

  @override
  String toString() => pesan;
}

class LayananStripe {
  final _fungsi =
      FirebaseFunctions.instanceFor(region: 'asia-southeast1');

  Future<void> berlangganan(String tingkat) async {
    try {
      // 1. Minta server membuat sesi
      final hasil = await _fungsi
          .httpsCallable('buatPaymentSheet')
          .call<Map<String, dynamic>>({'tingkat': tingkat});

      final data = hasil.data;

      // 2. Siapkan lembar pembayaran
      await Stripe.instance.initPaymentSheet(
        paymentSheetParameters: SetupPaymentSheetParameters(
          paymentIntentClientSecret: data['clientSecret'] as String,
          customerEphemeralKeySecret: data['ephemeralKey'] as String,
          customerId: data['customerId'] as String,
          merchantDisplayName: 'Aplikasi Polling',
          style: ThemeMode.system,
          googlePay: const PaymentSheetGooglePay(
            merchantCountryCode: 'ID',
            testEnv: true,   // ubah menjadi false saat rilis
          ),
          applePay: const PaymentSheetApplePay(merchantCountryCode: 'ID'),
        ),
      );

      // 3. Tampilkan — pengguna memasukkan kartu di UI milik Stripe
      await Stripe.instance.presentPaymentSheet();

      // Sampai di sini berarti pembayaran diterima.
      // TETAPI status premium menunggu webhook, bukan baris ini.
    } on StripeException catch (e) {
      if (e.error.code == FailureCode.Canceled) {
        throw KesalahanPembayaran('Pembayaran dibatalkan.', dibatalkan: true);
      }
      throw KesalahanPembayaran(
        e.error.localizedMessage ?? 'Pembayaran gagal.',
      );
    } on FirebaseFunctionsException catch (e) {
      throw KesalahanPembayaran(
        switch (e.code) {
          'unauthenticated' => 'Silakan masuk terlebih dahulu.',
          'invalid-argument' => 'Paket tidak tersedia.',
          _ => 'Gagal memproses pembayaran.',
        },
      );
    }
  }

  Future<void> batalkan() async {
    await _fungsi.httpsCallable('batalkanLangganan').call();
  }
}

Detail penting: kartu pengguna tidak pernah menyentuh kodemu. Lembar pembayaran adalah UI milik Stripe, dan datanya langsung dikirim ke server mereka. Ini yang membuatmu tidak perlu sertifikasi PCI.

Ada jeda beberapa detik antara pembayaran diterima dan webhook memperbarui Firestore. UI harus menanganinya dengan anggun.

dart
// lib/providers/penyedia_langganan.dart
import 'dart:async';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/foundation.dart';
import '../data/models/langganan.dart';
import '../data/services/layanan_stripe.dart';

class PenyediaLangganan extends ChangeNotifier {
  PenyediaLangganan({required String uid, required LayananStripe stripe})
      : _uid = uid,
        _stripe = stripe {
    _dengarkan();
  }

  final String _uid;
  final LayananStripe _stripe;
  StreamSubscription<DocumentSnapshot<Map<String, dynamic>>>? _langganan;

  Langganan _data = Langganan.gratis;
  bool _memproses = false;
  bool _menungguWebhook = false;
  String? _kesalahan;

  Langganan get data => _data;
  bool get memproses => _memproses;
  bool get menungguWebhook => _menungguWebhook;
  String? get kesalahan => _kesalahan;
  bool get bebasIklan => _data.bebasIklan;

  void _dengarkan() {
    // Dokumen ini hanya bisa ditulis server — aman dijadikan sumber kebenaran
    _langganan = FirebaseFirestore.instance
        .doc('subscriptions/$_uid')
        .snapshots()
        .listen((doc) {
      _data = Langganan.dariMap(doc.data());

      // Webhook sudah tiba
      if (_menungguWebhook && _data.aktif &&
          _data.tingkat != TingkatLangganan.gratis) {
        _menungguWebhook = false;
      }

      notifyListeners();
    });
  }

  Future<bool> berlangganan(String tingkat) async {
    _memproses = true;
    _kesalahan = null;
    notifyListeners();

    try {
      await _stripe.berlangganan(tingkat);

      // Pembayaran diterima — sekarang tunggu webhook
      _menungguWebhook = true;
      notifyListeners();

      // Beri batas waktu agar tidak menunggu selamanya
      await _tungguAktif(const Duration(seconds: 30));
      return _data.aktif;
    } on KesalahanPembayaran catch (e) {
      if (!e.dibatalkan) _kesalahan = e.pesan;
      return false;
    } finally {
      _memproses = false;
      _menungguWebhook = false;
      notifyListeners();
    }
  }

  Future<void> _tungguAktif(Duration batas) async {
    final selesai = DateTime.now().add(batas);

    while (DateTime.now().isBefore(selesai)) {
      if (_data.aktif && _data.tingkat != TingkatLangganan.gratis) return;
      await Future.delayed(const Duration(milliseconds: 500));
    }
  }

  @override
  void dispose() {
    _langganan?.cancel();
    super.dispose();
  }
}

Perhatikan _tungguAktif yang punya batas waktu. Kalau webhook terlambat lebih dari 30 detik, UI tetap lanjut — dan snapshots() akan memperbarui status begitu webhook akhirnya tiba, kapan pun itu.

PayPal

PayPal memakai alur berbeda: pengguna diarahkan ke halaman web PayPal, lalu kembali ke aplikasi.

yaml
dependencies:
  flutter_paypal_payment: ^1.0.8
dart
// lib/presentation/screens/layar_paypal.dart
import 'package:flutter/material.dart';
import 'package:flutter_paypal_payment/flutter_paypal_payment.dart';

class LayarPayPal extends StatelessWidget {
  const LayarPayPal({
    super.key,
    required this.jumlah,
    required this.deskripsi,
    required this.saatSukses,
  });

  final String jumlah;
  final String deskripsi;
  final void Function(String idTransaksi) saatSukses;

  @override
  Widget build(BuildContext context) {
    return PaypalCheckoutView(
      sandboxMode: true,   // false saat rilis
      clientId: const String.fromEnvironment('PAYPAL_CLIENT_ID'),
      secretKey: const String.fromEnvironment('PAYPAL_SECRET'),

      transactions: [
        {
          'amount': {
            'total': jumlah,
            'currency': 'USD',
            'details': {
              'subtotal': jumlah,
              'shipping': '0',
              'shipping_discount': 0,
            },
          },
          'description': deskripsi,
          'item_list': {
            'items': [
              {
                'name': deskripsi,
                'quantity': 1,
                'price': jumlah,
                'currency': 'USD',
              }
            ],
          },
        }
      ],

      onSuccess: (Map hasil) {
        final id = hasil['data']?['id'] as String? ?? '';
        Navigator.pop(context);
        saatSukses(id);
      },

      onError: (kesalahan) {
        Navigator.pop(context);
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('Pembayaran gagal: $kesalahan')),
        );
      },

      onCancel: () {
        Navigator.pop(context);
      },
    );
  }
}

Secret key PayPal di aplikasi adalah risiko

Paket ini meminta secretKey di sisi klien, yang berarti kunci itu bisa diekstrak dari aplikasi.

Untuk produksi, jangan pakai pola ini. Alih-alih:

  1. Cloud Function membuat order lewat PayPal REST API.
  2. Aplikasi hanya membuka URL persetujuan di peramban.
  3. Webhook PayPal memberi tahu servermu ketika pembayaran selesai.

Polanya identik dengan Stripe di atas, dan alasannya sama.

Verifikasi di server tetap wajib:

javascript
exports.verifikasiPayPal = onCall(
  { secrets: [paypalClientId, paypalSecret], region: 'asia-southeast1' },
  async (request) => {
    if (!request.auth) {
      throw new HttpsError('unauthenticated', 'Silakan masuk.');
    }

    const idOrder = request.data?.idOrder;
    if (!idOrder) {
      throw new HttpsError('invalid-argument', 'ID order diperlukan.');
    }

    // 1. Ambil token akses
    const kredensial = Buffer.from(
      `${paypalClientId.value()}:${paypalSecret.value()}`
    ).toString('base64');

    const resToken = await fetch(
      'https://api-m.paypal.com/v1/oauth2/token',
      {
        method: 'POST',
        headers: {
          Authorization: `Basic ${kredensial}`,
          'Content-Type': 'application/x-www-form-urlencoded',
        },
        body: 'grant_type=client_credentials',
      }
    );
    const { access_token } = await resToken.json();

    // 2. Periksa status order LANGSUNG KE PAYPAL,
    //    jangan percaya apa yang dikirim aplikasi
    const resOrder = await fetch(
      `https://api-m.paypal.com/v2/checkout/orders/${idOrder}`,
      { headers: { Authorization: `Bearer ${access_token}` } }
    );
    const order = await resOrder.json();

    if (order.status !== 'COMPLETED') {
      throw new HttpsError('failed-precondition', 'Pembayaran belum selesai.');
    }

    // 3. Baru berikan aksesnya
    const uid = request.auth.uid;
    const sampai = new Date();
    sampai.setMonth(sampai.getMonth() + 1);

    await db.doc(`subscriptions/${uid}`).set(
      {
        tingkat: 'pro',
        status: 'aktif',
        berlakuSampai: Timestamp.fromDate(sampai),
        idLangganan: idOrder,
        penyedia: 'paypal',
        diperbaruiPada: Timestamp.now(),
      },
      { merge: true }
    );

    return { sukses: true };
  }
);

Langkah kedua itu intinya: server memeriksa langsung ke PayPal, tidak mempercayai klaim dari aplikasi.

Layar paket

dart
// lib/presentation/screens/layar_paket.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../data/models/langganan.dart';
import '../../providers/penyedia_langganan.dart';

class LayarPaket extends StatelessWidget {
  const LayarPaket({super.key});

  static const _paket = [
    (
      tingkat: TingkatLangganan.gratis,
      nama: 'Gratis',
      harga: 'Rp0',
      periode: '',
      fitur: [
        '3 polling per hari',
        'Statistik dasar',
        'Dengan iklan',
      ],
    ),
    (
      tingkat: TingkatLangganan.pro,
      nama: 'Pro',
      harga: 'Rp49.000',
      periode: '/bulan',
      fitur: [
        '25 polling per hari',
        'Polling unggulan',
        'Tanpa iklan',
        'Statistik lengkap',
      ],
    ),
    (
      tingkat: TingkatLangganan.bisnis,
      nama: 'Bisnis',
      harga: 'Rp149.000',
      periode: '/bulan',
      fitur: [
        'Polling tanpa batas',
        'Polling unggulan prioritas',
        'Tanpa iklan',
        'Ekspor data',
        'Dukungan prioritas',
      ],
    ),
  ];

  @override
  Widget build(BuildContext context) {
    final penyedia = context.watch<PenyediaLangganan>();

    return Scaffold(
      appBar: AppBar(title: const Text('Pilih Paket')),
      body: Stack(
        children: [
          ListView(
            padding: const EdgeInsets.all(16),
            children: [
              for (final p in _paket)
                _KartuPaket(
                  nama: p.nama,
                  harga: p.harga,
                  periode: p.periode,
                  fitur: p.fitur,
                  aktif: penyedia.data.tingkat == p.tingkat,
                  disarankan: p.tingkat == TingkatLangganan.pro,
                  saatPilih: p.tingkat == TingkatLangganan.gratis ||
                          penyedia.memproses
                      ? null
                      : () => _berlangganan(context, p.tingkat.name),
                ),

              const SizedBox(height: 24),
              Text(
                'Langganan diperpanjang otomatis. Kamu bisa membatalkannya '
                'kapan saja dari halaman ini.',
                style: Theme.of(context).textTheme.bodySmall,
                textAlign: TextAlign.center,
              ),
            ],
          ),

          if (penyedia.menungguWebhook)
            const Positioned.fill(
              child: ColoredBox(
                color: Color(0xAA000000),
                child: Center(
                  child: Card(
                    child: Padding(
                      padding: EdgeInsets.all(24),
                      child: Column(
                        mainAxisSize: MainAxisSize.min,
                        children: [
                          CircularProgressIndicator(),
                          SizedBox(height: 16),
                          Text('Mengaktifkan langganan...'),
                          SizedBox(height: 4),
                          Text(
                            'Ini biasanya hanya butuh beberapa detik.',
                            style: TextStyle(fontSize: 12),
                          ),
                        ],
                      ),
                    ),
                  ),
                ),
              ),
            ),
        ],
      ),
    );
  }

  Future<void> _berlangganan(BuildContext context, String tingkat) async {
    final penyedia = context.read<PenyediaLangganan>();
    final berhasil = await penyedia.berlangganan(tingkat);

    if (!context.mounted) return;

    if (berhasil) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('Langganan aktif. Terima kasih!')),
      );
    } else if (penyedia.kesalahan != null) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text(penyedia.kesalahan!)),
      );
    }
  }
}

class _KartuPaket extends StatelessWidget {
  const _KartuPaket({
    required this.nama,
    required this.harga,
    required this.periode,
    required this.fitur,
    required this.aktif,
    required this.disarankan,
    required this.saatPilih,
  });

  final String nama;
  final String harga;
  final String periode;
  final List<String> fitur;
  final bool aktif;
  final bool disarankan;
  final VoidCallback? saatPilih;

  @override
  Widget build(BuildContext context) {
    final skema = Theme.of(context).colorScheme;

    return Card(
      elevation: disarankan ? 4 : 1,
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(16),
        side: BorderSide(
          color: disarankan ? skema.primary : skema.outlineVariant,
          width: disarankan ? 2 : 1,
        ),
      ),
      child: Padding(
        padding: const EdgeInsets.all(20),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(
              children: [
                Text(nama, style: Theme.of(context).textTheme.titleLarge),
                const SizedBox(width: 8),
                if (disarankan)
                  Chip(
                    label: const Text('Populer'),
                    backgroundColor: skema.primaryContainer,
                    visualDensity: VisualDensity.compact,
                  ),
                const Spacer(),
                if (aktif)
                  Icon(Icons.check_circle, color: skema.primary),
              ],
            ),
            const SizedBox(height: 8),
            Row(
              crossAxisAlignment: CrossAxisAlignment.baseline,
              textBaseline: TextBaseline.alphabetic,
              children: [
                Text(
                  harga,
                  style: Theme.of(context).textTheme.headlineMedium,
                ),
                Text(periode,
                    style: Theme.of(context).textTheme.bodyMedium),
              ],
            ),
            const SizedBox(height: 16),
            for (final f in fitur)
              Padding(
                padding: const EdgeInsets.only(bottom: 6),
                child: Row(
                  children: [
                    Icon(Icons.check, size: 18, color: skema.primary),
                    const SizedBox(width: 8),
                    Expanded(child: Text(f)),
                  ],
                ),
              ),
            const SizedBox(height: 16),
            SizedBox(
              width: double.infinity,
              child: aktif
                  ? OutlinedButton(
                      onPressed: null,
                      child: const Text('Paket Aktif'),
                    )
                  : FilledButton(
                      onPressed: saatPilih,
                      child: const Text('Pilih Paket'),
                    ),
            ),
          ],
        ),
      ),
    );
  }
}

Menegakkan batas paket

Batas harus ditegakkan di server, bukan hanya di UI.

javascript
exports.buatPolling = onCall({ region: 'asia-southeast1' }, async (request) => {
  if (!request.auth) {
    throw new HttpsError('unauthenticated', 'Silakan masuk.');
  }

  const uid = request.auth.uid;

  // Ambil tingkat langganan dari dokumen yang hanya bisa ditulis server
  const snapLangganan = await db.doc(`subscriptions/${uid}`).get();
  const data = snapLangganan.data() ?? {};

  const aktif =
    data.status === 'aktif' &&
    data.berlakuSampai?.toMillis?.() > Date.now();

  const tingkat = aktif ? data.tingkat : 'gratis';
  const batas = { gratis: 3, pro: 25, bisnis: 999 }[tingkat] ?? 3;

  // Hitung polling yang dibuat hari ini
  const awalHari = new Date();
  awalHari.setHours(0, 0, 0, 0);

  const hariIni = await db
    .collection('polls')
    .where('pemilikId', '==', uid)
    .where('dibuatPada', '>=', Timestamp.fromDate(awalHari))
    .count()
    .get();

  if (hariIni.data().count >= batas) {
    throw new HttpsError(
      'resource-exhausted',
      `Batas ${batas} polling per hari tercapai. Tingkatkan paketmu untuk lebih banyak.`
    );
  }

  // ... buat polling
});

Perhatikan .count() — agregasi Firestore yang menghitung dokumen tanpa membacanya satu per satu. Jauh lebih murah daripada get() lalu docs.length.

Di sisi UI, tampilkan batasnya agar pengguna tahu sebelum mencoba:

dart
class TombolBuatPolling extends StatelessWidget {
  const TombolBuatPolling({super.key});

  @override
  Widget build(BuildContext context) {
    final langganan = context.watch<PenyediaLangganan>().data;
    final terpakai = context.watch<PenyediaPoll>().jumlahHariIni;
    final batas = langganan.batasPollPerHari;
    final tersisa = batas - terpakai;

    if (tersisa <= 0) {
      return Card(
        color: Theme.of(context).colorScheme.errorContainer,
        child: ListTile(
          leading: const Icon(Icons.lock_outline),
          title: const Text('Batas harian tercapai'),
          subtitle: Text('Paketmu mengizinkan $batas polling per hari.'),
          trailing: FilledButton(
            onPressed: () => Navigator.pushNamed(context, '/paket'),
            child: const Text('Tingkatkan'),
          ),
        ),
      );
    }

    return Column(
      children: [
        FilledButton.icon(
          onPressed: () => Navigator.pushNamed(context, '/buat-polling'),
          icon: const Icon(Icons.add),
          label: const Text('Buat Polling'),
        ),
        const SizedBox(height: 4),
        Text(
          'Tersisa $tersisa dari $batas hari ini',
          style: Theme.of(context).textTheme.bodySmall,
        ),
      ],
    );
  }
}

Alur lengkap

Menguji

Stripe menyediakan kartu uji:

Nomor kartuHasil
4242 4242 4242 4242Berhasil
4000 0000 0000 0002Ditolak
4000 0025 0000 3155Butuh autentikasi 3D Secure
4000 0000 0000 9995Dana tidak cukup

Tanggal kedaluwarsa apa pun di masa depan, CVC apa pun.

Untuk menguji webhook secara lokal:

bash
stripe listen --forward-to localhost:5001/proyek/asia-southeast1/stripeWebhook
stripe trigger customer.subscription.created

PayPal menyediakan akun sandbox di developer.paypal.com.

Daftar periksa sebelum rilis

  • Kunci berpindah dari test ke live (pk_live_, sk_live_)
  • sandboxMode PayPal diubah menjadi false
  • testEnv Google Pay diubah menjadi false
  • Webhook produksi terdaftar dengan URL yang benar
  • Verifikasi tanda tangan webhook AKTIF
  • Aturan Firestore melarang klien menulis field langganan
  • Batas paket ditegakkan di server, bukan hanya UI
  • Alur pembatalan berfungsi
  • Kebijakan privasi & syarat layanan tersedia
  • Sesuai aturan toko (in-app purchase untuk konten digital)

Latihan Mandiri

Kerjakan salah satu, beberapa, atau semuanya secara berurutan untuk melatih pemahamanmu sampai benar-benar lekat.

Variasi 1: Siapa Boleh Menulis Apa — ⭐⭐ · 45–60 menit

Tantangan: Rancang — dan tulis aturan keamanannya — untuk fitur langganan sederhana, tanpa perlu menyelesaikan integrasi pembayarannya. Yang diuji di sini adalah siapa yang boleh menulis apa, karena itulah bagian yang kalau salah membuat siapa pun bisa memberi dirinya langganan gratis.

Kriteria selesai:

  • Model data langganan punya status, tanggal mulai, dan tanggal kedaluwarsa — dan aplikasi memeriksa tanggal kedaluwarsa, bukan hanya status.
  • Aturan Firestore menyatakan bahwa field langganan tidak bisa ditulis dari aplikasi sama sekali, dan kamu sudah membuktikannya dengan mencoba menulisnya dari kode.
  • Ada keadaan "sedang mengaktifkan" dengan batas waktu, untuk jeda antara pembayaran selesai dan webhook tiba.
  • Status langganan diawasi lewat snapshots(), sehingga aktif dengan sendirinya kapan pun webhook tiba — tanpa pengguna perlu menyegarkan.
  • Kamu bisa menjelaskan dalam satu paragraf kenapa langganan ini tidak boleh dirilis ke toko aplikasi dalam bentuk sekarang.

Petunjuk: Aturan emasnya cukup satu kalimat: aplikasi tidak pernah memutuskan status pembayaran. Semua yang lain mengikuti dari sana. Kalau aplikasi boleh menulis field langganan, maka siapa pun yang membongkar aplikasimu — dan itu tidak sulit — bisa menulisnya juga. allow write: if false pada field itu terasa ekstrem sampai kamu menyadari bahwa server punya jalur sendiri yang tidak melewati aturan ini. Soal tanggal kedaluwarsa: memeriksa status saja berarti langganan yang webhook pembatalannya gagal terkirim akan aktif selamanya. Dan untuk kriteria terakhir, jawabannya ada di awal bab — langganan yang membuka fitur di dalam aplikasi harus melewati sistem pembayaran toko.

Variasi 2: Alur Bayar Sandbox — ⭐⭐⭐ · 45–60 menit

Tantangan: Selesaikan satu alur pembayaran penuh dalam mode uji: dari tombol beli, lewat lembar pembayaran, sampai konfirmasi. Uji seluruh nomor kartu uji yang tersedia — yang berhasil, yang ditolak, dan yang butuh otentikasi tambahan.

Bayangan tampilan:

text
╭───────────────────────────────────╮
│ 9:41                      ▂▄▆ 🔋   │
│  Langganan Premium  Rp 49.000/bln  │
│          [ Berlangganan ]          │
│              ▬▬▬▬                  │
╰───────────────────────────────────╯

Tap "Berlangganan" → lembar pembayaran milik penyedia yang terbuka (bukan formulir buatanmu sendiri):

text
┌─────────────────────────────────┐
│ Bayar dengan Kartu               │
│ Nomor Kartu [____-____-____-___]│
│         [ Bayar Rp 49.000 ]      │
└─────────────────────────────────┘

Tiga hasil kartu uji berbeda tampil berbeda: ✓ Berhasil, ✗ Ditolak, ⚠ Perlu verifikasi tambahan (3D Secure).

Kriteria selesai:

  • Ketiga hasil ditangani dengan tampilan berbeda, termasuk kartu yang butuh verifikasi tambahan.
  • Data kartu tidak pernah menyentuh kodemu — lembar pembayaran milik penyedia yang menanganinya.
  • Hanya publishable key yang ada di aplikasi; tidak ada sk_ di seluruh repositori.
  • Pengguna yang menutup aplikasi tepat setelah membayar tetap mendapat langganannya saat membuka lagi.

Petunjuk: Kartu yang membutuhkan otentikasi tambahan adalah jalur yang paling sering dilupakan, dan di beberapa wilayah ia justru mayoritas transaksi. Penyedia menyediakan nomor kartu uji khusus untuk itu — cari daftarnya dan jalankan semuanya. Kriteria terakhir adalah alasan webhook ada: aplikasi yang ditutup tidak bisa memberi tahu siapa pun bahwa pembayarannya berhasil.

Variasi 3: Webhook sebagai Kebenaran — ⭐⭐⭐⭐ · 60–90 menit

Tantangan: Bangun penanganan webhook untuk empat kejadian: pembayaran berhasil, pembayaran gagal, langganan diperpanjang, dan langganan dibatalkan. Setiap kejadian harus memperbarui dokumen pengguna yang benar, dan aman terhadap pengiriman ganda.

Kriteria selesai:

  • Tanda tangan webhook diverifikasi, dan permintaan tanpa tanda tangan sah ditolak.
  • Kejadian yang sama dikirim dua kali tidak menghasilkan efek ganda.
  • Webhook selalu membalas 200 setelah diterima, bahkan ketika pemrosesannya gagal — dan kegagalan itu dicatat untuk ditangani terpisah.
  • Pemetaan dari pelanggan penyedia ke pengguna aplikasimu bekerja tanpa menebak.

Petunjuk: Verifikasi tanda tangan bukan formalitas: tanpanya, siapa pun yang tahu alamat endpoint-mu bisa mengirim permintaan palsu dan memberi dirinya langganan seumur hidup. Untuk pengiriman ganda, simpan ID kejadian yang sudah diproses dan lewati yang sudah ada — penyedia memang sengaja mengirim ulang ketika tidak yakin pesannya sampai. Dan untuk pemetaan, simpan pengenal penggunamu di metadata pelanggan saat pertama dibuat.

Variasi 4: Menegakkan Batas Paket — ⭐⭐⭐⭐ · 60–90 menit

Tantangan: Bangun tiga tingkat paket dengan batas yang berbeda, lalu tegakkan batas itu di tiga tempat: UI yang menyembunyikan, aplikasi yang menolak, dan aturan keamanan yang menjadi benteng terakhir. Tangani juga apa yang terjadi ketika pengguna turun paket sementara pemakaiannya melebihi batas baru.

Bayangan tampilan:

text
╭───────────────────────────────────╮
│ 9:41                      ▂▄▆ 🔋   │
│  Paket: Gratis (5 dari 5 proyek)   │
│  [ + Proyek Baru ]  ← disembunyikan│
│    "Upgrade untuk tambah proyek"   │
│              ▬▬▬▬                  │
╰───────────────────────────────────╯

Turun dari paket Pro (20 proyek) ke Gratis (5) saat sudah punya 8 proyek → kedelapan proyek tetap ada dan bisa dibuka, tapi tombol "+ Proyek Baru" terkunci sampai jumlahnya turun ke 5 atau kurang.

Kriteria selesai:

  • Ketiga lapisan penegakan ada, dan kamu bisa menjelaskan kenapa satu saja tidak cukup.
  • Pengguna yang turun paket tidak kehilangan data, tetapi tidak bisa menambah sampai kembali di bawah batas.
  • Langganan yang kedaluwarsa menurunkan paket secara otomatis.
  • Definisi paket berada di satu tempat, dan mengubah batas cukup mengubah satu angka.

Petunjuk: Kriteria kedua adalah keputusan produk yang penting: menghapus data pengguna yang turun paket adalah cara tercepat kehilangan kepercayaan mereka selamanya. Menyimpannya tetapi mengunci penambahan memberi mereka jalan kembali, dan itu juga jauh lebih mudah dijelaskan. Untuk tiga lapisan penegakan: UI mencegah kebingungan, aplikasi mencegah kesalahan, dan aturan keamanan mencegah penyalahgunaan yang disengaja.

Ikhtisar

  • Aplikasi tidak pernah memutuskan status pembayaran. Hanya server yang boleh menulis field langganan — tegakkan lewat aturan Firestore (allow write: if false).
  • Kunci rahasia tidak pernah ada di aplikasi. Publishable key (pk_) aman; secret key (sk_) hanya di server.
  • Webhook adalah sumber kebenaran, karena pengguna bisa menutup aplikasi tepat setelah membayar.
  • Verifikasi tanda tangan webhook wajib — tanpanya siapa pun bisa mengirim permintaan palsu dan memberi dirinya langganan gratis.
  • Selalu balas 200 ke webhook, atau penyedia mengirim ulang terus-menerus.
  • Simpan firebaseUid di metadata pelanggan agar webhook tahu dokumen mana yang diperbarui.
  • Data kartu tidak pernah menyentuh kodemu — lembar pembayaran adalah UI milik Stripe.
  • Ada jeda beberapa detik antara pembayaran dan webhook. Tampilkan keadaan "mengaktifkan" dengan batas waktu, dan biarkan snapshots() memperbarui status kapan pun webhook tiba.
  • Periksa tanggal kedaluwarsa, bukan hanya status — agar langganan tetap berakhir kalau webhook pembatalan gagal.
  • Verifikasi pembayaran langsung ke penyedia di server; jangan percaya klaim dari aplikasi.
  • Tegakkan batas paket di server. Pakai .count() Firestore agar murah.
  • Google Play dan App Store mewajibkan sistem penagihan mereka untuk konten digital. Stripe/PayPal hanya untuk barang fisik atau layanan di luar aplikasi.

Berikutnya: Bab 37 — Git & GitHub.

Transkrip asli

Disintesis dari 4_flutter_ai-chatbot-n-firebase/10_subscription-management-with-stripe-and-paypal.md (16 video: UI featuring poll, PayPal setup & implementation, sorting featured polls, auto-unfeature, Cloud Functions untuk Stripe, publishing & deploying, Stripe setup, tokenizing card details, customer ID, error handling, managing subscription data, free tier restrictions). Lihat PDF Firebase & AI.

Rangkuman pembelajaran pribadi, disusun ulang dari beberapa kursus Flutter.