@bigbinary/neeto-payments-frontend v3.3.30
neeto-payments-nano
The neeto-payments-nano is a comprehensive payment processing solution designed for the Neeto ecosystem. Implemented as a Ruby on Rails engine with associated React frontend components (@bigbinary/neeto-payments-frontend), it provides a unified interface for managing payments across different providers, abstracting away provider-specific complexities.
This engine enables host applications to:
- Integrate with multiple payment providers (Stripe, Razorpay, UPI).
- Process payments.
- Handle payment splits for marketplace scenarios (Stripe).
- Manage refunds.
- Store and reuse payment methods securely (optional).
- Configure fee structures and apply taxes/discounts.
- Provide administrative dashboards for monitoring transactions, refunds, payouts, and accounts.
Table of Contents
- Installation (Backend Engine)
- Configuration (Backend Engine)
- Frontend Integration
- Core Concepts
- Webhook Handling
- Authentication (JWT for OAuth)
- Callbacks
- Exposed Entities
- API Endpoints
- Incineration Concern
- Deprecated Patterns
- Development Environment Setup
- Helper methods
- Testing & Debugging
- Gotchas & Tips
- Publishing
Installation (Backend Engine)
Follow these steps to integrate the neeto-payments-engine into your host Rails application:
1. Add the Gem
Add the gem to your application's Gemfile:
# Gemfile
source "NEETO_GEM_SERVER_URL" do
gem "neeto-payments-engine"
end2. Install Gems
Run bundler to install the gem and its dependencies:
bundle install3. Install Migrations
Copy the engine's database migrations into your host application. This step is crucial.
bundle exec rails neeto_payments_engine:install:migrations4. Run Migrations
Apply the migrations to your database:
bundle exec rails db:migrate5. Mount the Engine
Add the engine's routes to your application's config/routes.rb:
# config/routes.rb
mount NeetoPaymentsEngine::Engine, at: "/payments" # Or your preferred mount pointThis makes the engine's API endpoints available, by default under /payments.
Once the installation is done, we have to do some configuration for the engine to work as intended. Please go through the whole README to complete the process of setting up neeto-payments-nano in your host app.
Configuration (Backend Engine)
1. Initializer
Create an initializer file config/initializers/neeto_payments_engine.rb to configure the engine:
# config/initializers/neeto_payments_engine.rb
NeetoPaymentsEngine.is_card_preserving_enabled = false # Set to true to enable saving payment methods
# IMPORTANT: Configure which model holds the payment integration for each provider.
# Replace "Organization" or "User" with your actual model names.
# This configuration is MANDATORY.
NeetoPaymentsEngine.providers_holdable_class = {
stripe_standard: "Organization", # e.g., Organization or User that owns the Stripe Standard account
stripe_platform: "Organization", # Typically Organization that owns the Stripe Platform account
razorpay: "Organization", # e.g., Organization or User that owns the Razorpay account
upi: "Organization" # Typically Organization that owns the UPI VPA list
}providers_holdable_class: This hash maps each payment provider type supported by the engine to the class name (as a String) of the model in your host application that will "hold" or own the integration for that provider. The engine uses polymorphic associations (holdable_type,holdable_id) based on this setting to link payment provider accounts (likeStripe::Account,Integrations::Razorpay) to your application's models. Failing to configure this correctly will result in errors when the engine attempts to find or create payment provider integrations. Example: In NeetoCal eachUsercan connect their ownstripe_standardaccount, but in NeetoPay, only onestripe_standardaccount can exist in anOrganization.
2. Model Associations
Ensure the models specified in providers_holdable_class have the correct has_one or has_many associations defined to link to the engine's integration models. Adapt the following examples based on your configuration:
# app/models/organization.rb (Example if Organization is a holdable)
class Organization < ApplicationRecord
# If Organization holds Stripe Standard Connect account(s)
has_one :stripe_connected_account, -> { where(payment_provider: :stripe_standard) }, class_name: "::NeetoPaymentsEngine::Stripe::Account", as: :holdable, dependent: :destroy
# OR if one Org can hold many standard accounts (less common)
# has_many :stripe_connected_accounts, -> { where(payment_provider: :stripe_standard) }, class_name: "::NeetoPaymentsEngine::Stripe::Account", as: :holdable, dependent: :destroy
# We have to add this association no matter what because
# it's internally required by the engine. Change the parent association
# to Organization/User/Whatever depending on your host app.
has_one :stripe_platform_account, class_name: "::NeetoPaymentsEngine::Stripe::PlatformAccount", inverse_of: :organization, dependent: :destroy
# If Organization holds the Razorpay account
has_one :razorpay_integration, -> { where(payment_provider: :razorpay) }, class_name: "::NeetoPaymentsEngine::Integration", as: :holdable, dependent: :destroy
# If Organization holds the UPI VPAs list
has_one :upi_integration, -> { where(payment_provider: :eupi) }, class_name: "::NeetoPaymentsEngine::Integration", as: :holdable, dependent: :destroy
# General association (optional, can be useful)
has_many :payment_integrations, class_name: "::NeetoPaymentsEngine::Integration", as: :holdable, dependent: :destroy
# If Organization itself can have fees associated
has_one :fee, class_name: "::NeetoPaymentsEngine::Fee", as: :feeable, dependent: :destroy
has_one :split, class_name: "::NeetoPaymentsEngine::Split", dependent: :destroy # If Org defines platform split %
end
# app/models/user.rb (Example if User is a holdable for Stripe Standard)
class User < ApplicationRecord
has_one :stripe_connected_account, -> { where(payment_provider: :stripe_standard) }, class_name: "::NeetoPaymentsEngine::Stripe::Account", as: :holdable, dependent: :destroy
# Add other associations if User can hold other provider accounts
endAdd associations to your Payable models (e.g., Invoice, Booking, Meeting - the item being paid for) - you don't need to add this until you create your Payable models in your host app:
# app/models/invoice.rb (Example Payable Model)
class Invoice < ApplicationRecord
# Association to the payment record for this invoice
has_one :payment, class_name: "::NeetoPaymentsEngine::Payment", as: :payable, dependent: :destroy
# Optional: If fees are directly configured on the payable item itself
has_one :fee, class_name: "::NeetoPaymentsEngine::Fee", as: :feeable, dependent: :destroy
end3. Secrets and Credentials
Configure API keys and secrets securely using environment variables and secrets file.
Essential ENV variables for neeto-payments-engine:
.env.development(Commit this file): Contains non-sensitive defaults, placeholders, or development-specific configurations.# .env.development # Base URL of your host application (adjust port if needed) APP_URL='http://app.lvh.me:<APP_PORT>/' # --- Stripe --- # Base URL for OAuth callback (using tunnelto 'connect' subdomain for dev) STRIPE_CALLBACK_BASE_URL="https://connect.tunnelto.dev" # Or your tunnel URL # --- Razorpay (if used) --- RAZORPAY_CLIENT_SECRET="rzp_test_..." # Test Key ID can often be committed RAZORPAY_CLIENT_ID="..." # OAuth Client ID is usually safe RAZORPAY_WEBHOOK_SECRET=".." # webhook secret RAZORPAY_CALLBACK_BASE_URL="https://connect.tunnelto.dev" # Or your tunnel URL.env.local(Add this file to.gitignore- DO NOT COMMIT): Contains sensitive API keys, secrets, and private keys. This file overrides values in.env.development.# .env.local (DO NOT COMMIT THIS FILE) # --- Stripe --- STRIPE_SECRET_KEY="sk_test_..." # Your TEST Stripe Secret Key STRIPE_WEBHOOK_SECRET="whsec_..." # Your TEST Stripe Webhook Signing Secret (from manual setup) # Can find this from Stripe dashboard STRIPE_PUBLISHABLE_KEY="pk_test_..." # First sign up for Stripe Connect, then go to OAuth section of Stripe # Connect to get the client id. You'd also have to register OAuth callback # URI in that page. STRIPE_CLIENT_ID="ca_..." # --- Razorpay (if used) --- RAZORPAY_KEY_SECRET="..." # Your TEST Razorpay Key Secret RAZORPAY_WEBHOOK_SECRET="..." # Your TEST Razorpay Webhook Secret (you set this) RAZORPAY_CLIENT_SECRET="..." # Your TEST Razorpay OAuth Client Secret # --- JWT Keys --- # Generate using: openssl genrsa -out private.pem 2048 && openssl rsa -in private.pem -pubout -out public.pem # Copy the *entire* content including -----BEGIN...----- and -----END...----- lines. CONNECT_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY----- ...your private key content... -----END RSA PRIVATE KEY-----" CONNECT_PUBLIC_KEY="-----BEGIN PUBLIC KEY----- ...your public key content... -----END PUBLIC KEY-----" # --- Optional --- # OPEN_EXCHANGE_RATE_API_KEY="your_actual_key" # Sensitive API keyLoading Secrets: Ensure these secrets are loaded into
Rails.application.secrets. An exampleconfig/secrets.ymlstructure:# config/secrets.yml default: &default host: <%= ENV['APP_URL'] %> stripe: publishable_key: <%= ENV['STRIPE_PUBLISHABLE_KEY'] %> secret_key: <%= ENV['STRIPE_SECRET_KEY'] %> client_id: <%= ENV['STRIPE_CLIENT_ID'] %> webhooks: secret: <%= ENV['STRIPE_WEBHOOK_SECRET'] %> razorpay: key_id: <%= ENV["RAZORPAY_KEY_ID"] %> key_secret: <%= ENV["RAZORPAY_KEY_SECRET"] %> client_id: <%= ENV["RAZORPAY_CLIENT_ID"] %> client_secret: <%= ENV["RAZORPAY_CLIENT_SECRET"] %> oauth_callback_base_url: <%= ENV["RAZORPAY_CALLBACK_BASE_URL"] %> webhook: secret: <%= ENV["RAZORPAY_WEBHOOK_SECRET"] %> jwt: connect_private_key: <%= ENV['CONNECT_PRIVATE_KEY'].inspect %> connect_public_key: <%= ENV['CONNECT_PUBLIC_KEY'].inspect %> # Optional: If using Open Exchange Rates for currency conversion open_exchange_rates: api_key: <%= ENV["OPEN_EXCHANGE_RATE_API_KEY"] %> # ... other secrets ... development: <<: *default # Development specific overrides production: <<: *default # Production specific overrides (use credentials!)Secrets Management:
- Development: Use
.env.local(which is typically gitignored) to store sensitive keys locally. - Staging/Production: Use environment variables managed by your deployment platform (e.g., NeetoDeploy, Heroku Config Vars) or Rails encrypted credentials. Do not commit secrets directly into your repository.
- Development: Use
4. Stripe Connect Signup
You must sign up for Stripe Connect via the Stripe dashboard, even if you only intend to use a Stripe Platform account. This registration enables the necessary APIs for account management and OAuth flows used by the engine.
5. OAuth Callback URI Registration
Register the following callback URIs in your respective payment provider dashboards:
- Stripe:
https://<your_connect_subdomain_or_app_domain>/payments/api/v1/public/stripe/oauth/callback<your_connect_subdomain_or_app_domain>: This should be the publicly accessible URL that routes to your Rails app. In development, this is typically yourtunneltoURL using theconnectsubdomain (e.g.,https://connect.tunnelto.dev). In production, it might be your main application domain or a dedicated subdomain.
- Razorpay:
https://<your_razorpay_oauth_base_url>/payments/api/v1/public/razorpay/oauth/callback<your_razorpay_oauth_base_url>: This must match the value you configured forRAZORPAY_OAUTH_CALLBACK_BASE_URL. Use theconnectsubdomain viatunneltoin development.
Frontend Integration
Integrate the React components provided by the @bigbinary/neeto-payments-frontend package.
1. Install Frontend Package
yarn add @bigbinary/neeto-payments-frontend2. Install Peer Dependencies
If the host app already includes all of the following peer deps, then you don't have to install anything explicitly. Use the latest version of each of these peer dependencies if you need to install:
# DO NOT INSTALL THE VERSIONS MENTIONED BELOW AS IT MIGHT BE OUTDATED.
# ALWAYS PREFER INSTALLING THE LATEST VERSIONS.
# If something isn't working, then refer to these versions and see
# what's causing the breakage.
yarn add @babel/runtime@7.26.10 @bigbinary/neeto-cist@1.0.15 @bigbinary/neeto-commons-frontend@4.13.28 @bigbinary/neeto-editor@1.45.23 @bigbinary/neeto-filters-frontend@4.3.15 @bigbinary/neeto-icons@1.20.31 @bigbinary/neeto-molecules@3.16.1 @bigbinary/neetoui@8.2.75 @honeybadger-io/js@6.10.1 @honeybadger-io/react@6.1.25 @tailwindcss/container-queries@^0.1.1 @tanstack/react-query@5.59.20 @tanstack/react-query-devtools@5.59.20 antd@5.22.0 axios@1.8.2 buffer@^6.0.3 classnames@2.5.1 crypto-browserify@3.12.1 dompurify@^3.2.4 formik@2.4.6 https-browserify@1.0.0 i18next@22.5.1 js-logger@1.6.1 mixpanel-browser@^2.45.0 os-browserify@0.3.0 path-browserify@^1.0.1 qs@^6.11.2 ramda@0.29.0 react@18.2.0 react-dom@18.2.0 react-helmet@^6.1.0 react-i18next@12.3.1 react-router-dom@5.3.3 react-toastify@8.0.2 source-map-loader@4.0.1 stream-browserify@^3.0.0 stream-http@3.2.0 tailwindcss@3.4.14 tty-browserify@0.0.1 url@^0.11.0 util@^0.12.5 vm-browserify@1.1.2 yup@0.32.11 zustand@4.3.2Note: Carefully manage potential version conflicts with your host application.
3. Components
Import components into your React application as needed.
TaxesDashboard(source code)Props
feeId: The unique identifier for the tax fee.breadcrumbs: Data for rendering the header breadcrumbs.paymentUrl(optional): The URL of the pricing page to redirect users if payments are not enabled.headerSize(optional): Specifies the size of the header. Default size issmallnoDataHelpText(optional): Help text displayed when there is no data available.onTaxesChange(optional): Callback function triggered after performing actions in the taxes dashboard.titleHelpPopoverProps(optional): Data for the header help popover.
Usage
import React from "react"; import { TaxesDashboard } from "@bigbinary/neeto-payments-frontend"; const App = () => { return ( <TaxesDashboard feeId={fee.id} breadcrumbs={[{text: "Settings" ,link: routes.admin.form.settings}]} /> ); };PaymentsDashboard(source code)Props
dashboardKind: Accepts eitherconnectedorplatform(default:platform). Useconnectedto display payments related to split transfers. Useplatformto display payments received from customers.payableEntityColumns: To specify the columns from the payable entity which need to be additionally displayed. It is an optional prop that defaults to[]if not specified.searchProps: Allows specifying additional columns to be included in the search functionality. By default, only theidentifiercolumn from the payments table is searchable.holdableIds: Provide the holdable IDs for each payment provider when the holdable entity is not aUserorOrganizationmodel.Usage
import React from "react"; import { PaymentsDashboard } from "@bigbinary/neeto-payments-frontend"; const App = () => { // neeto-filters-engine search prop syntax. const SEARCH_PROPS = { node: "bookings:payable.meeting.name", model: "Meeting", placeholder: "Search by identifier or meeting name", }; // Only required if the payment provider holdable modal is not a User or Organization const holdableIds = { stripeStandard: formId, razorpay: formId } const payableEntityColumns = { title: "Submission", dataIndex: ["payable", "id"], key: "submission", ellipsis: true, width: "300px",, }, return ( <PaymentsDashboard {...{ payableEntityColumns, holdableIds }} searchProps={SEARCH_PROPS} /> ); };
RefundsDashboard(source code)Props
payableEntityColumns: To specify the columns from the payable entity which need to be additionally displayed. It is an optional prop that defaults to[]if not specified.searchProps: Allows specifying additional columns to be included in the search functionality. By default, only theidentifiercolumn from the refunds table is searchable.
Usage
import React from "react"; import { SplitTransfersDashboard } from "@bigbinary/neeto-payments-frontend"; const App = () => { // neeto-filters-engine search prop syntax. const SEARCH_PROPS = { node: "bookings:payable.meeting.name", model: "Meeting", placeholder: "Search by identifier or meeting name", }; const payableEntityColumns = { title: "Submission", dataIndex: ["payable", "id"], key: "submission", ellipsis: true, width: "300px",, }, return ( <SplitTransfersDashboard {...{ payableEntityColumns }} searchProps={SEARCH_PROPS} /> ); };SplitTransfersDashboard(source code)Props
payableEntityColumns: To specify the columns from the payable entity which need to be additionally displayed. It is an optional prop that defaults to[]if not specified.searchProps: Allows specifying additional columns to be included in the search functionality. By default, only theidentifiercolumn from the payment splits table is searchable. Usageimport React from "react"; import { RefundsDashboard } from "@bigbinary/neeto-payments-frontend"; const App = () => { // neeto-filters-engine search prop syntax. const SEARCH_PROPS = { node: "bookings:payable.meeting.name", model: "Meeting", placeholder: "Search by identifier or meeting name", }; const payableEntityColumns = { title: "Submission", dataIndex: ["payable", "id"], key: "submission", ellipsis: true, width: "300px",, }, return ( <RefundsDashboard {...{ payableEntityColumns }} searchProps={SEARCH_PROPS} /> ); };
AccountsDashboard(source code)Usage
import React from "react"; import { AccountsDashboard } from "@bigbinary/neeto-payments-frontend"; const App = () => { return (<AccountsDashboard />); };PayoutsDashboard(source code)Props
isPlatformEnabled: Indicates whether platform integration is enabled.payoutsPageRoute: The route used to display detailed payout information.
Usage
```jsx
import React from "react";
import { PayoutsDashboard } from "@bigbinary/neeto-payments-frontend";
const App = () => {
return (
<PayoutsDashboard
isPlatformEnabled={true}
payoutsPageRoute={routes.admin.payments.payouts.show}
/>
);
};
```RazorpayPaymentButton(source code)Props
label: The button label. Defaults toPay.payableId: The ID of the payable entity.discountCode: The discount code to be applied, if any.email: The customer's email address.name: The customer's name.theme: The theme configuration object.payableType: The type of the payable entity.onBeforePayment: A callback function triggered before the payment is initiated.onSuccessfulPayment: A callback function triggered after a successful payment.onFailedPayment: A callback function triggered after a failed payment.
Usage
import React from "react"; import { RazorpayPaymentButton } from "@bigbinary/neeto-payments-frontend"; const App = () => { return ( <RazorpayPaymentButton email={customer.email} name={customer.name} discountCode={discountCode.code} payableId={booking.id} theme={meeting.theme} /> ); };ConfirmUpiPaymentButton(source code)Props
vpas: A list of UPI IDs presented for the user to select during confirmation.identifier: The unique identifier associated with the payment.paymentId: The ID of the payment.onSuccess: A callback function triggered upon successful confirmation.
Usage
import React from "react"; import { ConfirmUpiPaymentButton } from "@bigbinary/neeto-payments-frontend"; const App = () => { return ( <ConfirmUpiPaymentButton identifier={payment.identifier} payableId={payment.payableId} paymentId={payment.paymentId} vpas={meeting.fee.vpas} /> ); };
4. Hooks
useStripePromise(source code)This hook can used to provide the value for the
stripeprop of the StripeElementcomponent.Usage
import React from "react"; import { useStripePromise } from "@bigbinary/neeto-payments-frontend"; const App = () => { const stripePromise = useStripePromise({ stripePlatformAccount // The integration object for the Stripe platform account. No value is required if the platform account integration is not configured. stripeAccountIdentifier: paymentRecipient?.stripeConnectedAccount?.identifier, // Stripe Standard account integration identifier }); return ( <Elements stripe={stripePromise}> </Elements> ); };useRazorpayPayment(source code)This hook returns a function that can be used to initiate a Razorpay payment. Use it only if you want to trigger the payment flow through a custom button in the host application.
**Usage**
```jsx
import React from "react";
import { useRazorpayPayment } from "@bigbinary/neeto-payments-frontend";
const App = () => {
const { makePayment } = useRazorpayPayment({
payableId, // The ID of the payable entity.
onBeforePayment, // A callback function triggered before the payment is initiated.
onSuccessfulPayment, // A callback function triggered after a successful payment.
onFailedPayment, // A callback function triggered after a failed payment.
customAmount, // The custom amount value to be used when a fee of type `range` or `variable` is configured.
});
};
```5. constants
CURRENCY_OPTIONS
A list of supported currencies in
{ value, label }format. This can be used as options for the NeetoUISelectcomponent.
6. Others
// Example: Payment Settings Page
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import { PaymentsDashboard, StripeConnect } from '@bigbinary/neeto-payments-frontend';
import { useQueryParams } from "@bigbinary/neeto-commons-frontend"; // Assuming you use this hook
const PaymentSettingsPage = () => {
const [integrations, setIntegrations] = useState({});
const [isLoading, setIsLoading] = useState(true);
const [isStripeConnectOpen, setIsStripeConnectOpen] = useState(false);
const { app: returnedApp, error: oauthError } = useQueryParams(); // For handling OAuth callbacks
useEffect(() => {
const fetchIntegrations = async () => {
setIsLoading(true);
try {
// IMPORTANT: Specify ONLY the providers your application actually uses.
const usedProviders = ["stripe_standard"]; // Example: only using Stripe Standard
const response = await axios.get('/payments/api/v1/integrations', {
params: { providers: usedProviders }
});
setIntegrations(response.data);
} catch (err) {
console.error("Failed to fetch integrations:", err);
// Handle error appropriately (e.g., show toast notification)
} finally {
setIsLoading(false);
}
};
fetchIntegrations();
// Handle OAuth callback results
if (returnedApp === 'stripe' && oauthError) {
console.error("Stripe connection failed:", oauthError);
// Show error message to user
} else if (returnedApp === 'stripe') {
console.log("Stripe connection initiated successfully. Waiting for webhook verification.");
// Maybe show a success message, fetchIntegrations will update the state eventually
}
// Add similar checks for Razorpay if using OAuth
}, [returnedApp, oauthError]);
// Determine holdableId based on your app's logic (e.g., current organization)
// This might come from context, props, or another state hook
const currentHoldableId = integrations.stripe_standard_account?.holdable_id || 'your_org_or_user_id'; // Replace with actual logic
if (isLoading) {
return <div>Loading payment settings...</div>;
}
return (
<div>
<h1>Payment Settings</h1>
{/* Conditionally render based on integration status */}
{!integrations.stripe_standard_account?.is_connected && (
<button onClick={() => setIsStripeConnectOpen(true)}>
Connect Stripe Account
</button>
)}
{integrations.stripe_standard_account?.is_connected && (
<p>Stripe Account Connected: {integrations.stripe_standard_account.identifier}</p>
// Add disconnect button here
)}
{/* Stripe Connect Modal */}
<StripeConnect
isOpen={isStripeConnectOpen}
onClose={() => setIsStripeConnectOpen(false)}
holdableId={currentHoldableId} // Pass the ID of the Organization/User model instance
returnUrl={`${window.location.origin}/payment-settings`} // URL Stripe redirects back to (should match registered URI)
isPlatform={false} // Set true only if connecting a Stripe Platform account
/>
{/* Example Dashboard (only show if integrated) */}
{integrations.stripe_standard_account?.is_connected && (
<>
<h2>Payments Overview</h2>
<PaymentsDashboard
// Example: Use 'connected' kind if viewing standard account payments
dashboardKind="connected"
// Pass holdable ID for the specific account being viewed
holdableIds={{ stripe_standard: currentHoldableId }}
/>
</>
)}
</div>
);
};
export default PaymentSettingsPage;4. API Calls - Specify Providers
When calling the /api/v1/integrations endpoint (or other APIs that might implicitly check integrations), you must pass the providers parameter listing only the providers your application uses. Omitting this or including unused providers can lead to errors if the engine tries to load configurations or associations that don't exist for your setup.
// Correct: Fetching only Stripe Standard integration status
axios.get('/payments/api/v1/integrations', {
params: { providers: ["stripe_standard"] }
});
// Incorrect (if not using all providers): Might cause errors
// axios.get('/payments/api/v1/integrations');Core Concepts
- Polymorphic Associations:
payable(what's being paid for),accountable(the payment account, e.g.,Stripe::Account), andholdable(owner of the integration, e.g.,Organization) link the engine to host app models dynamically based on configuration. - Single Table Inheritance (STI): Used for provider-specific models like
Payments::Stripe,Payments::Razorpay,Integrations::Razorpay. - Service Objects: Encapsulate business logic (e.g.,
Stripe::Payments::CreateService). - Callbacks: Host applications implement methods in
NeetoPaymentsEngine::Callbacksto customize behavior. See the Callbacks section. - Webhooks: Asynchronous events from providers update payment statuses. See Webhook Handling.
- JWT Authentication: Secures OAuth callback flows using RSA keys.
Webhook Handling
- Webhooks are crucial for updating payment statuses asynchronously.
- Endpoints are available at
/payments/api/v1/public/<provider>/webhooks. - Signatures are verified using configured secrets.
Development Environment Setup
1. Tunneling
Use a tool like ngrok or tunnelto to expose your local development server to the internet. The connect subdomain is reserved within Neeto for receiving callbacks from third-party services. Refer to Using Tunnelto and Exposing Tunnelto Locally for more details.
# Example using tunnelto
tunnelto --subdomain connect --port <YOUR_RAILS_APP_PORT>
# This makes https://connect.tunnelto.dev forward to your local app2. Stripe Webhook Setup (Manual)
- Go to your Stripe Test Dashboard > Developers > Webhooks.
- Click "Add endpoint".
- Endpoint URL:
https://connect.tunnelto.dev/payments/api/v1/public/stripe/webhooks(replace with your actual tunnel URL). - Select events to listen to. Refer to
config/stripe/webhooks.ymlin the engine for the required list (e.g.,payment_intent.succeeded,charge.refunded,account.updated). - Click "Add endpoint".
- After creation, find the Signing secret (starts with
whsec_...). - Copy this secret and set it as the
STRIPE_WEBHOOK_SECRETin your local.env.localor development credentials. - Note: The
neeto_payments_engine:stripe:webhooks:subscriberake task is not recommended for development setup as it doesn't create the endpoint and might show outdated secrets. Manual setup provides better control and ensures you have the correct, current secret. Refer to the Official Stripe Webhook Documentation for more details.
3. Razorpay Webhook Setup (Manual)
- Go to your Razorpay Partners Dashboard.
- Go to Applications
- Create a new application or open the existing application
- Add test webhook
- Webhook URL:
https://connect.tunnelto.dev/payments/api/v1/public/razorpay/webhooks(replace with your tunnel URL). - Set a Secret (create a strong random string).
- Select Active Events (e.g.,
payment.authorized,payment.captured,payment.failed,refund.processed,refund.failed). - Save the webhook.
- Copy the Secret you set and configure it as
RAZORPAY_WEBHOOK_SECRETin your local development environment.
Authentication (JWT for OAuth)
- Secures the OAuth callback flow for connecting Stripe/Razorpay accounts.
- Uses RSA public/private keys (
CONNECT_PUBLIC_KEY,CONNECT_PRIVATE_KEY) configured in your secrets. - The
ConnectLinkServicecreates a short-lived JWT containing user context when initiating the OAuth flow. - The
JwtServiceverifies the token signature using the public key upon callback.
Callbacks
Host applications must implement specific methods within a NeetoPaymentsEngine::Callbacks module (e.g., in app/lib/neeto_payments_engine/callbacks.rb) to tailor the engine's behavior to their domain logic.
Mandatory Callbacks
# app/lib/neeto_payments_engine/callbacks.rb
module NeetoPaymentsEngine
class Callbacks
# --- Mandatory Callbacks ---
# Check user permissions for general payment management access.
# @param user [User] The user attempting the action.
# @return [Boolean] True if the user has permission.
def self.can_manage_payments?(user)
user.has_role?(:admin) # Replace with your authorization logic
end
# ... other callbacks ...
end
endOptional Callbacks (Implement as needed)
# app/lib/neeto_payments_engine/callbacks.rb
module NeetoPaymentsEngine
class Callbacks
# --- Optional Callbacks (Implement as needed) ---
# Return the fee object applicable to the payable item.
# @param payable [Object] The host application's payable model instance (e.g., Invoice, Booking).
# @return [NeetoPaymentsEngine::Fee, nil] The Fee object or nil if no fee applies.
def self.get_fee(payable)
# Example: Find fee based on payable's attributes or associated product/service
payable.applicable_fee # Replace with your actual logic
end
# Check if a payment can be processed for this payable item.
# @param payable [Object] The host application's payable model instance.
# @return [Boolean] True if payment is allowed, false otherwise.
def self.payment_processable?(payable)
# Example: Check if the payable is in a state allowing payment (e.g., 'pending', 'unpaid')
payable.can_be_paid? # Replace with your actual logic
end
# Return an ActiveRecord::Relation scope for payable objects.
# Used by the engine, e.g., when filtering payments by associated payables.
# @param organization [Organization] The organization context.
# @param payable_type [String, nil] The stringified class name of the payable type (e.g., "Invoice"), or nil.
# @return [ActiveRecord::Relation] A scope of payable objects.
def self.get_payables(organization, payable_type = nil)
# Example: Return all Invoices for the organization if type matches
if payable_type == "Invoice"
organization.invoices
elsif payable_type == "Booking"
organization.bookings
else
# Return a sensible default or handle all relevant types
organization.invoices # Defaulting to invoices here, adjust as needed
end
end
# Validate if a discount code is applicable to a specific payable item.
# @param discount_code [DiscountCode] Host application's discount code model instance.
# @param payable [Object] The host application's payable model instance.
# @return [Boolean] True if the discount code is valid for the payable, false otherwise.
def self.valid_discount_code?(discount_code, payable)
# Example: Check code validity, usage limits, applicability to the payable's item/service
discount_code.is_valid_for?(payable) # Replace with your actual logic
end
# Determine if the transaction cookie (used by Stripe flow) should be deleted.
# Default logic (return true) is usually sufficient. Delete after success/refund.
# @param payment [NeetoPaymentsEngine::Payment] The payment object.
# @return [Boolean] True to delete the cookie, false to keep it.
def self.delete_transaction_cookie?(payment)
true
end
# Return the Stripe connected account associated with the payable's context.
# Crucial for marketplace payments where funds go to a connected account.
# @param payable [Object] The host application's payable model instance.
# @return [NeetoPaymentsEngine::Stripe::Account, nil] The connected account or nil.
def self.get_connected_account(payable)
# Example: Find the seller/vendor associated with the payable item
payable.seller&.stripe_connected_account # Replace with your actual logic
end
# Return the Stripe connected account that should receive the split amount for a given payable.
# Often the same logic as get_connected_account.
# @param payable [Object] The host application's payable model instance.
# @return [NeetoPaymentsEngine::Stripe::Account, nil] The destination account for the split.
def self.get_split_account(payable)
# Example: Find the recipient/beneficiary for the split payment
payable.recipient_user&.stripe_connected_account # Replace with your actual logic
end
# Return a hash of details for a payable item, used in CSV exports.
# @param payable [Object] The host application's payable model instance.
# @return [Hash] Key-value pairs to include in the export.
def self.get_exportable_details(payable)
{
# Example: Extract relevant fields from your payable model
payable_name: payable.try(:name) || "N/A",
invoice_number: payable.try(:invoice_number),
product_sku: payable.try(:product_sku)
}
end
# Return the primary custom hostname for an organization.
# Used for registering Stripe Payment Method Domains.
# @param organization [Organization] The organization context.
# @return [String, nil] The custom hostname or nil.
def self.custom_domain_hostname(organization)
# Example: Fetch from a dedicated custom domain model or setting
organization.custom_domain_setting&.hostname
end
# Determine if a payment related to this payable can be split.
# @param payable [Object] The host application's payable model instance.
# @return [Boolean] True if splits are allowed for this payable type/context.
def self.splittable?(payable)
# Example: Enable splits only for specific product types or services
payable.allows_splits? # Replace with your actual logic
end
# Determine if a split transfer should be initiated immediately or held.
# @param payable [Object] The host application's payable model instance.
# @return [Boolean] True to transfer immediately, false to hold (e.g., for review).
def self.transferable?(payable)
# Example: Hold transfers for items pending review or fulfillment
!payable.needs_manual_review? # Replace with your actual logic
end
# Return metadata about the payable to include in Stripe transfers.
# @param payable [Object] The host application's payable model instance.
# @return [Hash] Metadata hash (keys/values must be strings, limited length).
def self.get_payable_details(payable)
{
payable_id: payable.id.to_s,
payable_type: payable.class.name,
# Add other relevant identifiers (e.g., order_id, booking_ref)
order_reference: payable.try(:order_reference).to_s
}
end
# Check permissions specifically for managing the Stripe Platform account (if applicable).
# @param user [User] The user attempting the action.
# @return [Boolean] True if the user has permission.
def self.can_manage_stripe_platform_account?(user)
user.has_role?(:super_admin) # Replace with your authorization logic
end
# Check permissions specifically for managing Razorpay integrations.
# @param user [User] The user attempting the action.
# @return [Boolean] True if the user has permission.
def self.can_manage_razorpay_integration?(user)
user.has_role?(:admin) # Replace with your authorization logic
end
end
endExposed Entities
The engine exposes various models, services, jobs, and tasks for integration and extension:
- Models:
Payment,Fee,Refund,Split,Payments::Split,Stripe::Account,Stripe::PlatformAccount,Integration,Customer,PaymentMethod,Payout,WebhookEvent, etc. Host apps primarily interact with these through ActiveRecord associations defined on their own models. - Services: Encapsulate core logic (e.g.,
Stripe::Payments::CreateService,Razorpay::Accounts::CreateService,SplitTransfersFilterService,ExportCsvService). While mostly used internally, filter and export services might be invoked directly or indirectly via controllers. - Jobs: Handle background tasks (
Stripe::WebhooksJob,StripePlatform::CreatePaymentSplitsJob,ExportCsvJob,CreatePaymentMethodDomainJob). These are queued and processed by Sidekiq. - Concerns: Reusable modules (
Amountable,PaymentProcessable,Taxable,Stripe::Accountable,Refundable). Mostly for internal engine use. - Rake Tasks:
neeto_payments_engine:stripe:account:integrate: Seeds a sample Stripe connected account. Development/Testing only.neeto_payments_engine:stripe:webhooks:subscribe: Do not rely on this for setup. See Webhook Handling for manual setup instructions.
API Endpoints
The engine exposes several API endpoints under the configured mount path (default /payments). Here are some key ones:
| Method | Path | Description | Authentication |
|---|---|---|---|
| GET | /api/v1/integrations | List integrated payment provider accounts for the current context. | Host App Auth |
| POST | /api/v1/exports | Initiate a CSV export for dashboards (Payments, Refunds, Splits). | Host App Auth |
| GET | /api/v1/payments | List payments with filtering and pagination. | Host App Auth |
| GET | /api/v1/refunds | List refunds with filtering and pagination. | Host App Auth |
| PUT | /api/v1/recurring_payments/:id | Update the status of an admin-managed recurring payment. | Host App Auth |
| GET | /api/v1/split_transfers | List split transfers (Stripe Platform) with filtering. | Host App Auth |
| GET | /api/v1/split_transfers/:id | Show details of a specific split transfer. | Host App Auth |
| POST | /api/v1/split_transfers/bulk_update | Cancel or resume multiple pending/cancelled split transfers. | Host App Auth |
| GET | /api/v1/fees/:fee_id/taxes | List taxes configured for a specific fee. | Host App Auth |
| POST | /api/v1/fees/:fee_id/taxes | Create a new tax for a fee. | Host App Auth |
| PUT | /api/v1/fees/:fee_id/taxes/:id | Update an existing tax for a fee. | Host App Auth |
| DELETE | /api/v1/fees/:fee_id/taxes/:id | Delete a tax from a fee. | Host App Auth |
| GET | /api/v1/fees/:fee_id/recurring_setting | Get recurring payment settings for a fee. | Host App Auth |
| PUT | /api/v1/fees/:fee_id/recurring_setting | Update recurring payment settings for a fee. | Host App Auth |
| GET | /api/v1/stripe/countries | List countries supported by Stripe for account creation. | Host App Auth |
| POST | /api/v1/stripe/accounts | Initiate Stripe connected account creation/onboarding. | Host App Auth |
| GET | /api/v1/stripe/accounts/:id/creation_status | Check the status of an async account creation job. | Host App Auth |
| DELETE | /api/v1/stripe/accounts/:id | Disconnect a Stripe connected account. | Host App Auth |
| GET | /api/v1/stripe_platform/account | Get details of the configured Stripe Platform account. | Host App Auth |
| POST | /api/v1/stripe_platform/account | Create/Connect the Stripe Platform account. | Host App Auth |
| PUT | /api/v1/stripe_platform/account | Update Stripe Platform account settings. | Host App Auth |
| DELETE | /api/v1/stripe_platform/account | Disconnect the Stripe Platform account. | Host App Auth |
| GET | /api/v1/stripe_platform/payouts | List payouts made from the Stripe Platform account. | Host App Auth |
| GET | /api/v1/razorpay/holdables/:holdable_id/account | Get Razorpay account details for a specific holdable (Deprecated style). | Host App Auth |
| GET | /api/v1/upi/vpas | List configured UPI VPAs for the organization. | Host App Auth |
| POST | /api/v1/upi/vpas | Add a new UPI VPA. | Host App Auth |
| DELETE | /api/v1/upi/vpas/:id | Delete a UPI VPA. | Host App Auth |
| PUT | /api/v1/upi/payments/:id | Confirm/update a manual UPI payment (Admin action). | Host App Auth |
| GET | /api/v1/integrations/connect/stripe | Start Stripe Connect OAuth flow. | Host App Auth |
| GET | /api/v1/integrations/connect/razorpay | Start Razorpay OAuth flow. | Host App Auth |
| POST | /api/v1/public/stripe/transactions | Create a Stripe Payment Intent (Public endpoint). | Open |
| POST | /api/v1/public/razorpay/payments | Create a Razorpay Order (Public endpoint). | Open |
| POST | /api/v1/public/upi/payments | Initiate a manual UPI payment (Public endpoint). | Open |
| GET | /api/v1/public/recurring_payments/:payable_id | Get status of a customer-managed recurring payment. | Open |
| PUT | /api/v1/public/recurring_payments/:payable_id | Allow customer to cancel their recurring payment. | Open |
| GET | /api/v1/public/stripe/oauth/authorize | Stripe OAuth authorization step (Redirect handled by engine). | JWT |
| GET | /api/v1/public/stripe/oauth/callback | Stripe OAuth callback processing endpoint. | JWT |
| GET | /api/v1/public/razorpay/oauth/authorize | Razorpay OAuth authorization step (Redirect handled by engine). | JWT |
| GET | /api/v1/public/razorpay/oauth/callback | Razorpay OAuth callback processing endpoint. | JWT |
| POST | /api/v1/public/stripe/webhooks | Stripe webhook receiver endpoint. | Stripe Sig. |
| POST | /api/v1/public/razorpay/webhooks | Razorpay webhook receiver endpoint. | Razorpay Sig. |
| POST | /api/v1/public/stripe_platform/webhooks | Stripe Platform webhook receiver endpoint. | Stripe Sig. |
Note: "Host App Auth" means the endpoint relies on the host application's authentication (e.g., authenticate_user!). "JWT" means authentication relies on the JWT generated during the OAuth flow. "Open" means no authentication. "Stripe Sig." / "Razorpay Sig." means verification is done via webhook signatures.
Incineration Concern
If your host application uses neeto-org-incineration-engine, you need to integrate NeetoPaymentsEngine models correctly. The NeetoPaymentsEngine::Fee model often requires special handling as it might be associated with host application models (feeable).
- Initial Setup: When first adding
neeto-payments-engine, addNeetoPaymentsEngine::Feeto theSKIPPED_MODELSlist in your host application'sIncinerableConcernimplementation (e.g., inapp/models/concerns/incinerable_concern.rb). This prevents incineration errors before associations are correctly set up especially while running the whole test suite in host app.# In your host app's IncinerableConcern # TODO: Incinerate Fee based on Entity later SKIPPED_MODELS = ["NeetoActivitiesEngine::Activity", "NeetoPaymentsEngine::Fee"] - Define Associations Later(optional): Add the
has_one :fee, as: :feeableassociation to your host application models that should have fees (e.g.,Product,ServicePlan). Update Incineration Logic(optional): Once associations are defined, remove
"NeetoPaymentsEngine::Fee"fromSKIPPED_MODELS. You must then update yourIncinerableConcern.associated_modelsmethod to include logic that correctly finds and targetsNeetoPaymentsEngine::Feerecords associated with the organization being incinerated, likely through thefeeableassociation.# In your host app's IncinerableConcern def self.associated_models(org_id) # Add logic for NeetoPaymentsEngine::Fee "NeetoPaymentsEngine::Fee": { # Example: Find fees associated with Products belonging to the org joins: "JOIN products ON products.id = neeto_payments_engine_fees.feeable_id AND neeto_payments_engine_fees.feeable_type = 'Product'", where: ["products.organization_id = ?", org_id] # Adjust join and where clause based on your actual feeable models }, # Add logic for other NeetoPaymentsEngine models if needed, # though many are handled via cascading deletes or direct Org association. # Refer to the engine's IncinerableConcern for models it handles itself. # "NeetoPaymentsEngine::Payment": { ... } # rest of the code endConsult the engine's own
NeetoPaymentsEngine::IncinerableConcern(app/models/concerns/neeto_payments_engine/incinerable_concern.rb) for the list of models it defines and how it targets them for deletion, to avoid duplication and ensure comprehensive cleanup.
Deprecated Patterns
Please be aware of the following deprecations and use the recommended alternatives:
Configuration
holdable_class:- Deprecated: The singular
NeetoPaymentsEngine.holdable_class = "ClassName"configuration. - Recommended: Use the hash-based
NeetoPaymentsEngine.providers_holdable_class = { provider: "ClassName", ... }to configure holdable models per provider. This provides more flexibility.
- Deprecated: The singular
Holdable-Scoped APIs:
- Deprecated: API endpoints previously located under
/api/v1/<provider>/holdables/:holdable_id/...are deprecated. - Recommended: Use the newer API structure:
- For general integration status:
/api/v1/integrations(passing theprovidersparam). - For provider-specific account management:
/api/v1/<provider>/accounts/.... - For provider-specific resources like payouts:
/api/v1/<provider>/payouts/....
- For general integration status:
- Deprecated: API endpoints previously located under
ChargeModel/Concept:- Deprecated: Older versions might have referred to payment processing entities as
Charge. - Recommended: The primary entity for payment processing is now
NeetoPaymentsEngine::Payment(and its STI subclasses likePayments::Stripe). UsePaymentin associations and logic. The concept ofFee(NeetoPaymentsEngine::Fee) represents the configuration of what to charge, whilePaymentrepresents the actual transaction.
- Deprecated: Older versions might have referred to payment processing entities as
Development Environment Setup
To run the engine locally integrated with a host application, ensure the following processes are running:
- Rails Server: Starts the main web application.
bundle exec rails s - Vite Dev Server: Compiles and serves frontend assets.
# If using Vite yarn dev - Sidekiq Worker: Processes background jobs (like webhook handling).
bundle exec sidekiq -e development -C config/sidekiq.yml - Tunneling Service (for Webhooks/OAuth): Exposes your local server to the internet. Use the
connectsubdomain for OAuth callbacks.
Note: The# Using tunnelto (replace <YOUR_APP_PORT> with your Rails server port, e.g., 3000) tunnelto --subdomain connect --port <YOUR_APP_PORT>connectsubdomain is conventionally used across Neeto applications for receiving callbacks from third-party services like Stripe/Razorpay OAuth.
The Procfile.dev.PAYMENTS might look like this:
web: bundle exec rails s
vite: yarn dev
worker: bundle exec sidekiq -e development -C config/sidekiq.yml
# Tunnel needs to be run in a separate terminalHelper methods
currency_format_with_symbolThis helper method converts a currency code (like "INR" or "USD") into its corresponding symbol and returns the formatted amount with the symbol. Example $10.00.Usage in host application:
In general modules (e.g., Services or View helpers)
module ApplicationHelper include ::NeetoPaymentsEngine::CurrencyFormatHelper def formatted_amount currency_format_with_symbol(payment&.amount, payment&.currency) end endIn mailers
Include the helper in your mailer:
class Packages::PurchaseConfirmedMailer < ApplicationMailer helper ::NeetoPaymentsEngine::CurrencyFormatHelper def customer_email end endUse the method in the mailer view:
<%= "#{currency_format_with_symbol(@purchase.payment&.amount,@purchase.payment&.currency)}" %>
Testing & Debugging
- Dummy App: Use the
test/dummyapp within the engine's repository for isolated testing. - Test Helpers: Utilize
NeetoPaymentsEngine::TestHelpers(includesHttpRequestHelpers) for stubbing API calls to Stripe/Razorpay (test/helpers/http_request_helpers/). - Stripe Test Cards:
- Valid Card:
4242 4242 4242 4242 - Declined Card:
4000 0000 0000 0002 - Expiry Date: Any future date (e.g.,
12/30) - CVC: Any 3 digits (e.g.,
123) - ZIP Code: Any valid ZIP (e.g.,
12345) - More Stripe Test Cards
- Valid Card:
- Razorpay Test Cards/UPI: Refer to Razorpay Testing Docs.
- Logging: Check Rails logs (
log/development.log) for detailed output from the engine'sLogActivityHelper. - Provider Dashboards: Use the Stripe and Razorpay Test Mode dashboards to view API logs, payment details, webhook attempts, and specific error messages.
- JWT Debugging: Use tools like jwt.io to decode JWTs generated during OAuth flows. Paste the token and the public key (
CONNECT_PUBLIC_KEY) to verify the signature and inspect the payload (checkexpclaim for expiry).
Gotchas & Tips
providers_holdable_classis Mandatory: Forgetting to configure this in the initializer will lead to errors when the engine tries to find associated accounts.- Specify
providersin API Calls: When calling/api/v1/integrations, always pass theprovidersparam listing only the providers you actually use in your host app (e.g.,params: { providers: ["stripe_standard"] }). Failing to do so might cause errors if the engine tries to load an unconfigured provider (like UPI). - Stripe Connect Signup: You must complete the Stripe Connect signup process in your Stripe account, even for platform-only usage.
- Webhook Secrets in Dev: Manually created webhook endpoint secrets from Stripe/Razorpay dashboards are the source of truth for development, not necessarily what rake tasks might print.
- JWT Key Security: Treat your JWT private key with the same security as your API secret keys.
- Migration Order: Always run
bundle exec rails neeto_payments_engine:install:migrationsbeforedb:migratewhen setting up or upgrading. We also need to run this rake task and migration after we run./bin/setupin the host app.
Publishing
For instructions on building and releasing the @bigbinary/neeto-payments-frontend NPM package and the neeto-payments-engine Ruby gem, please refer to the internal guide: Building and Releasing Packages.
9 months ago
9 months ago
9 months ago
9 months ago
10 months ago
10 months ago
11 months ago
10 months ago
10 months ago
10 months ago
11 months ago
11 months ago
11 months ago
6 months ago
8 months ago
8 months ago
8 months ago
8 months ago
8 months ago
8 months ago
8 months ago
9 months ago
9 months ago
9 months ago
7 months ago
6 months ago
6 months ago
6 months ago
6 months ago
6 months ago
8 months ago
8 months ago
7 months ago
7 months ago
11 months ago
1 year ago
10 months ago
10 months ago
9 months ago
9 months ago
10 months ago
10 months ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago