762 lines
21 KiB
Markdown
762 lines
21 KiB
Markdown
# ET-PAY-002: Integración Stripe API Completa
|
|
|
|
**Epic:** OQI-005 Pagos y Stripe
|
|
**Versión:** 1.0
|
|
**Fecha:** 2025-12-05
|
|
**Responsable:** Requirements-Analyst
|
|
|
|
---
|
|
|
|
## 1. Descripción
|
|
|
|
Integración completa con Stripe API para:
|
|
- Gestión de Customers
|
|
- Payment Intents (one-time payments)
|
|
- Subscriptions (pagos recurrentes)
|
|
- Payment Methods
|
|
- Invoices
|
|
- Refunds
|
|
- Stripe Elements (frontend)
|
|
|
|
---
|
|
|
|
## 2. Arquitectura de Integración
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────────┐
|
|
│ Stripe Integration Stack │
|
|
├─────────────────────────────────────────────────────────────────┤
|
|
│ │
|
|
│ Frontend Backend Stripe API │
|
|
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
|
│ │ Stripe │ │ Stripe │ │ Stripe │ │
|
|
│ │ Elements │─────►│ Service │───►│ Platform │ │
|
|
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
|
│ │ │ │ │
|
|
│ │ │ │ │
|
|
│ ▼ ▼ ▼ │
|
|
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
|
│ │ React │ │ Database │ │ Webhooks │ │
|
|
│ │ Components │ │ Persist │◄───│ Handler │ │
|
|
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
|
│ │
|
|
└─────────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
---
|
|
|
|
## 3. Stripe Service Implementation
|
|
|
|
### 3.1 Core Stripe Service
|
|
|
|
```typescript
|
|
// src/services/stripe/stripe.service.ts
|
|
|
|
import Stripe from 'stripe';
|
|
import { AppError } from '../../utils/errors';
|
|
import { logger } from '../../utils/logger';
|
|
|
|
export class StripeService {
|
|
private stripe: Stripe;
|
|
|
|
constructor() {
|
|
const secretKey = process.env.STRIPE_SECRET_KEY!;
|
|
|
|
this.stripe = new Stripe(secretKey, {
|
|
apiVersion: '2024-11-20.acacia',
|
|
typescript: true,
|
|
maxNetworkRetries: 3,
|
|
timeout: 30000,
|
|
});
|
|
}
|
|
|
|
// ============ CUSTOMERS ============
|
|
|
|
/**
|
|
* Crea un customer en Stripe
|
|
*/
|
|
async createCustomer(params: {
|
|
email: string;
|
|
name?: string;
|
|
phone?: string;
|
|
metadata?: Record<string, string>;
|
|
}): Promise<Stripe.Customer> {
|
|
try {
|
|
const customer = await this.stripe.customers.create({
|
|
email: params.email,
|
|
name: params.name,
|
|
phone: params.phone,
|
|
metadata: params.metadata || {},
|
|
});
|
|
|
|
logger.info('Stripe customer created', { customer_id: customer.id });
|
|
return customer;
|
|
} catch (error: any) {
|
|
logger.error('Failed to create Stripe customer', { error: error.message });
|
|
throw new AppError('Failed to create customer', 500);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Actualiza un customer
|
|
*/
|
|
async updateCustomer(
|
|
customerId: string,
|
|
params: Stripe.CustomerUpdateParams
|
|
): Promise<Stripe.Customer> {
|
|
try {
|
|
return await this.stripe.customers.update(customerId, params);
|
|
} catch (error: any) {
|
|
throw new AppError('Failed to update customer', 500);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Obtiene un customer
|
|
*/
|
|
async getCustomer(customerId: string): Promise<Stripe.Customer> {
|
|
try {
|
|
return await this.stripe.customers.retrieve(customerId) as Stripe.Customer;
|
|
} catch (error: any) {
|
|
throw new AppError('Customer not found', 404);
|
|
}
|
|
}
|
|
|
|
// ============ PAYMENT INTENTS ============
|
|
|
|
/**
|
|
* Crea un Payment Intent
|
|
*/
|
|
async createPaymentIntent(params: {
|
|
amount: number;
|
|
currency?: string;
|
|
customer_id?: string;
|
|
payment_method?: string;
|
|
metadata?: Record<string, string>;
|
|
description?: string;
|
|
}): Promise<Stripe.PaymentIntent> {
|
|
try {
|
|
const paymentIntent = await this.stripe.paymentIntents.create({
|
|
amount: Math.round(params.amount * 100), // Convertir a centavos
|
|
currency: params.currency || 'usd',
|
|
customer: params.customer_id,
|
|
payment_method: params.payment_method,
|
|
confirmation_method: 'manual',
|
|
confirm: false,
|
|
metadata: params.metadata || {},
|
|
description: params.description,
|
|
});
|
|
|
|
logger.info('Payment Intent created', {
|
|
payment_intent_id: paymentIntent.id,
|
|
amount: params.amount,
|
|
});
|
|
|
|
return paymentIntent;
|
|
} catch (error: any) {
|
|
logger.error('Failed to create Payment Intent', { error: error.message });
|
|
throw new AppError('Failed to create payment intent', 500);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Confirma un Payment Intent
|
|
*/
|
|
async confirmPaymentIntent(
|
|
paymentIntentId: string,
|
|
paymentMethodId?: string
|
|
): Promise<Stripe.PaymentIntent> {
|
|
try {
|
|
const params: Stripe.PaymentIntentConfirmParams = {};
|
|
if (paymentMethodId) {
|
|
params.payment_method = paymentMethodId;
|
|
}
|
|
|
|
return await this.stripe.paymentIntents.confirm(paymentIntentId, params);
|
|
} catch (error: any) {
|
|
throw new AppError('Payment confirmation failed', 400);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cancela un Payment Intent
|
|
*/
|
|
async cancelPaymentIntent(paymentIntentId: string): Promise<Stripe.PaymentIntent> {
|
|
try {
|
|
return await this.stripe.paymentIntents.cancel(paymentIntentId);
|
|
} catch (error: any) {
|
|
throw new AppError('Failed to cancel payment', 400);
|
|
}
|
|
}
|
|
|
|
// ============ PAYMENT METHODS ============
|
|
|
|
/**
|
|
* Adjunta Payment Method a Customer
|
|
*/
|
|
async attachPaymentMethod(
|
|
paymentMethodId: string,
|
|
customerId: string
|
|
): Promise<Stripe.PaymentMethod> {
|
|
try {
|
|
return await this.stripe.paymentMethods.attach(paymentMethodId, {
|
|
customer: customerId,
|
|
});
|
|
} catch (error: any) {
|
|
throw new AppError('Failed to attach payment method', 400);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Desvincula Payment Method
|
|
*/
|
|
async detachPaymentMethod(paymentMethodId: string): Promise<Stripe.PaymentMethod> {
|
|
try {
|
|
return await this.stripe.paymentMethods.detach(paymentMethodId);
|
|
} catch (error: any) {
|
|
throw new AppError('Failed to detach payment method', 400);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Lista Payment Methods de un Customer
|
|
*/
|
|
async listPaymentMethods(
|
|
customerId: string,
|
|
type: 'card' | 'bank_account' = 'card'
|
|
): Promise<Stripe.PaymentMethod[]> {
|
|
try {
|
|
const paymentMethods = await this.stripe.paymentMethods.list({
|
|
customer: customerId,
|
|
type,
|
|
});
|
|
|
|
return paymentMethods.data;
|
|
} catch (error: any) {
|
|
throw new AppError('Failed to list payment methods', 500);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Establece Payment Method como default
|
|
*/
|
|
async setDefaultPaymentMethod(
|
|
customerId: string,
|
|
paymentMethodId: string
|
|
): Promise<Stripe.Customer> {
|
|
try {
|
|
return await this.stripe.customers.update(customerId, {
|
|
invoice_settings: {
|
|
default_payment_method: paymentMethodId,
|
|
},
|
|
});
|
|
} catch (error: any) {
|
|
throw new AppError('Failed to set default payment method', 400);
|
|
}
|
|
}
|
|
|
|
// ============ SUBSCRIPTIONS ============
|
|
|
|
/**
|
|
* Crea una suscripción
|
|
*/
|
|
async createSubscription(params: {
|
|
customer_id: string;
|
|
price_id: string;
|
|
trial_days?: number;
|
|
metadata?: Record<string, string>;
|
|
}): Promise<Stripe.Subscription> {
|
|
try {
|
|
const subscriptionParams: Stripe.SubscriptionCreateParams = {
|
|
customer: params.customer_id,
|
|
items: [{ price: params.price_id }],
|
|
metadata: params.metadata || {},
|
|
payment_behavior: 'default_incomplete',
|
|
expand: ['latest_invoice.payment_intent'],
|
|
};
|
|
|
|
if (params.trial_days) {
|
|
subscriptionParams.trial_period_days = params.trial_days;
|
|
}
|
|
|
|
const subscription = await this.stripe.subscriptions.create(subscriptionParams);
|
|
|
|
logger.info('Subscription created', {
|
|
subscription_id: subscription.id,
|
|
customer_id: params.customer_id,
|
|
});
|
|
|
|
return subscription;
|
|
} catch (error: any) {
|
|
logger.error('Failed to create subscription', { error: error.message });
|
|
throw new AppError('Failed to create subscription', 500);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Actualiza una suscripción
|
|
*/
|
|
async updateSubscription(
|
|
subscriptionId: string,
|
|
params: Stripe.SubscriptionUpdateParams
|
|
): Promise<Stripe.Subscription> {
|
|
try {
|
|
return await this.stripe.subscriptions.update(subscriptionId, params);
|
|
} catch (error: any) {
|
|
throw new AppError('Failed to update subscription', 500);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cancela una suscripción
|
|
*/
|
|
async cancelSubscription(
|
|
subscriptionId: string,
|
|
immediate: boolean = false
|
|
): Promise<Stripe.Subscription> {
|
|
try {
|
|
if (immediate) {
|
|
return await this.stripe.subscriptions.cancel(subscriptionId);
|
|
} else {
|
|
return await this.stripe.subscriptions.update(subscriptionId, {
|
|
cancel_at_period_end: true,
|
|
});
|
|
}
|
|
} catch (error: any) {
|
|
throw new AppError('Failed to cancel subscription', 500);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reactiva una suscripción cancelada
|
|
*/
|
|
async resumeSubscription(subscriptionId: string): Promise<Stripe.Subscription> {
|
|
try {
|
|
return await this.stripe.subscriptions.update(subscriptionId, {
|
|
cancel_at_period_end: false,
|
|
});
|
|
} catch (error: any) {
|
|
throw new AppError('Failed to resume subscription', 500);
|
|
}
|
|
}
|
|
|
|
// ============ INVOICES ============
|
|
|
|
/**
|
|
* Obtiene una factura
|
|
*/
|
|
async getInvoice(invoiceId: string): Promise<Stripe.Invoice> {
|
|
try {
|
|
return await this.stripe.invoices.retrieve(invoiceId);
|
|
} catch (error: any) {
|
|
throw new AppError('Invoice not found', 404);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Lista facturas de un customer
|
|
*/
|
|
async listInvoices(
|
|
customerId: string,
|
|
limit: number = 10
|
|
): Promise<Stripe.Invoice[]> {
|
|
try {
|
|
const invoices = await this.stripe.invoices.list({
|
|
customer: customerId,
|
|
limit,
|
|
});
|
|
|
|
return invoices.data;
|
|
} catch (error: any) {
|
|
throw new AppError('Failed to list invoices', 500);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Paga una factura manualmente
|
|
*/
|
|
async payInvoice(invoiceId: string): Promise<Stripe.Invoice> {
|
|
try {
|
|
return await this.stripe.invoices.pay(invoiceId);
|
|
} catch (error: any) {
|
|
throw new AppError('Failed to pay invoice', 400);
|
|
}
|
|
}
|
|
|
|
// ============ REFUNDS ============
|
|
|
|
/**
|
|
* Crea un reembolso
|
|
*/
|
|
async createRefund(params: {
|
|
payment_intent_id: string;
|
|
amount?: number;
|
|
reason?: 'duplicate' | 'fraudulent' | 'requested_by_customer';
|
|
metadata?: Record<string, string>;
|
|
}): Promise<Stripe.Refund> {
|
|
try {
|
|
const refundParams: Stripe.RefundCreateParams = {
|
|
payment_intent: params.payment_intent_id,
|
|
metadata: params.metadata || {},
|
|
};
|
|
|
|
if (params.amount) {
|
|
refundParams.amount = Math.round(params.amount * 100);
|
|
}
|
|
|
|
if (params.reason) {
|
|
refundParams.reason = params.reason;
|
|
}
|
|
|
|
const refund = await this.stripe.refunds.create(refundParams);
|
|
|
|
logger.info('Refund created', {
|
|
refund_id: refund.id,
|
|
payment_intent: params.payment_intent_id,
|
|
amount: params.amount,
|
|
});
|
|
|
|
return refund;
|
|
} catch (error: any) {
|
|
logger.error('Failed to create refund', { error: error.message });
|
|
throw new AppError('Failed to create refund', 500);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Obtiene un reembolso
|
|
*/
|
|
async getRefund(refundId: string): Promise<Stripe.Refund> {
|
|
try {
|
|
return await this.stripe.refunds.retrieve(refundId);
|
|
} catch (error: any) {
|
|
throw new AppError('Refund not found', 404);
|
|
}
|
|
}
|
|
|
|
// ============ PRICES ============
|
|
|
|
/**
|
|
* Lista precios de un producto
|
|
*/
|
|
async listPrices(productId?: string): Promise<Stripe.Price[]> {
|
|
try {
|
|
const params: Stripe.PriceListParams = { active: true };
|
|
if (productId) {
|
|
params.product = productId;
|
|
}
|
|
|
|
const prices = await this.stripe.prices.list(params);
|
|
return prices.data;
|
|
} catch (error: any) {
|
|
throw new AppError('Failed to list prices', 500);
|
|
}
|
|
}
|
|
|
|
// ============ SETUP INTENTS ============
|
|
|
|
/**
|
|
* Crea Setup Intent para guardar payment method
|
|
*/
|
|
async createSetupIntent(customerId: string): Promise<Stripe.SetupIntent> {
|
|
try {
|
|
return await this.stripe.setupIntents.create({
|
|
customer: customerId,
|
|
payment_method_types: ['card'],
|
|
});
|
|
} catch (error: any) {
|
|
throw new AppError('Failed to create setup intent', 500);
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 4. Frontend Integration
|
|
|
|
### 4.1 Stripe Elements Configuration
|
|
|
|
```typescript
|
|
// src/config/stripe.config.ts
|
|
|
|
export const STRIPE_ELEMENTS_OPTIONS = {
|
|
fonts: [
|
|
{
|
|
cssSrc: 'https://fonts.googleapis.com/css?family=Roboto',
|
|
},
|
|
],
|
|
locale: 'en' as const,
|
|
};
|
|
|
|
export const CARD_ELEMENT_OPTIONS = {
|
|
style: {
|
|
base: {
|
|
fontSize: '16px',
|
|
color: '#424770',
|
|
'::placeholder': {
|
|
color: '#aab7c4',
|
|
},
|
|
fontFamily: '"Roboto", sans-serif',
|
|
},
|
|
invalid: {
|
|
color: '#9e2146',
|
|
iconColor: '#9e2146',
|
|
},
|
|
},
|
|
hidePostalCode: false,
|
|
};
|
|
```
|
|
|
|
### 4.2 Payment Form Component
|
|
|
|
```typescript
|
|
// src/components/payments/PaymentForm.tsx
|
|
|
|
import React, { useState } from 'react';
|
|
import { loadStripe } from '@stripe/stripe-js';
|
|
import {
|
|
Elements,
|
|
CardElement,
|
|
useStripe,
|
|
useElements,
|
|
} from '@stripe/react-stripe-js';
|
|
import { paymentApi } from '../../api/payment.api';
|
|
import { CARD_ELEMENT_OPTIONS } from '../../config/stripe.config';
|
|
|
|
const stripePromise = loadStripe(process.env.REACT_APP_STRIPE_PUBLISHABLE_KEY!);
|
|
|
|
interface PaymentFormProps {
|
|
amount: number;
|
|
onSuccess: (paymentIntentId: string) => void;
|
|
onError: (error: string) => void;
|
|
}
|
|
|
|
const PaymentFormContent: React.FC<PaymentFormProps> = ({
|
|
amount,
|
|
onSuccess,
|
|
onError,
|
|
}) => {
|
|
const stripe = useStripe();
|
|
const elements = useElements();
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
|
|
if (!stripe || !elements) return;
|
|
|
|
setLoading(true);
|
|
|
|
try {
|
|
// Crear Payment Method
|
|
const cardElement = elements.getElement(CardElement);
|
|
if (!cardElement) throw new Error('Card element not found');
|
|
|
|
const { error: pmError, paymentMethod } = await stripe.createPaymentMethod({
|
|
type: 'card',
|
|
card: cardElement,
|
|
});
|
|
|
|
if (pmError || !paymentMethod) {
|
|
throw new Error(pmError?.message || 'Failed to create payment method');
|
|
}
|
|
|
|
// Crear Payment Intent en backend
|
|
const response = await paymentApi.createPaymentIntent({
|
|
amount,
|
|
payment_method_id: paymentMethod.id,
|
|
});
|
|
|
|
const { client_secret } = response.data.payment_intent;
|
|
|
|
// Confirmar pago
|
|
const { error: confirmError, paymentIntent } = await stripe.confirmCardPayment(
|
|
client_secret
|
|
);
|
|
|
|
if (confirmError) {
|
|
throw new Error(confirmError.message);
|
|
}
|
|
|
|
if (paymentIntent?.status === 'succeeded') {
|
|
onSuccess(paymentIntent.id);
|
|
}
|
|
} catch (err: any) {
|
|
onError(err.message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit}>
|
|
<div className="form-group">
|
|
<label>Card Details</label>
|
|
<CardElement options={CARD_ELEMENT_OPTIONS} />
|
|
</div>
|
|
|
|
<button type="submit" disabled={!stripe || loading}>
|
|
{loading ? 'Processing...' : `Pay $${amount.toFixed(2)}`}
|
|
</button>
|
|
</form>
|
|
);
|
|
};
|
|
|
|
export const PaymentForm: React.FC<PaymentFormProps> = (props) => {
|
|
return (
|
|
<Elements stripe={stripePromise}>
|
|
<PaymentFormContent {...props} />
|
|
</Elements>
|
|
);
|
|
};
|
|
```
|
|
|
|
### 4.3 Subscription Form Component
|
|
|
|
```typescript
|
|
// src/components/payments/SubscriptionForm.tsx
|
|
|
|
import React, { useState } from 'react';
|
|
import { useStripe, useElements, CardElement } from '@stripe/react-stripe-js';
|
|
import { subscriptionApi } from '../../api/subscription.api';
|
|
|
|
interface SubscriptionFormProps {
|
|
priceId: string;
|
|
planName: string;
|
|
onSuccess: () => void;
|
|
}
|
|
|
|
export const SubscriptionForm: React.FC<SubscriptionFormProps> = ({
|
|
priceId,
|
|
planName,
|
|
onSuccess,
|
|
}) => {
|
|
const stripe = useStripe();
|
|
const elements = useElements();
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
|
|
if (!stripe || !elements) return;
|
|
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
// Crear Payment Method
|
|
const cardElement = elements.getElement(CardElement);
|
|
if (!cardElement) throw new Error('Card element not found');
|
|
|
|
const { error: pmError, paymentMethod } = await stripe.createPaymentMethod({
|
|
type: 'card',
|
|
card: cardElement,
|
|
});
|
|
|
|
if (pmError || !paymentMethod) {
|
|
throw new Error(pmError?.message || 'Failed to create payment method');
|
|
}
|
|
|
|
// Crear suscripción en backend
|
|
const response = await subscriptionApi.create({
|
|
price_id: priceId,
|
|
payment_method_id: paymentMethod.id,
|
|
});
|
|
|
|
const { subscription, client_secret } = response.data;
|
|
|
|
// Si requiere confirmación
|
|
if (client_secret) {
|
|
const { error: confirmError } = await stripe.confirmCardPayment(client_secret);
|
|
|
|
if (confirmError) {
|
|
throw new Error(confirmError.message);
|
|
}
|
|
}
|
|
|
|
onSuccess();
|
|
} catch (err: any) {
|
|
setError(err.message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit}>
|
|
<h3>Subscribe to {planName}</h3>
|
|
|
|
<CardElement />
|
|
|
|
{error && <div className="error">{error}</div>}
|
|
|
|
<button type="submit" disabled={!stripe || loading}>
|
|
{loading ? 'Processing...' : 'Subscribe'}
|
|
</button>
|
|
</form>
|
|
);
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## 5. Configuración
|
|
|
|
### 5.1 Variables de Entorno
|
|
|
|
```bash
|
|
# Backend
|
|
STRIPE_SECRET_KEY=sk_test_...
|
|
STRIPE_WEBHOOK_SECRET=whsec_...
|
|
STRIPE_API_VERSION=2024-11-20.acacia
|
|
|
|
# Frontend
|
|
REACT_APP_STRIPE_PUBLISHABLE_KEY=pk_test_...
|
|
```
|
|
|
|
---
|
|
|
|
## 6. Testing
|
|
|
|
### 6.1 Test con Stripe Test Mode
|
|
|
|
```typescript
|
|
// tests/stripe/payment-intent.test.ts
|
|
|
|
import { StripeService } from '../../src/services/stripe/stripe.service';
|
|
|
|
describe('Stripe Payment Intents', () => {
|
|
let stripeService: StripeService;
|
|
|
|
beforeAll(() => {
|
|
stripeService = new StripeService();
|
|
});
|
|
|
|
it('should create payment intent', async () => {
|
|
const paymentIntent = await stripeService.createPaymentIntent({
|
|
amount: 100.00,
|
|
metadata: { test: 'true' },
|
|
});
|
|
|
|
expect(paymentIntent.amount).toBe(10000); // 100 * 100 centavos
|
|
expect(paymentIntent.currency).toBe('usd');
|
|
});
|
|
|
|
it('should confirm payment intent with test card', async () => {
|
|
// Usar tarjeta de prueba: 4242424242424242
|
|
// Implementar lógica de confirmación
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## 7. Referencias
|
|
|
|
- [Stripe API Documentation](https://stripe.com/docs/api)
|
|
- [Stripe React Elements](https://stripe.com/docs/stripe-js/react)
|
|
- [Payment Intents Guide](https://stripe.com/docs/payments/payment-intents)
|
|
- [Subscriptions Guide](https://stripe.com/docs/billing/subscriptions/overview)
|