.claude/skills/implement-in-app-purchase/SKILL.md
--- name: implement-in-app-purchase description: Implements In-App Purchase (consumable, non-consumable, or subscription) following the project architecture. Asks whether there is a backend server, then generates the complete implementation: InAppPurchaseService, Cubit, State, View, DI registration, and purchase verification flow (local storage via StorageService OR backend endpoint call). Use whenever adding purchases, subscriptions, paywall, or premium features to the app, integrating App Stor
npx skillsauth add andrelucassvt/CleanMacForDevsWeb .claude/skills/implement-in-app-purchaseInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Implementa o fluxo completo de In-App Purchase seguindo a arquitetura do projeto.
InAppPurchaseService + StorageService — sem Repository ou DataSource.completePurchase() NUNCA é chamado antes da verificação — local ou server-side.purchaseStream — nunca emita PurchaseSuccess direto no buyProduct()._purchaseSubscription no close() — nunca deixe o stream vazar.Antes de gerar qualquer código, faça TODAS as perguntas abaixo em uma única mensagem usando o vscode_askQuestions tool (ou liste claramente e aguarde resposta):
1. O app tem back-end próprio?
- SIM → o back-end valida e registra as compras
- NÃO → verificação ocorre 100% localmente no dispositivo
2. Quais tipos de produto você precisa implementar?
- [ ] Consumível (ex: pacotes de créditos, tokens)
- [ ] Assinatura (ex: plano mensal/anual)
- [ ] Não-consumível permanente (ex: remover anúncios)
3. Quais são os IDs dos produtos cadastrados nas lojas?
(App Store Connect e Google Play Console)
Ex: "basic_1", "pro_pack", "premium_monthly"
4. Qual é o nome da feature/tela onde ficará a lógica de compra?
Ex: "purchase", "paywall", "subscription"
Guarde as respostas para guiar toda a implementação.
Com base na resposta sobre back-end, siga um dos dois caminhos:
Arquitetura:
View → Cubit → InAppPurchaseService → App Store / Google Play
↑ ↓
purchaseStream PurchaseDetails
↓
verifyPurchase / verifySubscription (VerifyLocalPurchase)
↓
StorageService.setString('purchase_<productId>', verificationData)
No InAppPurchaseService, após verificação bem-sucedida:
purchase.verificationData.localVerificationData via StorageServicepurchase.verificationData.source para identificar a plataforma'purchase_receipt_<productId>'Exemplo no service:
Future<bool> verifyPurchase(
PurchaseDetails purchase,
StorageService storage,
) async {
final token = getOneTimePurchaseToken(purchase);
final isValid = await VerifyLocalPurchase().verifyPurchase(token);
if (isValid) {
await storage.setString(
'purchase_receipt_${purchase.productID}',
purchase.verificationData.localVerificationData,
);
await storage.setString(
'purchase_source_${purchase.productID}',
purchase.verificationData.source,
);
await _iap.completePurchase(purchase);
}
return isValid;
}
Future<bool> verifySubscription(
PurchaseDetails purchase,
StorageService storage,
) async {
final token = getSubscriptionToken(purchase);
final isValid = await VerifyLocalPurchase().verifySubscription(token);
if (isValid) {
await storage.setString(
'purchase_receipt_${purchase.productID}',
purchase.verificationData.localVerificationData,
);
await storage.setString(
'purchase_source_${purchase.productID}',
purchase.verificationData.source,
);
await _iap.completePurchase(purchase);
}
return isValid;
}
Dados salvos no StorageService:
| Chave | Valor | Descrição |
|---|---|---|
| purchase_receipt_<productId> | localVerificationData | Receipt iOS (base64) ou Purchase Token Android |
| purchase_source_<productId> | app_store / google_play | Plataforma de origem |
Aviso ao usuário: Informe que os dados de verificação ficam somente no dispositivo — se o usuário reinstalar o app, as compras não-consumíveis/assinaturas precisarão ser restauradas via restorePurchases().
Arquitetura:
View → Cubit → InAppPurchaseService → App Store / Google Play
↑ ↓
purchaseStream PurchaseDetails
↓
PurchaseRepository (interface no domain)
↓
PurchaseRepositoryImpl (data layer)
↓
PurchaseRemoteDataSource → POST /purchases/verify
Dado enviado ao back-end:
{
"product_id": purchase.productID,
"verification_data": purchase.verificationData.serverVerificationData,
"source": purchase.verificationData.source, // "app_store" | "google_play"
"local_verification_data": purchase.verificationData.localVerificationData,
}
Estrutura de arquivos adicionais (apenas no modo com back-end):
domain/
entities/purchase_entity.dart
interfaces/purchase_repository.dart
data/
models/purchase_model.dart
datasources/purchase_remote_datasource.dart
repositories/purchase_repository_impl.dart
Interface do Repository:
abstract class PurchaseRepository {
/// Envia dados de verificação ao back-end e retorna se a compra é válida
Future<Result<bool>> verifyPurchaseOnServer({
required String productId,
required String serverVerificationData,
required String localVerificationData,
required String source,
});
}
No Cubit (modo back-end):
InAppPurchaseService E PurchaseRepository_processPurchaseUpdates, após receber PurchaseStatus.purchased:
_purchaseRepository.verifyPurchaseOnServer(...) com os dados de verificaçãoresult.when(ok: ..., error: ...) para emitir o estado final_purchaseService.completePurchase(purchase) manualmente// Cubit com back-end — trecho do _processPurchaseUpdates
if (purchase.status == PurchaseStatus.purchased ||
purchase.status == PurchaseStatus.restored) {
try {
final result = await _purchaseRepository.verifyPurchaseOnServer(
productId: purchase.productID,
serverVerificationData: purchase.verificationData.serverVerificationData,
localVerificationData: purchase.verificationData.localVerificationData,
source: purchase.verificationData.source,
);
result.when(
ok: (isValid) async {
if (isValid) {
await _purchaseService.completePurchase(purchase);
}
emit(PurchaseSuccess(productId: purchase.productID, isValid: isValid));
},
error: (e) => emit(PurchaseError('Verificação no servidor falhou: $e')),
);
} catch (e) {
emit(PurchaseError('Erro inesperado: $e'));
}
}
✅ lib/common/services/in_app_purchase/in_app_purchase_service.dart
✅ lib/presentation/<feature>/view_model/<feature>_cubit.dart
✅ lib/presentation/<feature>/view_model/<feature>_state.dart
✅ lib/presentation/<feature>/view/<feature>_view.dart ← incluir links de Termos de Uso e Privacidade
✅ Registrar InAppPurchaseService (LazySingleton) e Cubit (Factory) em app_injector.dart
✅ Adicionar rota em app_routes.dart e app_router.dart
✅ Inicializar VerifyLocalPurchase em app_initializer.dart
✅ Adicionar strings premiumTermsOfUse e premiumPrivacyPolicy nos arquivos ARB
⚠️ Apple Guideline 3.1.2(c) — obrigatório para assinaturas: A View de paywall DEVE exibir links funcionais para Termos de Uso (EULA) e Política de Privacidade. Use
url_launchercomLaunchMode.externalApplication. Exemplo:import 'package:url_launcher/url_launcher.dart'; Row( mainAxisAlignment: MainAxisAlignment.center, children: [ TextButton( onPressed: () => launchUrl( Uri.parse('https://sua-url/termos'), mode: LaunchMode.externalApplication, ), style: TextButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 8), textStyle: theme.textTheme.bodySmall, ), child: Text(l10n.premiumTermsOfUse), ), Text('·', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurface.withValues(alpha: 0.5), )), TextButton( onPressed: () => launchUrl( Uri.parse('https://sua-url/privacidade'), mode: LaunchMode.externalApplication, ), style: TextButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 8), textStyle: theme.textTheme.bodySmall, ), child: Text(l10n.premiumPrivacyPolicy), ), ], ),
✅ Injetar StorageService no InAppPurchaseService (via construtor)
✅ Salvar dados de verificação com StorageService após compra validada
✅ lib/domain/entities/purchase_entity.dart
✅ lib/domain/interfaces/purchase_repository.dart
✅ lib/data/models/purchase_model.dart
✅ lib/data/datasources/purchase_remote_datasource.dart
✅ lib/data/repositories/purchase_repository_impl.dart
✅ Registrar DataSource e Repository em app_injector.dart
✅ Injetar PurchaseRepository no Cubit junto com InAppPurchaseService
Após gerar o código, valide cada item:
static const_productIds (consumíveis) e _subscriptionIds separados em SetspurchaseStream exposto para o CubitisSubscriptionProduct(productId) implementadocompletePurchase() chamado apenas após validação bem-sucedidaStorageService injetado via construtor; dados salvos após verificação_listenToPurchaseStream() chamado no construtor_purchaseSubscription cancelado no close()PurchaseStatus.pending ignorado (apenas continue)PurchaseStatus.error → emite PurchaseErrorPurchaseStatus.purchased e restored → verificam e emitem PurchaseSuccessbuyProduct() e buySubscription() não emitem PurchaseSuccess diretamentePurchaseLoading primeiro@immutable em todosconst em todos os construtoresInitial, Loading, ProductsLoaded, SubscriptionsLoaded, Success, ErrorSafeArea envolvendo o conteúdo principalBlocBuildercontext.l10n.<chave> — zero strings hardcoded_cubit.close() chamado no dispose()_cubit.loadProducts() ou loadSubscriptions() chamado no initState()url_launcher importado na View para abrir os linkspremiumTermsOfUse e premiumPrivacyPolicy adicionadas nos arquivos ARB (app_en.arb e app_pt.arb)InAppPurchaseService → registerLazySingletonStorageService já registrado (reutilizar existente)PurchaseRemoteDataSource → registerLazySingleton; PurchaseRepository → registerLazySingletonPurchaseCubit → registerFactoryVerifyLocalPurchase.initialize(...) chamado antes de setupDependenciesuseSandbox: flavor != AppFlavor.production no AppleConfigSempre informe ao usuário:
⚠️ Nunca confie apenas na validação client-side para desbloquear conteúdo premium.
- Sem back-end: a verificação via
verify_local_purchaseé uma proteção razoável contra fraudes simples, mas não é infalível. Considere migrar para validação server-side no futuro.- Com back-end: valide SEMPRE no servidor antes de conceder acesso ao conteúdo. Nunca desbloqueie premium apenas com base no retorno do
purchaseStreamsem passar pelo seu servidor.
Após gerar o código-esqueleto, lembre o usuário de substituir os placeholders:
📋 Substitua no código gerado:
- 'com.example.app' → Bundle ID / Package Name real do app
- 'your-issuer-id' → Issuer ID da App Store Connect
- 'your-key-id' → Key ID da App Store Connect
- '-----BEGIN PRIVATE KEY-----' → Chave privada da App Store Connect
- '{"type":"service_account"}' → JSON da conta de serviço do Google Play
- IDs dos produtos → IDs exatos cadastrados nas lojas
| Tipo | Método de compra | Método de verificação | completePurchase? |
|---|---|---|---|
| Consumível | buyConsumable | verifyPurchase | ✅ Sim (obrigatório Android) |
| Assinatura | buyNonConsumable | verifySubscription | ✅ Sim |
| Não-consumível permanente | buyNonConsumable | verifySubscription | ✅ Sim |
PurchaseSuccess direto no buyProduct — o resultado vem pelo streamInAppPurchaseService diretamente da View_purchaseSubscription antes do dispose() do CubitPurchaseStatus.errorSharedPreferences diretamente — use sempre StorageServiceserverVerificationData localmente — use apenas localVerificationDataÚltima atualização: 28 de março de 2026
testing
Create new skills, modify and improve existing skills, and measure skill performance. Use when users want to create a skill from scratch, edit, or optimize an existing skill, run evals to test a skill, benchmark skill performance with variance analysis, or optimize a skill's description for better triggering accuracy. Activate even when the user says 'create a skill for X', 'the skill is not triggering', 'improve this skill description', 'the agent is not using the skill', 'add a skill to teach the agent how to do X', 'this skill is wrong', or 'update the skill' without explicitly mentioning evals or benchmark.
development
Implements Flutter reusable widgets following the project architecture. Use whenever creating or modifying widgets in presentation/<feature>/widgets/, presentation/<feature>/content/, or common/widgets/. Covers StatelessWidget vs StatefulWidget decision, Entity as parameter, i18n, dispose, componentization rules, and when to access the Cubit via context.read. Activate even when the user says 'extract this to a widget', 'create a list item widget', 'build a reusable card', 'factor out this UI block', 'create a component for this', or 'this View is getting too big' without explicitly mentioning StatelessWidget or reusable components.
tools
Implements Flutter View screens following the project architecture. Use whenever creating or modifying a View (StatefulWidget + Cubit + BlocBuilder), adding a new screen, wiring up BlocBuilder/BlocConsumer/BlocListener, setting up SafeArea, or navigating from the View. Covers State, Cubit, View file, route, DI registration, and common mistakes. Activate even when the user just says "create a screen" or "add a new page", without explicitly mentioning Cubit or BLoC.
testing
Implements Flutter Cubit and State (View Model layer) following the project architecture. Use whenever creating or modifying a Cubit or State class, adding an async method to a Cubit, handling form submission or validation, implementing debounce search, managing loading/error/navigation states, or wiring a Cubit to a Repository or StorageService. Covers sealed States, async patterns with Result<T>, CRUD Cubits, local persistence via StorageService, navigation states, debounce, and common mistakes. Activate even when the user says "add a method", "handle the loading state", or "save locally" without explicitly mentioning Cubit or BLoC.