feat(stripe): view customer billing details in conversations (#15765)

Adds a read-only Stripe integration so support agents can view customer
details, subscriptions, and recent invoices directly in the conversation
sidebar without asking a teammate to look them up in Stripe.

Account administrators connect through OAuth and manage the connection
from Settings → Integrations. Super Admin controls the Stripe app
configuration. Sandbox and live connections are supported; Chatwoot's
own subscription billing is unchanged.

### Things to know

- This is an initial read-only integration. Refunds, payment actions,
and webhook-driven updates are not included.
- Customer lookup uses the conversation contact's email. When multiple
Stripe customers match, the agent selects one. Billing data is scoped to
conversations the agent can access.
- Customer matching uses Stripe Search with an exact case-insensitive
email check. Newly created or edited customers may not appear
immediately because of indexing delays. Stripe Search is unavailable to
businesses in India; this integration currently shares that limitation.
See [Stripe Search
limitations](https://docs.stripe.com/search#limitations).
- Connections reuse integration hooks, with encrypted OAuth credentials
and token refresh. No new database table is required. Active Record
encryption must be configured.
- Configure the matching OAuth installation link and developer secret in
Super Admin → Settings → Stripe. The Stripe app must allow the
installation's `/stripe/callback` URL. Each installation uses one
credential pair; changing environments requires reconnecting accounts.
- Disconnecting removes the local credentials. Uninstall the app in
Stripe to revoke provider-side access.
- The sandbox flow has been exercised locally. Live-mode behavior has
automated coverage, but live credentials, an internal live pilot,
deployment, and Marketplace publishing are deferred.

### How to test

1. Configure a sandbox OAuth link and matching developer key in Super
Admin → Settings → Stripe, with Active Record encryption enabled.
2. As an account administrator, open Settings → Integrations → Stripe
and connect a sandbox. Confirm the connected account, environment, date,
and dashboard link.
3. Create a Stripe customer whose email matches a Chatwoot contact, with
a subscription and invoices. Open that contact's conversation and expand
Stripe to see the customer, subscription status, and recent invoice
amounts, dates, and statuses.
4. Check a contact with no email, no matching customer, and multiple
matching customers. The sidebar should show the appropriate empty state
or customer selector.
Use a mixed-case email in Stripe to verify case-insensitive matching,
and edit the contact email with the sidebar open to verify it reloads
and clears the old customer selection. Allow time for Stripe's search
index to update.
5. Confirm agents cannot manage the connection or retrieve Stripe data
through an inaccessible conversation.
6. Disconnect and confirm the sidebar is no longer available.
Live-account verification is a separate rollout step and has not been
performed.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
This commit is contained in:
Muhsin Keloth
2026-10-06 18:10:20 +04:00
committed by GitHub
parent af1e939467
commit f1c9e4831b
42 changed files with 2289 additions and 14 deletions

View File

@@ -10,14 +10,17 @@ class Api::V1::Accounts::Integrations::AppsController < Api::V1::Accounts::BaseC
private
def fetch_apps
@apps = Integrations::App.all.select { |app| app.active?(Current.account) }
@apps = Integrations::App.all.select do |app|
app.active?(Current.account) ||
(app.id == 'stripe' && Current.account_user&.administrator? && Current.account.hooks.exists?(app_id: 'stripe'))
end
end
def fetch_app
@app = Integrations::App.find(id: params[:id])
return unless @app&.id == 'shopify'
return unless %w[shopify stripe].include?(@app&.id)
# Keep direct Shopify lookups subject to the same availability checks as the integrations list.
# Keep direct lookups subject to the same availability checks as the integrations list.
raise ActiveRecord::RecordNotFound unless @app.active?(Current.account)
end
end

View File

@@ -2,6 +2,7 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Inte
before_action :fetch_hook, except: [:create]
before_action :check_authorization
before_action :ensure_shopify_enabled, if: :shopify_hook?
before_action :reject_stripe_hook
def create
@hook = Current.account.hooks.create!(permitted_params)
@@ -32,6 +33,14 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Inte
private
def reject_stripe_hook
app_id = action_name == 'create' ? permitted_params[:app_id] : @hook.app_id
return unless app_id == 'stripe'
# Stripe credentials and connection changes are managed by its OAuth and disconnect endpoints.
head :not_found
end
def fetch_hook
@hook = Current.account.hooks.find(params[:id])
end

View File

@@ -0,0 +1,52 @@
class Api::V1::Accounts::Integrations::StripeController < Api::V1::Accounts::Integrations::BaseController
before_action :ensure_configured, only: [:auth, :customer]
before_action :check_admin_authorization?, only: [:show, :auth, :destroy]
def show
hook = Current.account.hooks.find_by!(app_id: 'stripe')
mode = Integrations::Stripe::Connection.new(hook).livemode? ? 'live' : 'sandbox'
render json: { account_id: hook.reference_id, connected_at: hook.settings.fetch('connected_at', hook.created_at), mode: mode,
reauthorization_required: hook.reauthorization_required? }
end
def auth
url = Integrations::Stripe::Oauth.authorize_url(account: Current.account, user: Current.user)
state = URI.decode_www_form(URI(url).query).to_h.fetch('state')
cookies.signed[:stripe_oauth_state] = { value: state, httponly: true, secure: request.ssl?, same_site: :lax,
expires: Integrations::Stripe::Oauth::STATE_TTL.from_now }
render json: { url: url }
end
def destroy
Current.account.hooks.find_by!(app_id: 'stripe').destroy!
head :no_content
end
def customer
conversation_id = params.require(:conversation_id)
unless conversation_id.is_a?(String) && conversation_id.match?(/\A[1-9]\d*\z/)
return render_could_not_create_error('conversation_id must be a positive integer')
end
conversation = Current.account.conversations.find_by!(display_id: conversation_id)
authorize conversation, :show?
hook = Current.account.hooks.find_by!(app_id: 'stripe', status: :enabled)
connection = Integrations::Stripe::Connection.new(hook)
render json: Integrations::Stripe::CustomerSummary.new(connection: connection,
contact: conversation.contact).perform(customer_id: params[:customer_id])
rescue Integrations::Stripe::Connection::ReauthorizationRequired, ::Stripe::AuthenticationError
hook.prompt_reauthorization!
render json: { error: 'stripe_reauthorization_required' }, status: :unprocessable_entity
rescue OAuth2::Error => e
hook.prompt_reauthorization! if e.code == 'invalid_grant'
render json: { error: 'stripe_unavailable' }, status: :unprocessable_entity
rescue ::Stripe::StripeError, Faraday::TimeoutError, Faraday::ConnectionFailed
render json: { error: 'stripe_unavailable' }, status: :unprocessable_entity
end
private
def ensure_configured
head :not_found unless Integrations::App.find(id: 'stripe').active?(Current.account)
end
end

View File

@@ -0,0 +1,71 @@
class Stripe::CallbacksController < ApplicationController
OAUTH_ERROR_CODES = %w[invalid_request invalid_client invalid_grant unauthorized_client unsupported_grant_type
invalid_scope access_denied server_error temporarily_unavailable].freeze
before_action :verify_browser_state
before_action :load_installation
rescue_from Faraday::TimeoutError, Faraday::ConnectionFailed, with: -> { authorization_failed('token_exchange_unavailable') }
def show
return authorization_failed('provider_error') if params[:error].present?
return authorization_failed('missing_code') if params[:code].blank?
token = Integrations::Stripe::Oauth.client.auth_code.get_token(params[:code], redirect_uri: Integrations::Stripe::Oauth.callback_url)
return authorization_failed('token_mode_mismatch') unless token.params.fetch('livemode') == @livemode
save_connection(token)
redirect_to destination
rescue OAuth2::Error => e
authorization_failed('token_exchange_failed', oauth_error: safe_oauth_error_code(e.code), http_status: e.response.status)
end
private
def safe_oauth_error_code(code)
# Provider messages and response bodies can contain credentials. Only log known error codes.
OAUTH_ERROR_CODES.include?(code) ? code : 'unknown'
end
def authorization_failed(reason, **details)
Rails.logger.warn({ event: 'stripe_oauth_authorization_failed', reason: reason, account_id: @account.id,
request_id: request.request_id, expected_livemode: @livemode }.merge(details).to_json)
redirect_to "#{destination}?error=authorization_failed"
end
def save_connection(token)
@account.with_lock do
hook = @account.hooks.find_or_initialize_by(app_id: 'stripe')
hook.reference_id = token.params.fetch('stripe_user_id')
hook.status = :enabled
hook.settings = hook.settings.merge('livemode' => @livemode, 'connected_at' => Time.current.iso8601)
Integrations::Stripe::Connection.new(hook).store_token!(token)
hook.reauthorized!
end
end
def verify_browser_state
browser_state = cookies.signed[:stripe_oauth_state]
return head :bad_request unless browser_state.present? && browser_state == params[:state]
cookies.delete(:stripe_oauth_state)
end
def load_installation
return head :not_found unless Integrations::Stripe::Oauth.configured?
state = Integrations::Stripe::Oauth.consume_state(params[:state])
return head :bad_request unless state
@account = Account.find(state.fetch('account_id'))
return head :not_found unless @account.feature_enabled?('stripe_integration')
@livemode = state.fetch('livemode')
return head :bad_request unless @livemode == Integrations::Stripe::Oauth.livemode?
head :forbidden unless @account.account_users.exists?(user_id: state.fetch('user_id'), role: :administrator)
end
def destination
"#{ENV.fetch('FRONTEND_URL')}/app/accounts/#{@account.id}/settings/integrations/stripe"
end
end

View File

@@ -29,7 +29,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
end
def create
errors = shopify_partner_config_errors
errors = shopify_partner_config_errors + stripe_config_errors
params['app_config'].each do |key, value|
break if errors.any?
next unless @allowed_configs.include?(key)
@@ -48,6 +48,14 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
private
def stripe_config_errors
return [] unless @config == 'stripe'
values = InstallationConfig.where(name: @allowed_configs).to_h { |config| [config.name, config.value] }
values.merge!(params.fetch('app_config', {}).permit(*@allowed_configs).to_h)
Integrations::Stripe::Oauth.configuration_errors(url: values['STRIPE_APP_AUTHORIZE_URL'], key: values['STRIPE_APP_SECRET_KEY'])
end
def set_config
@config = params[:config] || 'general'
end
@@ -61,6 +69,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
'microsoft' => %w[AZURE_APP_ID AZURE_APP_SECRET],
'email' => %w[MAILER_INBOUND_EMAIL_DOMAIN ACCOUNT_EMAILS_LIMIT ACCOUNT_EMAILS_PLAN_LIMITS],
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
'stripe' => %w[STRIPE_APP_AUTHORIZE_URL STRIPE_APP_SECRET_KEY],
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET SLACK_SIGNING_SECRET],
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
'tiktok' => %w[TIKTOK_APP_ID TIKTOK_APP_SECRET TIKTOK_API_VERSION],

View File

@@ -120,6 +120,12 @@ microsoft:
config_key: 'microsoft'
# ------- Third-party Integrations ------- #
stripe:
name: 'Stripe'
description: 'Configure the Stripe customer billing integration'
enabled: true
icon: 'icon-settings-2-line'
config_key: 'stripe'
linear:
name: 'Linear'
description: 'Configuration for setting up Linear Integration'

View File

@@ -0,0 +1,25 @@
/* global axios */
import ApiClient from '../ApiClient';
class StripeAPI extends ApiClient {
constructor() {
super('integrations/stripe', { accountScoped: true });
}
connect() {
return axios.post(`${this.url}/auth`);
}
disconnect() {
return axios.delete(this.url);
}
customer(conversationId, customerId, signal) {
return axios.get(`${this.url}/customer`, {
params: { conversation_id: conversationId, customer_id: customerId },
signal,
});
}
}
export default new StripeAPI();

View File

@@ -0,0 +1,170 @@
<script setup>
import { ref, watch } from 'vue';
import { dateFormat } from 'shared/helpers/timeHelper';
import { useAbortableRequest } from 'dashboard/composables/useAbortableRequest';
import {
formatStripeAmount,
STRIPE_STATUS_COLORS,
} from 'dashboard/helper/stripeHelper';
import StripeAPI from 'dashboard/api/integrations/stripe';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Label from 'dashboard/components-next/label/Label.vue';
import StripeSubscription from './StripeSubscription.vue';
const props = defineProps({
conversationId: { type: [Number, String], required: true },
contactEmail: { type: String, default: '' },
});
const { run, isPending } = useAbortableRequest();
const summary = ref(null);
const customerId = ref('');
const error = ref(false);
const load = async () => {
error.value = false;
summary.value = null;
try {
const response = await run(signal =>
StripeAPI.customer(
props.conversationId,
customerId.value || undefined,
signal
)
);
if (response) summary.value = response.data;
} catch {
error.value = true;
}
};
watch(
[() => props.conversationId, () => props.contactEmail],
() => {
customerId.value = '';
load();
},
{ immediate: true }
);
</script>
<template>
<div class="flex flex-col gap-4 px-4 py-3 text-sm text-n-slate-12">
<Spinner v-if="isPending" class="mx-auto" />
<p v-else-if="error" role="alert" class="m-0 text-n-ruby-11">
{{ $t('STRIPE_INTEGRATION.ERROR') }}
</p>
<template v-else-if="summary">
<p v-if="summary.missing_email" class="m-0 text-n-slate-11">
{{ $t('STRIPE_INTEGRATION.NO_EMAIL') }}
</p>
<p v-else-if="!summary.customers.length" class="m-0 text-n-slate-11">
{{ $t('STRIPE_INTEGRATION.EMPTY') }}
</p>
<template v-else>
<label v-if="summary.customers.length > 1" class="flex flex-col gap-2">
{{ $t('STRIPE_INTEGRATION.SELECT') }}
<select v-model="customerId" class="!mb-0" @change="load">
<option value="" disabled>
{{ $t('STRIPE_INTEGRATION.SELECT') }}
</option>
<option
v-for="customer in summary.customers"
:key="customer.id"
:value="customer.id"
>
{{
$t('STRIPE_INTEGRATION.CUSTOMER_OPTION', {
name: customer.name || customer.email,
id: customer.id,
})
}}
</option>
</select>
</label>
<p
v-if="summary.has_more_customers"
class="m-0 text-xs text-n-slate-11"
>
{{ $t('STRIPE_INTEGRATION.MORE') }}
</p>
<template v-if="summary.customer">
<div class="flex items-center gap-3 min-w-0">
<span
class="flex items-center justify-center size-9 shrink-0 rounded-lg bg-n-alpha-2 text-n-slate-11"
>
<span class="i-lucide-user-round size-4" aria-hidden="true" />
</span>
<div
class="flex flex-col gap-0.5 min-w-0 text-xs text-n-slate-11"
:title="summary.customer.id"
>
<span class="text-sm font-medium truncate text-n-slate-12">
{{ summary.customer.name }}
</span>
<span class="truncate">{{ summary.customer.email }}</span>
<span v-if="summary.customer.phone">
{{ summary.customer.phone }}
</span>
</div>
</div>
<section class="flex flex-col gap-2 pt-3 border-t border-n-weak">
<h4 class="m-0 text-xs font-medium text-n-slate-11">
{{ $t('STRIPE_INTEGRATION.SUBSCRIPTIONS') }}
</h4>
<p
v-if="!summary.subscriptions.length"
class="m-0 text-xs text-n-slate-11"
>
{{ $t('STRIPE_INTEGRATION.NO_SUBSCRIPTIONS') }}
</p>
<StripeSubscription
v-for="subscription in summary.subscriptions"
:key="subscription.id"
:subscription="subscription"
/>
</section>
<section class="flex flex-col pt-3 border-t border-n-weak">
<h4 class="m-0 text-xs font-medium text-n-slate-11">
{{ $t('STRIPE_INTEGRATION.INVOICES') }}
</h4>
<p
v-if="!summary.invoices.length"
class="mt-2 mb-0 text-xs text-n-slate-11"
>
{{ $t('STRIPE_INTEGRATION.NO_INVOICES') }}
</p>
<div
v-for="invoice in summary.invoices"
:key="invoice.id"
class="flex items-center justify-between gap-3 py-2.5 border-b border-n-weak last:border-b-0 last:pb-0"
>
<div class="flex flex-col gap-1 min-w-0">
<span
class="text-sm font-medium truncate"
:title="invoice.number || invoice.id"
>
{{ invoice.number || invoice.id }}
</span>
<span class="text-xs text-n-slate-11">
{{ dateFormat(invoice.created) }}
</span>
</div>
<div class="flex flex-col items-end gap-1 shrink-0">
<span class="text-sm font-medium tabular-nums">
{{ formatStripeAmount(invoice.total, invoice.currency) }}
</span>
<Label
:label="invoice.status"
:color="STRIPE_STATUS_COLORS[invoice.status] || 'slate'"
compact
class="capitalize"
/>
</div>
</div>
</section>
</template>
</template>
</template>
</div>
</template>

View File

@@ -0,0 +1,217 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useClipboard } from '@vueuse/core';
import { dateFormat } from 'shared/helpers/timeHelper';
import {
formatStripeAmount,
STRIPE_STATUS_COLORS,
} from 'dashboard/helper/stripeHelper';
import SidePanel from 'dashboard/components-next/side-panel/SidePanel.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Label from 'dashboard/components-next/label/Label.vue';
const props = defineProps({ subscription: { type: Object, required: true } });
const { t } = useI18n();
const { copy, copied } = useClipboard();
const panel = ref(null);
const title = computed(
() =>
props.subscription.items
?.map(item => item.name)
.filter(Boolean)
.join(', ') || t('STRIPE_INTEGRATION.SUBSCRIPTION')
);
const statusLabel = computed(() =>
props.subscription.status.replaceAll('_', ' ')
);
const statusColor = computed(
() => STRIPE_STATUS_COLORS[props.subscription.status] || 'slate'
);
const isTrialing = computed(() => props.subscription.status === 'trialing');
const isRenewing = computed(
() =>
['active', 'trialing'].includes(props.subscription.status) &&
!props.subscription.cancel_at_period_end &&
!props.subscription.cancel_at
);
const trialMessage = computed(() =>
isTrialing.value && props.subscription.trial_end
? t('STRIPE_INTEGRATION.TRIAL_ENDS', {
date: dateFormat(props.subscription.trial_end),
})
: ''
);
const cancellationMessage = computed(() => {
if (props.subscription.status === 'canceled') return '';
if (props.subscription.cancel_at)
return t('STRIPE_INTEGRATION.CANCELS', {
date: dateFormat(props.subscription.cancel_at),
});
return props.subscription.cancel_at_period_end
? t('STRIPE_INTEGRATION.CANCELS_AT_PERIOD_END')
: '';
});
const periodEndLabel = timestamp =>
isRenewing.value
? t('STRIPE_INTEGRATION.RENEWS', { date: dateFormat(timestamp) })
: t('STRIPE_INTEGRATION.PERIOD_END', { date: dateFormat(timestamp) });
// The card only has room for one period, so it is shown for single item subscriptions
const cardPeriodEnd = computed(() => {
const [item, ...otherItems] = props.subscription.items || [];
if (isTrialing.value || otherItems.length || !item?.current_period_end)
return '';
return periodEndLabel(item.current_period_end);
});
const priceLabel = item => {
if (
item.unit_amount == null ||
item.billing_scheme === 'tiered' ||
item.recurring?.usage_type === 'metered'
)
return t('STRIPE_INTEGRATION.VARIABLE_PRICE');
const amount = formatStripeAmount(item.unit_amount, item.currency, {
preservePrecision: true,
});
if (!item.recurring) return amount;
const count = item.recurring.interval_count;
return t('STRIPE_INTEGRATION.PRICE_INTERVAL', {
amount,
interval: {
day: t('STRIPE_INTEGRATION.INTERVAL.day', count),
week: t('STRIPE_INTEGRATION.INTERVAL.week', count),
month: t('STRIPE_INTEGRATION.INTERVAL.month', count),
year: t('STRIPE_INTEGRATION.INTERVAL.year', count),
}[item.recurring.interval],
});
};
</script>
<template>
<button
type="button"
class="flex items-center gap-2 w-full rounded-lg bg-n-alpha-1 p-3 text-start hover:bg-n-alpha-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-n-brand"
:aria-label="$t('STRIPE_INTEGRATION.VIEW_SUBSCRIPTION', { name: title })"
@click="panel.open()"
>
<span class="flex flex-col flex-1 gap-1 min-w-0 text-xs text-n-slate-11">
<span class="flex items-start justify-between gap-2">
<span class="text-sm font-medium break-words text-n-slate-12">
{{ title }}
</span>
<Label
:label="statusLabel"
:color="statusColor"
compact
class="capitalize"
/>
</span>
<span v-for="item in subscription.items" :key="item.id">
{{ priceLabel(item) }}
<span
v-if="item.quantity != null"
class="ms-2 ps-2 border-s border-n-strong"
>
{{ $t('STRIPE_INTEGRATION.QUANTITY', { count: item.quantity }) }}
</span>
</span>
<span v-if="trialMessage">{{ trialMessage }}</span>
<span v-if="cancellationMessage" class="text-n-amber-11">
{{ cancellationMessage }}
</span>
<span v-else-if="cardPeriodEnd">{{ cardPeriodEnd }}</span>
</span>
<span
class="i-lucide-chevron-right size-4 shrink-0 text-n-slate-10"
aria-hidden="true"
/>
</button>
<SidePanel ref="panel" :title="title" width="md">
<div class="flex flex-col gap-5 text-sm text-n-slate-12">
<div class="flex items-center gap-2">
<Label
:label="statusLabel"
:color="statusColor"
compact
class="capitalize"
/>
<span class="flex-1 min-w-0 font-mono text-xs truncate text-n-slate-11">
{{ subscription.id }}
</span>
<Button
:label="
copied
? $t('STRIPE_INTEGRATION.COPIED')
: $t('STRIPE_INTEGRATION.COPY_ID')
"
:icon="copied ? 'i-lucide-check' : 'i-lucide-copy'"
xs
ghost
slate
@click="copy(subscription.id)"
/>
</div>
<p v-if="trialMessage" class="m-0">{{ trialMessage }}</p>
<p
v-if="cancellationMessage"
class="flex items-center gap-2 px-3 py-2 m-0 rounded-lg bg-n-amber-2 text-n-amber-11"
>
<span class="i-lucide-calendar-x size-4 shrink-0" aria-hidden="true" />
{{ cancellationMessage }}
</p>
<p
v-if="subscription.status === 'canceled' && subscription.canceled_at"
class="m-0"
>
{{
$t('STRIPE_INTEGRATION.CANCELED_ON', {
date: dateFormat(subscription.canceled_at),
})
}}
</p>
<section
v-for="item in subscription.items"
:key="item.id"
class="flex flex-col gap-1.5 pt-4 border-t border-n-weak text-n-slate-11"
>
<h4
v-if="subscription.items.length > 1"
class="m-0 text-sm font-medium text-n-slate-12"
>
{{ item.name || $t('STRIPE_INTEGRATION.SUBSCRIPTION') }}
</h4>
<p class="m-0 text-n-slate-12">{{ priceLabel(item) }}</p>
<p v-if="item.quantity != null" class="m-0">
{{ $t('STRIPE_INTEGRATION.QUANTITY', { count: item.quantity }) }}
</p>
<p v-if="item.current_period_start" class="m-0">
{{
$t('STRIPE_INTEGRATION.PERIOD_START', {
date: dateFormat(item.current_period_start),
})
}}
</p>
<p v-if="item.current_period_end" class="m-0">
{{ periodEndLabel(item.current_period_end) }}
</p>
</section>
<p v-if="subscription.has_more_items" class="m-0 text-n-slate-11">
{{ $t('STRIPE_INTEGRATION.MORE_ITEMS') }}
</p>
<p class="pt-4 m-0 text-xs border-t border-n-weak text-n-slate-11">
{{ $t('STRIPE_INTEGRATION.PRICE_NOTE') }}
</p>
</div>
</SidePanel>
</template>

View File

@@ -0,0 +1,250 @@
import { mount, flushPromises } from '@vue/test-utils';
import StripeCustomer from '../StripeCustomer.vue';
import StripeAPI from 'dashboard/api/integrations/stripe';
vi.mock('dashboard/components-next/TeleportWithDirection.vue', () => ({
default: {
props: ['to'],
template: '<Teleport :to="to"><slot /></Teleport>',
},
}));
vi.mock('dashboard/api/integrations/stripe', () => ({
default: { customer: vi.fn() },
}));
describe('StripeCustomer', () => {
const customer = {
id: 'cus_alfred',
name: 'Alfred',
email: 'alfred@example.com',
};
const data = {
customers: [customer],
customer,
subscriptions: [
{
id: 'sub_test',
status: 'active',
items: [
{
id: 'si_test',
name: 'Support Pro',
quantity: 3,
unit_amount: 4900,
currency: 'usd',
recurring: { interval: 'month', interval_count: 1 },
current_period_start: 1788998400,
current_period_end: 1791590400,
},
],
},
],
invoices: [
{
id: 'in_test',
number: 'INV-001',
total: 1000,
currency: 'usd',
status: 'paid',
created: 1788998400,
},
],
};
let wrapper;
it.each(['active', 'canceled', 'trialing'])(
'shows trial-end copy only for trialing subscriptions (%s)',
async status => {
StripeAPI.customer.mockResolvedValue({
data: {
...data,
subscriptions: [
{ ...data.subscriptions[0], status, trial_end: 1788998400 },
],
},
});
wrapper = mount(StripeCustomer, { props: { conversationId: 1 } });
await flushPromises();
await wrapper.get('button').trigger('click');
await flushPromises();
expect(
document.body.textContent.includes('STRIPE_INTEGRATION.TRIAL_ENDS')
).toBe(status === 'trialing');
}
);
afterEach(() => wrapper?.unmount());
it('renders customer billing details without a refresh button', async () => {
StripeAPI.customer.mockResolvedValue({ data });
wrapper = mount(StripeCustomer, { props: { conversationId: 1 } });
await flushPromises();
expect(wrapper.text()).toContain('Alfred');
expect(wrapper.text()).toContain('INV-001');
expect(wrapper.text()).toContain('$10.00');
expect(wrapper.text()).toContain('paid');
expect(wrapper.get('button').attributes('aria-label')).toBe(
'STRIPE_INTEGRATION.VIEW_SUBSCRIPTION'
);
});
it('shows the no-email state', async () => {
StripeAPI.customer.mockResolvedValue({
data: { customers: [], missing_email: true },
});
wrapper = mount(StripeCustomer, { props: { conversationId: 1 } });
await flushPromises();
expect(wrapper.text()).toContain('STRIPE_INTEGRATION.NO_EMAIL');
});
it('shows scheduled cancellation instead of a renewal', async () => {
StripeAPI.customer.mockResolvedValue({
data: {
...data,
subscriptions: [
{ ...data.subscriptions[0], cancel_at_period_end: true },
],
},
});
wrapper = mount(StripeCustomer, { props: { conversationId: 1 } });
await flushPromises();
expect(wrapper.text()).toContain(
'STRIPE_INTEGRATION.CANCELS_AT_PERIOD_END'
);
expect(wrapper.text()).not.toContain('STRIPE_INTEGRATION.RENEWS');
});
it('does not display metered pricing as a fixed charge', async () => {
const subscription = data.subscriptions[0];
const item = subscription.items[0];
StripeAPI.customer.mockResolvedValue({
data: {
...data,
subscriptions: [
{
...subscription,
items: [
{
...item,
recurring: { ...item.recurring, usage_type: 'metered' },
},
],
},
],
},
});
wrapper = mount(StripeCustomer, { props: { conversationId: 1 } });
await flushPromises();
expect(wrapper.text()).toContain('STRIPE_INTEGRATION.VARIABLE_PRICE');
expect(wrapper.text()).not.toContain('STRIPE_INTEGRATION.PRICE_INTERVAL');
});
it('opens subscription details and removes them when the conversation changes', async () => {
StripeAPI.customer.mockResolvedValueOnce({ data });
wrapper = mount(StripeCustomer, { props: { conversationId: 1 } });
await flushPromises();
expect(wrapper.text()).toContain('Support Pro');
await wrapper.get('button').trigger('click');
expect(document.querySelector('[role="dialog"]').textContent).toContain(
'sub_test'
);
expect(document.querySelector('[role="dialog"]').textContent).toContain(
'STRIPE_INTEGRATION.PERIOD_START'
);
StripeAPI.customer.mockResolvedValueOnce({ data: { customers: [] } });
await wrapper.setProps({ conversationId: 2 });
await flushPromises();
expect(document.querySelector('[role="dialog"]')).toBeNull();
});
it('lets the agent select between customers sharing the same email', async () => {
StripeAPI.customer.mockResolvedValueOnce({
data: { customers: [customer, { ...customer, id: 'cus_second' }] },
});
wrapper = mount(StripeCustomer, { props: { conversationId: 1 } });
await flushPromises();
StripeAPI.customer.mockResolvedValueOnce({ data });
await wrapper.get('select').setValue('cus_alfred');
await flushPromises();
expect(StripeAPI.customer).toHaveBeenLastCalledWith(
1,
'cus_alfred',
expect.any(AbortSignal)
);
expect(wrapper.text()).toContain('INV-001');
});
it('clears the previous customer when switching to an unmatched conversation', async () => {
StripeAPI.customer.mockResolvedValueOnce({ data });
wrapper = mount(StripeCustomer, { props: { conversationId: 1 } });
await flushPromises();
StripeAPI.customer.mockResolvedValueOnce({ data: { customers: [] } });
await wrapper.setProps({ conversationId: 2 });
await flushPromises();
expect(StripeAPI.customer).toHaveBeenLastCalledWith(
2,
undefined,
expect.any(AbortSignal)
);
expect(wrapper.text()).not.toContain('Alfred');
expect(wrapper.text()).toContain('STRIPE_INTEGRATION.EMPTY');
});
it('shows an error without exposing the previous customer', async () => {
StripeAPI.customer.mockResolvedValueOnce({ data });
wrapper = mount(StripeCustomer, { props: { conversationId: 1 } });
await flushPromises();
StripeAPI.customer.mockRejectedValueOnce(
new Error('private provider error')
);
await wrapper.setProps({ conversationId: 2 });
await flushPromises();
expect(wrapper.get('[role="alert"]').text()).toContain(
'STRIPE_INTEGRATION.ERROR'
);
expect(wrapper.text()).not.toContain('Alfred');
expect(wrapper.text()).not.toContain('private provider error');
});
it('reloads and clears the selected customer after the contact email changes', async () => {
StripeAPI.customer.mockResolvedValueOnce({
data: { customers: [customer, { ...customer, id: 'cus_second' }] },
});
wrapper = mount(StripeCustomer, {
props: { conversationId: 1, contactEmail: customer.email },
});
await flushPromises();
StripeAPI.customer.mockResolvedValueOnce({ data });
await wrapper.get('select').setValue('cus_alfred');
await flushPromises();
StripeAPI.customer.mockResolvedValueOnce({
data: { customers: [], missing_email: true },
});
await wrapper.setProps({ contactEmail: '' });
await flushPromises();
expect(StripeAPI.customer).toHaveBeenLastCalledWith(
1,
undefined,
expect.any(AbortSignal)
);
expect(wrapper.text()).not.toContain('Alfred');
expect(wrapper.text()).toContain('STRIPE_INTEGRATION.NO_EMAIL');
StripeAPI.customer.mockResolvedValueOnce({ data });
await wrapper.setProps({ contactEmail: customer.email });
await flushPromises();
expect(wrapper.text()).toContain('INV-001');
});
it.each([
['jpy', 1000, '1,000'],
['isk', 1000, '10'],
])('formats %s using Stripe minor units', async (currency, total, amount) => {
StripeAPI.customer.mockResolvedValue({
data: { ...data, invoices: [{ ...data.invoices[0], currency, total }] },
});
wrapper = mount(StripeCustomer, { props: { conversationId: 1 } });
await flushPromises();
expect(wrapper.get('.tabular-nums').text()).toContain(amount);
});
});

View File

@@ -12,6 +12,7 @@ export const DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER = Object.freeze([
{ name: 'previous_conversation' },
{ name: 'conversation_participants' },
{ name: 'linear_issues' },
{ name: 'stripe_customer' },
{ name: 'shopify_orders' },
]);

View File

@@ -39,6 +39,7 @@ export const FEATURE_FLAGS = {
IP_LOOKUP: 'ip_lookup',
LINEAR: 'linear_integration',
SHOPIFY: 'shopify_integration',
STRIPE: 'stripe_integration',
CAPTAIN: 'captain_integration',
CUSTOM_ROLES: 'custom_roles',
CHATWOOT_V4: 'chatwoot_v4',

View File

@@ -0,0 +1,30 @@
import { formatStripeAmount } from '../stripeHelper';
describe('formatStripeAmount', () => {
it('preserves fractional minor units for subscription prices', () => {
expect(formatStripeAmount('0.05', 'USD', { preservePrecision: true })).toBe(
'$0.0005'
);
expect(
formatStripeAmount('0.000000000001', 'USD', { preservePrecision: true })
).toBe('$0.00000000000001');
});
it('keeps standard invoice precision and trims insignificant price zeros', () => {
expect(formatStripeAmount(1000, 'USD')).toBe('$10.00');
expect(
formatStripeAmount('1000.00', 'USD', { preservePrecision: true })
).toBe('$10.00');
});
it('uses two-decimal API units for UGX', () => {
expect(formatStripeAmount(5000, 'UGX')).toBe(
new Intl.NumberFormat(undefined, {
style: 'currency',
currency: 'UGX',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(50)
);
});
});

View File

@@ -0,0 +1,49 @@
// Stripe API units are independent of the locale's display precision.
const ZERO_DECIMAL_CURRENCIES = [
'BIF',
'CLP',
'DJF',
'GNF',
'JPY',
'KMF',
'KRW',
'MGA',
'PYG',
'RWF',
'VND',
'VUV',
'XAF',
'XOF',
'XPF',
];
const THREE_DECIMAL_CURRENCIES = ['BHD', 'JOD', 'KWD', 'OMR', 'TND'];
export const STRIPE_STATUS_COLORS = {
active: 'teal',
paid: 'teal',
trialing: 'blue',
open: 'amber',
past_due: 'ruby',
unpaid: 'ruby',
uncollectible: 'ruby',
};
export const formatStripeAmount = (
amount,
currency,
{ preservePrecision = false } = {}
) => {
const code = currency.toUpperCase();
let digits = 2;
if (ZERO_DECIMAL_CURRENCIES.includes(code)) digits = 0;
if (THREE_DECIMAL_CURRENCIES.includes(code)) digits = 3;
const fractionalDigits = preservePrecision
? (String(amount).split('.')[1] || '').replace(/0+$/, '').length
: 0;
return new Intl.NumberFormat(undefined, {
style: 'currency',
currency: code,
minimumFractionDigits: digits,
maximumFractionDigits: digits + fractionalDigits,
}).format(Number(amount) / 10 ** digits);
};

View File

@@ -1,4 +1,54 @@
{
"STRIPE_INTEGRATION": {
"UNAVAILABLE": "Stripe is unavailable for this account. Contact your administrator to check the integration configuration.",
"TITLE": "Stripe",
"DESCRIPTION": "View customer details, subscriptions, and invoices alongside your conversations.",
"CONNECT": "Connect",
"DISCONNECT": "Disconnect from Chatwoot",
"DISCONNECT_NOTE": "Disconnecting removes the saved credentials from Chatwoot. Uninstall the app in Stripe to revoke its access there.",
"CONNECTED": "Connected",
"RECONNECT": "Reconnect Stripe",
"REAUTHORIZATION_REQUIRED": "Reconnect required",
"ACCOUNT": "Connected account",
"ACCOUNT_ID": "Account ID",
"ENVIRONMENT": "Environment",
"SANDBOX": "Sandbox",
"LIVE": "Live",
"CONNECTED_ON": "Connected on",
"OPEN_STRIPE": "Open Stripe dashboard",
"ERROR": "Unable to load Stripe. Please try again or reconnect the integration.",
"EMPTY": "No Stripe customer matches this contact's email.",
"NO_EMAIL": "Add an email to this contact to look up Stripe customers.",
"SELECT": "Select a matching customer",
"CUSTOMER_OPTION": "{name} — {id}",
"MORE": "Showing the first 100 matching customers. Refine the contact email if the customer is not listed.",
"SUBSCRIPTIONS": "Latest five subscriptions",
"SUBSCRIPTION": "Subscription",
"VIEW_SUBSCRIPTION": "View {name} subscription details",
"COPY_ID": "Copy ID",
"COPIED": "Copied",
"QUANTITY": "Quantity: {count}",
"PRICE_INTERVAL": "{amount} / {interval}",
"INTERVAL": {
"day": "day | {count} days",
"week": "week | {count} weeks",
"month": "month | {count} months",
"year": "year | {count} years"
},
"VARIABLE_PRICE": "Usage-based or tiered pricing",
"RENEWS": "Renews {date}",
"PERIOD_START": "Period starts {date}",
"PERIOD_END": "Period ends {date}",
"TRIAL_ENDS": "Trial ends {date}",
"CANCELS": "Cancels {date}",
"CANCELS_AT_PERIOD_END": "Cancels at the end of the billing period",
"CANCELED_ON": "Canceled {date}",
"MORE_ITEMS": "Additional subscription items are not shown.",
"PRICE_NOTE": "Prices are per unit, before discounts and taxes. They are not an estimate of the next invoice.",
"INVOICES": "Recent invoices",
"NO_SUBSCRIPTIONS": "No subscriptions found.",
"NO_INVOICES": "No invoices found."
},
"INTEGRATION_SETTINGS": {
"SHOPIFY": {
"HEADER": "Shopify",

View File

@@ -24,6 +24,7 @@ import ShopifyOrdersList from 'dashboard/components/widgets/conversation/Shopify
import SidebarActionsHeader from 'dashboard/components-next/SidebarActionsHeader.vue';
import LinearIssuesList from 'dashboard/components/widgets/conversation/linear/IssuesList.vue';
import LinearSetupCTA from 'dashboard/components/widgets/conversation/linear/LinearSetupCTA.vue';
import StripeCustomer from 'dashboard/components/widgets/conversation/StripeCustomer.vue';
const props = defineProps({
conversationId: {
@@ -76,6 +77,10 @@ const isLinearConnected = computed(
);
const store = useStore();
const stripeIntegration = useFunctionGetter(
'integrations/getIntegration',
'stripe'
);
const currentChat = useMapGetter('getSelectedChat');
const conversationId = computed(() => props.conversationId);
const conversationMetadataGetter = useMapGetter(
@@ -135,8 +140,7 @@ onMounted(() => {
conversationSidebarItems.value = conversationSidebarItemsOrder.value;
getContactDetails();
store.dispatch('attributes/get', 0);
// Load integrations to ensure linear integration state is available
store.dispatch('integrations/get', 'linear');
store.dispatch('integrations/get');
});
</script>
@@ -299,6 +303,28 @@ onMounted(() => {
<ShopifyOrdersList :contact-id="contactId" />
</AccordionItem>
</div>
<div
v-else-if="
element.name === 'stripe_customer' &&
isCloudFeatureEnabled(FEATURE_FLAGS.STRIPE) &&
stripeIntegration.enabled
"
>
<AccordionItem
:title="$t('STRIPE_INTEGRATION.TITLE')"
:is-open="isContactSidebarItemOpen('is_stripe_customer_open')"
compact
@toggle="
value => toggleSidebarUIState('is_stripe_customer_open', value)
"
>
<StripeCustomer
v-if="isContactSidebarItemOpen('is_stripe_customer_open')"
:conversation-id="conversationId"
:contact-email="contact.email"
/>
</AccordionItem>
</div>
<div v-else-if="element.name === 'contact_notes'">
<AccordionItem
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.CONTACT_NOTES')"

View File

@@ -43,6 +43,9 @@ const integrationStatusColor = computed(() =>
props.enabled ? 'teal' : 'slate'
);
const isSvgLogo = computed(() => props.id === 'stripe');
const logoExtension = computed(() => (isSvgLogo.value ? 'svg' : 'png'));
const actionURL = computed(() =>
frontendURL(`accounts/${accountId.value}/settings/integrations/${props.id}`)
);
@@ -55,12 +58,20 @@ const actionURL = computed(() =>
<div class="flex items-start justify-between">
<div class="flex h-12 w-12 mb-2">
<img
:src="`/dashboard/images/integrations/${id}.png`"
class="max-w-full rounded-md border border-n-weak shadow-sm block dark:hidden bg-n-alpha-3 dark:bg-n-alpha-2"
:src="`/dashboard/images/integrations/${id}.${logoExtension}`"
:alt="name"
class="rounded-md border border-n-weak shadow-sm block dark:hidden"
:class="
isSvgLogo ? 'size-12 p-1.5 bg-white' : 'max-w-full bg-n-alpha-3'
"
/>
<img
:src="`/dashboard/images/integrations/${id}-dark.png`"
class="max-w-full rounded-md border border-n-weak shadow-sm hidden dark:block bg-n-alpha-3 dark:bg-n-alpha-2"
:src="`/dashboard/images/integrations/${id}-dark.${logoExtension}`"
:alt="name"
class="rounded-md border border-n-weak shadow-sm hidden dark:block"
:class="
isSvgLogo ? 'size-12 p-1.5 bg-n-solid-3' : 'max-w-full bg-n-alpha-2'
"
/>
</div>
<Label

View File

@@ -0,0 +1,235 @@
<script setup>
import { computed, ref, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { useStore, useFunctionGetter } from 'dashboard/composables/store';
import { useBranding } from 'shared/composables/useBranding';
import StripeAPI from 'dashboard/api/integrations/stripe';
import IntegrationsAPI from 'dashboard/api/integrations';
import Button from 'dashboard/components-next/button/Button.vue';
import Label from 'dashboard/components-next/label/Label.vue';
import SettingsLayout from '../SettingsLayout.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
const store = useStore();
const { replaceInstallationName } = useBranding();
const route = useRoute();
const integration = useFunctionGetter('integrations/getIntegration', 'stripe');
const loading = ref(true);
const loaded = ref(false);
const available = ref(false);
const busy = ref(false);
const error = ref(!!route.query.error);
const account = ref(null);
const STRIPE_LOGO = '/dashboard/images/integrations/stripe.svg';
const STRIPE_LOGO_DARK = '/dashboard/images/integrations/stripe-dark.svg';
const dashboardURL = computed(() => {
const { account_id: accountId, mode } = account.value;
return `https://dashboard.stripe.com/${accountId}/${mode === 'live' ? '' : 'test/'}dashboard`;
});
const loadIntegration = async () => {
const { data } = await IntegrationsAPI.get();
store.commit('integrations/SET_INTEGRATIONS', data.payload);
const stripe = data.payload.find(item => item.id === 'stripe');
available.value = !!stripe && !stripe.cleanup_only;
account.value =
stripe && (integration.value.enabled || stripe.cleanup_only)
? (await StripeAPI.get()).data
: null;
loaded.value = true;
};
const connect = async () => {
busy.value = true;
error.value = false;
try {
const { data } = await StripeAPI.connect();
window.location.assign(data.url);
} catch {
error.value = true;
} finally {
busy.value = false;
}
};
const disconnect = async () => {
busy.value = true;
error.value = false;
try {
await StripeAPI.disconnect();
account.value = null;
store.commit('integrations/DELETE_INTEGRATION', {
id: 'stripe',
enabled: false,
hooks: [],
});
await loadIntegration();
} catch {
error.value = true;
} finally {
busy.value = false;
}
};
onMounted(async () => {
try {
await loadIntegration();
} catch {
error.value = true;
} finally {
loading.value = false;
}
});
</script>
<template>
<SettingsLayout :is-loading="loading">
<template #header>
<BaseSettingsHeader
:title="$t('STRIPE_INTEGRATION.TITLE')"
:description="
integration.enabled ? $t('STRIPE_INTEGRATION.DESCRIPTION') : ''
"
:back-button-label="$t('INTEGRATION_SETTINGS.HEADER')"
/>
</template>
<template #body>
<div class="flex flex-col gap-6 w-full text-n-slate-12">
<p v-if="error" role="alert" class="text-n-ruby-11">
{{ $t('STRIPE_INTEGRATION.ERROR') }}
</p>
<p v-if="loaded && !available" role="status" class="text-n-slate-11">
{{ $t('STRIPE_INTEGRATION.UNAVAILABLE') }}
</p>
<template v-if="loaded && account">
<Button
v-if="available && account.reauthorization_required"
:label="$t('STRIPE_INTEGRATION.RECONNECT')"
:disabled="busy"
@click="connect"
/>
<div class="rounded-xl border border-n-weak bg-n-solid-1">
<div
class="flex flex-wrap items-center justify-between gap-4 p-6 border-b border-n-weak"
>
<div class="flex items-center gap-3">
<img :src="STRIPE_LOGO" alt="Stripe" class="w-16 dark:hidden" />
<img
:src="STRIPE_LOGO_DARK"
alt="Stripe"
class="w-16 hidden dark:block"
/>
<span class="text-sm font-medium">{{
$t('STRIPE_INTEGRATION.ACCOUNT')
}}</span>
</div>
<Label
v-if="account.reauthorization_required"
:label="$t('STRIPE_INTEGRATION.REAUTHORIZATION_REQUIRED')"
color="amber"
compact
/>
<Label
v-else
:label="$t('STRIPE_INTEGRATION.CONNECTED')"
color="teal"
compact
/>
</div>
<dl
class="grid grid-cols-1 sm:grid-cols-[10rem_1fr] gap-x-8 gap-y-3 p-6 text-sm m-0"
>
<dt class="text-n-slate-11">
{{ $t('STRIPE_INTEGRATION.ACCOUNT_ID') }}
</dt>
<dd class="m-0 break-all font-mono text-xs self-center">
{{ account.account_id }}
</dd>
<dt class="text-n-slate-11">
{{ $t('STRIPE_INTEGRATION.ENVIRONMENT') }}
</dt>
<dd class="m-0">
{{
account.mode === 'live'
? $t('STRIPE_INTEGRATION.LIVE')
: $t('STRIPE_INTEGRATION.SANDBOX')
}}
</dd>
<dt class="text-n-slate-11">
{{ $t('STRIPE_INTEGRATION.CONNECTED_ON') }}
</dt>
<dd class="m-0">
{{ new Date(account.connected_at).toLocaleDateString() }}
</dd>
</dl>
<div class="px-6 pb-6">
<a
:href="dashboardURL"
target="_blank"
rel="noopener noreferrer"
class="text-sm text-n-brand hover:underline"
>
{{ $t('STRIPE_INTEGRATION.OPEN_STRIPE') }}
</a>
</div>
</div>
<div
class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 rounded-xl border border-n-weak p-6"
>
<p class="m-0 text-sm text-n-slate-11 max-w-xl">
{{
replaceInstallationName(
$t('STRIPE_INTEGRATION.DISCONNECT_NOTE')
)
}}
</p>
<Button
:label="
replaceInstallationName($t('STRIPE_INTEGRATION.DISCONNECT'))
"
:disabled="busy"
variant="outline"
color="ruby"
@click="disconnect"
/>
</div>
</template>
<div
v-else-if="loaded && available"
class="flex flex-col items-start justify-between lg:flex-row lg:items-center p-6 outline outline-n-container outline-1 bg-n-card rounded-xl gap-6"
>
<div
class="flex items-start lg:items-center justify-start flex-1 m-0 gap-6 flex-col lg:flex-row"
>
<div
class="flex h-16 w-16 items-center justify-center flex-shrink-0 rounded-md border border-n-weak shadow-sm bg-n-alpha-3 dark:bg-n-alpha-2"
>
<img :src="STRIPE_LOGO" alt="Stripe" class="w-12 dark:hidden" />
<img
:src="STRIPE_LOGO_DARK"
alt="Stripe"
class="w-12 hidden dark:block"
/>
</div>
<div>
<h3 class="mb-1 text-heading-1 text-n-slate-12">
{{ $t('STRIPE_INTEGRATION.TITLE') }}
</h3>
<p class="text-n-slate-11 text-body-main">
{{ $t('STRIPE_INTEGRATION.DESCRIPTION') }}
</p>
</div>
</div>
<Button
:label="$t('STRIPE_INTEGRATION.CONNECT')"
:disabled="busy"
faded
blue
@click="connect"
/>
</div>
</div>
</template>
</SettingsLayout>
</template>

View File

@@ -8,6 +8,7 @@ import Webhook from './Webhooks/Index.vue';
import DashboardApps from './DashboardApps/Index.vue';
import Slack from './Slack.vue';
import Linear from './Linear.vue';
import Stripe from './Stripe.vue';
import Notion from './Notion.vue';
import Shopify from './Shopify.vue';
@@ -92,6 +93,14 @@ export default {
},
props: route => ({ code: route.query.code }),
},
{
path: 'stripe',
name: 'settings_integrations_stripe',
component: Stripe,
meta: {
permissions: ['administrator'],
},
},
{
path: 'notion',
name: 'settings_integrations_notion',

View File

@@ -0,0 +1,222 @@
import { mount, flushPromises } from '@vue/test-utils';
import Stripe from '../Stripe.vue';
import StripeAPI from 'dashboard/api/integrations/stripe';
import IntegrationsAPI from 'dashboard/api/integrations';
import messages from 'dashboard/i18n/locale/en/integrations.json';
const mocks = vi.hoisted(() => ({
commit: vi.fn(),
enabled: true,
query: {},
installationName: undefined,
}));
vi.mock('vue-router', () => ({ useRoute: () => ({ query: mocks.query }) }));
vi.mock(
'dashboard/routes/dashboard/settings/components/BaseSettingsHeader.vue',
() => ({
default: { template: '<header />' },
})
);
vi.mock('dashboard/composables/store', async () => {
const { computed, reactive } = await import('vue');
return {
useStore: () => ({ commit: mocks.commit }),
useFunctionGetter: () => {
const integration = reactive({ enabled: mocks.enabled });
mocks.commit.mockImplementation((type, data) => {
if (type === 'integrations/DELETE_INTEGRATION') {
Object.assign(integration, data);
}
});
return computed(() => integration);
},
useMapGetter: () =>
computed(() => ({ installationName: mocks.installationName })),
};
});
vi.mock('dashboard/api/integrations/stripe', () => ({
default: { get: vi.fn(), connect: vi.fn(), disconnect: vi.fn() },
}));
vi.mock('dashboard/api/integrations', () => ({
default: { get: vi.fn() },
}));
describe('Stripe integration settings', () => {
let wrapper;
const global = {
stubs: {
SettingsLayout: {
template: '<div><slot name="header"/><slot name="body"/></div>',
},
BaseSettingsHeader: true,
},
};
beforeEach(() => {
mocks.enabled = true;
mocks.query = {};
mocks.installationName = undefined;
IntegrationsAPI.get.mockResolvedValue({
data: { payload: [{ id: 'stripe', enabled: true }] },
});
StripeAPI.get.mockResolvedValue({
data: {
account_id: 'acct_test',
connected_at: '2026-09-10T00:00:00Z',
mode: 'sandbox',
},
});
});
afterEach(() => wrapper?.unmount());
it.each(['Acme Support', undefined])(
'uses installation branding in disconnect copy: %s',
async installationName => {
mocks.installationName = installationName;
wrapper = mount(Stripe, {
global: {
...global,
mocks: {
$t: key => messages.STRIPE_INTEGRATION[key.split('.')[1]] || key,
},
},
});
await flushPromises();
const brand = installationName || 'Chatwoot';
expect(wrapper.get('button').text()).toBe(`Disconnect from ${brand}`);
expect(wrapper.text()).toContain(
`Disconnecting removes the saved credentials from ${brand}.`
);
if (installationName) expect(wrapper.text()).not.toContain('Chatwoot');
}
);
it('shows connected account details and a sandbox dashboard link', async () => {
wrapper = mount(Stripe, { global });
await flushPromises();
expect(mocks.commit).toHaveBeenCalledWith('integrations/SET_INTEGRATIONS', [
{ id: 'stripe', enabled: true },
]);
expect(wrapper.text()).toContain('acct_test');
expect(wrapper.text()).toContain('STRIPE_INTEGRATION.CONNECTED');
expect(wrapper.get('a').attributes('href')).toBe(
'https://dashboard.stripe.com/acct_test/test/dashboard'
);
expect(wrapper.get('a').attributes('rel')).toBe('noopener noreferrer');
});
it('does not request account details when disconnected', async () => {
mocks.enabled = false;
wrapper = mount(Stripe, { global });
await flushPromises();
expect(StripeAPI.get).not.toHaveBeenCalled();
expect(wrapper.get('button').text()).toContain(
'STRIPE_INTEGRATION.CONNECT'
);
expect(wrapper.find('a').exists()).toBe(false);
});
it('allows disconnecting an unavailable integration without showing Connect', async () => {
mocks.enabled = false;
IntegrationsAPI.get.mockResolvedValueOnce({
data: { payload: [{ id: 'stripe', enabled: false, cleanup_only: true }] },
});
wrapper = mount(Stripe, { global });
await flushPromises();
expect(wrapper.text()).toContain('STRIPE_INTEGRATION.UNAVAILABLE');
expect(wrapper.get('button').text()).toContain(
'STRIPE_INTEGRATION.DISCONNECT'
);
IntegrationsAPI.get.mockResolvedValueOnce({ data: { payload: [] } });
await wrapper.get('button').trigger('click');
await flushPromises();
expect(StripeAPI.disconnect).toHaveBeenCalled();
expect(wrapper.find('button').exists()).toBe(false);
});
it('shows live mode and links to the live dashboard', async () => {
StripeAPI.get.mockResolvedValue({
data: {
account_id: 'acct_live',
connected_at: '2026-09-10T00:00:00Z',
mode: 'live',
},
});
wrapper = mount(Stripe, { global });
await flushPromises();
expect(wrapper.text()).toContain('STRIPE_INTEGRATION.LIVE');
expect(wrapper.text()).not.toContain('STRIPE_INTEGRATION.SANDBOX');
expect(wrapper.get('a').attributes('href')).toBe(
'https://dashboard.stripe.com/acct_live/dashboard'
);
});
it('shows a safe error if account details cannot load', async () => {
StripeAPI.get.mockRejectedValue(new Error('private provider details'));
wrapper = mount(Stripe, { global });
await flushPromises();
expect(wrapper.get('[role="alert"]').text()).toContain(
'STRIPE_INTEGRATION.ERROR'
);
expect(wrapper.text()).not.toContain('private provider details');
});
it('shows a failed OAuth callback error', async () => {
mocks.query = { error: 'authorization_failed' };
wrapper = mount(Stripe, { global });
await flushPromises();
expect(wrapper.get('[role="alert"]').exists()).toBe(true);
});
it('does not show Connect when the integrations request fails', async () => {
mocks.enabled = false;
IntegrationsAPI.get.mockRejectedValue(new Error('request failed'));
wrapper = mount(Stripe, { global });
await flushPromises();
expect(wrapper.get('[role="alert"]').text()).toContain(
'STRIPE_INTEGRATION.ERROR'
);
expect(wrapper.find('button').exists()).toBe(false);
expect(StripeAPI.get).not.toHaveBeenCalled();
});
it('stays disconnected when the refresh after deletion fails', async () => {
StripeAPI.disconnect.mockResolvedValue({});
wrapper = mount(Stripe, { global });
await flushPromises();
IntegrationsAPI.get.mockRejectedValue(new Error('request failed'));
await wrapper.get('button').trigger('click');
await flushPromises();
expect(mocks.commit).toHaveBeenCalledWith(
'integrations/DELETE_INTEGRATION',
{
id: 'stripe',
enabled: false,
hooks: [],
}
);
expect(wrapper.text()).not.toContain('acct_test');
expect(wrapper.text()).not.toContain('STRIPE_INTEGRATION.CONNECTED');
expect(wrapper.get('button').text()).toBe('STRIPE_INTEGRATION.CONNECT');
expect(wrapper.find('a').exists()).toBe(false);
expect(wrapper.get('[role="alert"]').exists()).toBe(true);
});
it('keeps the connected account visible when disconnect fails', async () => {
StripeAPI.disconnect.mockRejectedValue(new Error('request failed'));
wrapper = mount(Stripe, { global });
await flushPromises();
await wrapper.get('button').trigger('click');
await flushPromises();
expect(StripeAPI.disconnect).toHaveBeenCalledOnce();
expect(mocks.commit).not.toHaveBeenCalledWith(
'integrations/DELETE_INTEGRATION',
expect.anything()
);
expect(wrapper.text()).toContain('acct_test');
expect(wrapper.get('[role="alert"]').exists()).toBe(true);
});
});

View File

@@ -27,6 +27,7 @@ vi.mock('../Index.vue', () => ({ default: {} }));
vi.mock('../Webhooks/Index.vue', () => ({ default: {} }));
vi.mock('../DashboardApps/Index.vue', () => ({ default: {} }));
vi.mock('../Slack.vue', () => ({ default: {} }));
vi.mock('../Stripe.vue', () => ({ default: {} }));
vi.mock('../Linear.vue', () => ({ default: {} }));
vi.mock('../Notion.vue', () => ({ default: {} }));
vi.mock('../Shopify.vue', () => ({ default: {} }));

View File

@@ -58,10 +58,12 @@ class Integrations::App
def active?(account)
case params[:id]
when 'stripe'
stripe_enabled?(account)
when 'slack'
GlobalConfigService.load('SLACK_CLIENT_SECRET', nil).present?
when 'linear'
account.feature_enabled?('linear_integration') && GlobalConfigService.load('LINEAR_CLIENT_ID', nil).present?
linear_enabled?(account)
when 'shopify'
shopify_enabled?(account)
when 'leadsquared'
@@ -92,7 +94,7 @@ class Integrations::App
account.webhooks.exists?
when 'dashboard_apps'
account.dashboard_apps.exists?
when 'shopify'
when 'shopify', 'stripe'
account.hooks.exists?(app_id: id, status: :enabled)
else
account.hooks.exists?(app_id: id)
@@ -129,6 +131,14 @@ class Integrations::App
private
def stripe_enabled?(account)
account.feature_enabled?('stripe_integration') && Integrations::Stripe::Oauth.configured?
end
def linear_enabled?(account)
account.feature_enabled?('linear_integration') && GlobalConfigService.load('LINEAR_CLIENT_ID', nil).present?
end
def shopify_enabled?(account)
Shopify::FeatureGate.enabled?(account: account) &&
GlobalConfigService.load('SHOPIFY_CLIENT_ID', nil).present?

View File

@@ -27,6 +27,7 @@ class Integrations::Hook < ApplicationRecord
validates :account_id, presence: true
validates :app_id, presence: true
validates :access_token, length: { maximum: ->(hook) { hook.app_id == 'stripe' ? MAX_TEXT_COLUMN_LENGTH : MAX_STRING_COLUMN_LENGTH } }
validates :inbox_id, presence: true, if: -> { hook_type == 'inbox' }
validates :reference_id, presence: true, format: { with: Shopify::ShopDomain::FORMAT }, if: :shopify?
validates :reference_id,

View File

@@ -0,0 +1,46 @@
class Integrations::Stripe::Connection
class ReauthorizationRequired < StandardError; end
def initialize(hook)
@hook = hook
end
def store_token!(token)
@hook.update!(access_token: {
access_token: token.token,
refresh_token: token.refresh_token,
expires_at: 1.hour.from_now.to_i
}.to_json)
end
def api_token
unless livemode? == Integrations::Stripe::Oauth.livemode?
raise ReauthorizationRequired, 'Stripe connection environment does not match the configured key'
end
credentials = JSON.parse(@hook.access_token)
return credentials.fetch('access_token') unless expiring?(credentials)
@hook.with_lock do
credentials = JSON.parse(@hook.access_token)
if expiring?(credentials)
token = OAuth2::AccessToken.new(Integrations::Stripe::Oauth.client, credentials.fetch('access_token'),
refresh_token: credentials.fetch('refresh_token'))
store_token!(token.refresh!)
credentials = JSON.parse(@hook.access_token)
end
credentials.fetch('access_token')
end
end
def livemode?
# Connections created before live-mode support are sandbox connections.
@hook.settings.fetch('livemode', false)
end
private
def expiring?(credentials)
credentials.fetch('expires_at') <= 1.minute.from_now.to_i
end
end

View File

@@ -0,0 +1,74 @@
class Integrations::Stripe::CustomerSummary
RECENT_LIMIT = 5
def initialize(connection:, contact:)
@connection = connection
@contact = contact
end
def perform(customer_id: nil)
return { customers: [], missing_email: true } if @contact.email.blank?
@options = { api_key: @connection.api_token }
@products = {}
escaped_email = @contact.email.gsub(/["\\]/) { |char| "\\#{char}" }
customers = ::Stripe::Customer.search({ query: "email:\"#{escaped_email}\"", limit: 100 }, @options)
matches = customers.data.select { |customer| customer.email&.casecmp?(@contact.email) }
.map { |customer| customer.to_hash.slice(:id, :name, :email, :phone) }
selected = select_customer(matches, customer_id)
result = { customers: matches, has_more_customers: customers.has_more }
selected ? result.merge(customer: selected, **billing_details(selected[:id])) : result
end
private
def select_customer(matches, customer_id)
selected = matches.find { |customer| customer[:id] == customer_id } if customer_id
raise ActiveRecord::RecordNotFound if customer_id && !selected
selected || (matches.first if matches.one?)
end
def billing_details(customer_id)
subscriptions = ::Stripe::Subscription.list({ customer: customer_id, status: 'all', limit: RECENT_LIMIT }, @options)
invoices = ::Stripe::Invoice.list({ customer: customer_id, limit: RECENT_LIMIT }, @options)
subscriptions = subscriptions.data.map(&:to_hash)
load_products(subscriptions)
{
subscriptions: subscriptions.map { |subscription| subscription_details(subscription) },
invoices: invoices.data.map { |invoice| invoice.to_hash.slice(:id, :number, :status, :total, :currency, :created) }
}
end
def subscription_details(subscription)
subscription.slice(:id, :status, :trial_end, :cancel_at, :cancel_at_period_end, :canceled_at).merge(
items: subscription.fetch(:items, {}).fetch(:data, []).map { |item| subscription_item(item, subscription) },
has_more_items: subscription.dig(:items, :has_more) || false
)
end
def subscription_item(item, subscription)
price = item.fetch(:price)
{
id: item[:id], name: @products[price[:product]] || price[:nickname], quantity: item[:quantity],
unit_amount: price[:unit_amount_decimal] || price[:unit_amount], currency: price[:currency],
recurring: price.fetch(:recurring).slice(:interval, :interval_count, :usage_type),
billing_scheme: price[:billing_scheme],
current_period_start: item[:current_period_start] || subscription[:current_period_start],
current_period_end: item[:current_period_end] || subscription[:current_period_end]
}
end
def load_products(subscriptions)
product_ids = subscriptions.flat_map { |subscription| subscription.fetch(:items, {}).fetch(:data, []) }
.map { |item| item.fetch(:price).fetch(:product) }.uniq
product_ids.each_slice(100) do |ids|
products = ::Stripe::Product.list({ ids: ids, limit: 100 }, @options)
products.data.each { |product| @products[product.id] = product.name }
end
rescue ::Stripe::PermissionError
# Existing installations may not yet have granted product_read.
# Stop the whole batch lookup rather than retrying each product.
nil
end
end

View File

@@ -0,0 +1,72 @@
class Integrations::Stripe::Oauth
STATE_TTL = 10.minutes
def self.configuration_errors(url:, key:)
return [] if url.blank? && key.blank?
errors = []
errors << 'Stripe authorization URL must be an HTTPS Stripe Marketplace URL' unless valid_authorize_url?(URI.parse(url.to_s))
errors << 'Stripe App secret key must start with sk_test_ or sk_live_' unless key.to_s.match?(/\Ask_(test|live)_[A-Za-z0-9]+\z/)
errors
rescue URI::InvalidURIError
['Stripe authorization URL is invalid']
end
def self.valid_authorize_url?(uri)
return false unless uri.scheme == 'https' && uri.host == 'marketplace.stripe.com' && uri.userinfo.nil?
return false unless uri.path.match?(%r{\A/oauth/v2/(?:chnlink_[A-Za-z0-9]+/)?authorize\z})
query = URI.decode_www_form(uri.query.to_s).to_h
query.fetch('client_id', '').match?(/\Aca_[A-Za-z0-9]+\z/)
rescue ArgumentError
false
end
def self.configured?
GlobalConfig.get_value('STRIPE_APP_AUTHORIZE_URL').present? &&
configuration_errors(url: GlobalConfig.get_value('STRIPE_APP_AUTHORIZE_URL'),
key: GlobalConfig.get_value('STRIPE_APP_SECRET_KEY')).empty? && Chatwoot.encryption_configured?
end
def self.callback_url
"#{ENV.fetch('FRONTEND_URL')}/stripe/callback"
end
def self.client
key = GlobalConfig.get_value('STRIPE_APP_SECRET_KEY')
livemode?
OAuth2::Client.new(key, '',
site: 'https://api.stripe.com', token_url: '/v1/oauth/token', auth_scheme: :basic_auth,
connection_opts: { request: { open_timeout: 5, timeout: 10 } })
end
def self.livemode?
key = GlobalConfig.get_value('STRIPE_APP_SECRET_KEY')
raise ArgumentError, 'Invalid Stripe App secret key' unless key&.start_with?('sk_test_', 'sk_live_')
key.start_with?('sk_live_')
end
def self.authorize_url(account:, user:)
uri = URI(GlobalConfig.get_value('STRIPE_APP_AUTHORIZE_URL'))
raise ArgumentError, 'Invalid Stripe authorization URL' unless valid_authorize_url?(uri)
state = SecureRandom.hex(32)
payload = { account_id: account.id, user_id: user.id, livemode: livemode? }.to_json
Redis::Alfred.setex("stripe_app:oauth:#{state}", payload, STATE_TTL.to_i)
query = URI.decode_www_form(uri.query.to_s).to_h.merge('state' => state, 'redirect_uri' => callback_url)
uri.query = URI.encode_www_form(query)
uri.to_s
end
def self.consume_state(state)
return unless state.is_a?(String) && state.match?(/\A[0-9a-f]{64}\z/)
key = "stripe_app:oauth:#{state}"
payload = Redis::Alfred.get(key)
return unless payload && Redis::Alfred.delete_if_equals(key, payload) == [1]
JSON.parse(payload)
end
end

View File

@@ -2,7 +2,9 @@ json.id resource.id
json.name resource.name
json.description resource.description
json.short_description resource.short_description.presence
json.enabled resource.enabled?(@current_account)
cleanup_only = resource.id == 'stripe' && !resource.active?(@current_account)
json.enabled !cleanup_only && resource.enabled?(@current_account)
json.cleanup_only cleanup_only if resource.id == 'stripe'
if Current.account_user&.administrator?
json.call(resource.params, *resource.params.keys)

View File

@@ -300,3 +300,7 @@
enabled: false
premium: true
column: feature_flags_ext_1
- name: stripe_integration
display_name: Stripe Integration
enabled: false
column: feature_flags_ext_1

View File

@@ -419,6 +419,19 @@
description: 'Contents on your firebase credentials json file'
## ------ End of Configs added for FCM v1 notifications ------ ##
## Stripe customer integration (separate from Chatwoot subscription billing)
- name: STRIPE_APP_AUTHORIZE_URL
display_title: 'Stripe App Authorization URL'
value:
locked: false
description: 'Use the Stripe App OAuth link for this installation: Live mode for production or Sandbox for testing. The link and secret key must use the same environment.'
- name: STRIPE_APP_SECRET_KEY
display_title: 'Stripe App Secret Key'
value:
locked: false
type: secret
description: 'Use the app developer live secret key for Live mode, or the app managed sandbox secret key for Sandbox. Active Record encryption is required. Changing environments requires reconnecting existing accounts.'
## ------ Configs added for Linear ------ ##
- name: LINEAR_CLIENT_ID
display_title: 'Linear Client ID'

View File

@@ -57,6 +57,13 @@ openai:
},
]
visible_properties: ['label_suggestion']
stripe:
id: stripe
logo: stripe.svg
i18n_key: stripe
hook_type: account
allow_multiple_hooks: false
visible_properties: []
linear:
id: linear
logo: linear.png

View File

@@ -430,6 +430,10 @@ en:
online:
delete: '%{contact_name} is Online, please try again later'
integration_apps:
stripe:
name: 'Stripe'
short_description: 'View customer billing details in conversations.'
description: 'View customer details, subscriptions, and recent invoices from Stripe alongside your conversations.'
# Note: webhooks and dashboard_apps don't need short_description as they use different modal components
dashboard_apps:
name: 'Dashboard Apps'

View File

@@ -428,6 +428,10 @@ Rails.application.routes.draw do
resources :webhooks, only: [:index, :create, :update, :destroy]
namespace :integrations do
resource :stripe, controller: 'stripe', only: [:show, :destroy] do
post :auth
get :customer
end
resources :apps, only: [:index, :show]
resources :hooks, only: [:show, :create, :update, :destroy] do
member do
@@ -722,6 +726,9 @@ Rails.application.routes.draw do
resource :callback, only: [:show]
end
namespace :stripe do
resource :callback, only: [:show]
end
namespace :linear do
resource :callback, only: [:show]
end

View File

@@ -0,0 +1,9 @@
<svg width="360" height="151" viewBox="0 0 360 151" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M360 78.0002C360 52.4002 347.6 32.2002 323.9 32.2002C300.1 32.2002 285.7 52.4002 285.7 77.8002C285.7 107.9 302.7 123.1 327.1 123.1C339 123.1 348 120.4 354.8 116.6V96.6002C348 100 340.2 102.1 330.3 102.1C320.6 102.1 312 98.7002 310.9 86.9002H359.8C359.8 85.6002 360 80.4002 360 78.0002ZM310.6 68.5002C310.6 57.2002 317.5 52.5002 323.8 52.5002C329.9 52.5002 336.4 57.2002 336.4 68.5002H310.6Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M247.1 32.2002C237.3 32.2002 231 36.8002 227.5 40.0002L226.2 33.8002H204.2V150.4L229.2 145.1L229.3 116.8C232.9 119.4 238.2 123.1 247 123.1C264.9 123.1 281.2 108.7 281.2 77.0002C281.1 48.0002 264.6 32.2002 247.1 32.2002ZM241.1 101.1C235.2 101.1 231.7 99.0002 229.3 96.4002L229.2 59.3002C231.8 56.4002 235.4 54.4002 241.1 54.4002C250.2 54.4002 256.5 64.6002 256.5 77.7002C256.5 91.1002 250.3 101.1 241.1 101.1Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M169.8 26.3001L194.9 20.9001V0.600098L169.8 5.9001V26.3001Z" fill="white"/>
<path d="M194.9 33.9001H169.8V121.4H194.9V33.9001Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M142.9 41.3001L141.3 33.9001H119.7V121.4H144.7V62.1001C150.6 54.4001 160.6 55.8001 163.7 56.9001V33.9001C160.5 32.7001 148.8 30.5001 142.9 41.3001Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M92.8999 12.2002L68.4999 17.4002L68.3999 97.5002C68.3999 112.3 79.4999 123.2 94.2999 123.2C102.5 123.2 108.5 121.7 111.8 119.9V99.6002C108.6 100.9 92.7999 105.5 92.7999 90.7002V55.2002H111.8V33.9002H92.7999L92.8999 12.2002Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M25.3 59.3002C25.3 55.4002 28.5 53.9002 33.8 53.9002C41.4 53.9002 51 56.2002 58.6 60.3002V36.8002C50.3 33.5002 42.1 32.2002 33.8 32.2002C13.5 32.2002 0 42.8002 0 60.5002C0 88.1002 38 83.7002 38 95.6002C38 100.2 34 101.7 28.4 101.7C20.1 101.7 9.5 98.3002 1.1 93.7002V117.5C10.4 121.5 19.8 123.2 28.4 123.2C49.2 123.2 63.5 112.9 63.5 95.0002C63.4 65.2002 25.3 70.5002 25.3 59.3002Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -0,0 +1,9 @@
<svg width="360" height="150" viewBox="0 0 360 150" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M360 77.4001C360 51.8001 347.6 31.6001 323.9 31.6001C300.1 31.6001 285.7 51.8001 285.7 77.2001C285.7 107.3 302.7 122.5 327.1 122.5C339 122.5 348 119.8 354.8 116V96.0001C348 99.4001 340.2 101.5 330.3 101.5C320.6 101.5 312 98.1001 310.9 86.3001H359.8C359.8 85.0001 360 79.8001 360 77.4001ZM310.6 67.9001C310.6 56.6001 317.5 51.9001 323.8 51.9001C329.9 51.9001 336.4 56.6001 336.4 67.9001H310.6Z" fill="#533AFD"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M247.1 31.6001C237.3 31.6001 231 36.2001 227.5 39.4001L226.2 33.2001H204.2V149.8L229.2 144.5L229.3 116.2C232.9 118.8 238.2 122.5 247 122.5C264.9 122.5 281.2 108.1 281.2 76.4001C281.1 47.4001 264.6 31.6001 247.1 31.6001ZM241.1 100.5C235.2 100.5 231.7 98.4001 229.3 95.8001L229.2 58.7001C231.8 55.8001 235.4 53.8001 241.1 53.8001C250.2 53.8001 256.5 64.0001 256.5 77.1001C256.5 90.5001 250.3 100.5 241.1 100.5Z" fill="#533AFD"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M169.8 25.7L194.9 20.3V0L169.8 5.3V25.7Z" fill="#533AFD"/>
<path d="M194.9 33.3H169.8V120.8H194.9V33.3Z" fill="#533AFD"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M142.9 40.7L141.3 33.3H119.7V120.8H144.7V61.5C150.6 53.8 160.6 55.2 163.7 56.3V33.3C160.5 32.1 148.8 29.9 142.9 40.7Z" fill="#533AFD"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M92.8999 11.6001L68.4999 16.8001L68.3999 96.9001C68.3999 111.7 79.4999 122.6 94.2999 122.6C102.5 122.6 108.5 121.1 111.8 119.3V99.0001C108.6 100.3 92.7999 104.9 92.7999 90.1001V54.6001H111.8V33.3001H92.7999L92.8999 11.6001Z" fill="#533AFD"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M25.3 58.7001C25.3 54.8001 28.5 53.3001 33.8 53.3001C41.4 53.3001 51 55.6001 58.6 59.7001V36.2001C50.3 32.9001 42.1 31.6001 33.8 31.6001C13.5 31.6001 0 42.2001 0 59.9001C0 87.5001 38 83.1001 38 95.0001C38 99.6001 34 101.1 28.4 101.1C20.1 101.1 9.5 97.7001 1.1 93.1001V116.9C10.4 120.9 19.8 122.6 28.4 122.6C49.2 122.6 63.5 112.3 63.5 94.4001C63.4 64.6001 25.3 69.9001 25.3 58.7001Z" fill="#533AFD"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -7,6 +7,33 @@ RSpec.describe 'Integration Hooks API', type: :request do
let(:inbox) { create(:inbox, account: account) }
let(:params) { { app_id: 'dialogflow', inbox_id: inbox.id, settings: { project_id: 'xx', credentials: { test: 'test' }, region: 'europe-west1' } } }
describe 'Stripe hooks' do
before do
account.enable_features!('stripe_integration')
allow(Integrations::Stripe::Oauth).to receive(:configured?).and_return(true)
end
it 'rejects creation outside OAuth even when Stripe is available' do
expect do
post api_v1_account_integrations_hooks_url(account_id: account.id),
params: { app_id: 'stripe' }, headers: admin.create_new_auth_token, as: :json
end.not_to change(Integrations::Hook, :count)
expect(response).to have_http_status(:not_found)
end
it 'rejects updates and deletion through generic hook endpoints' do
hook = create(:integrations_hook, app_id: 'stripe', account: account)
path = "/api/v1/accounts/#{account.id}/integrations/hooks/#{hook.id}"
headers = admin.create_new_auth_token
patch path, params: { status: 'disabled' }, headers: headers, as: :json
expect(response).to have_http_status(:not_found)
expect(hook.reload).to be_enabled
delete path, headers: headers
expect(response).to have_http_status(:not_found)
expect(Integrations::Hook.exists?(hook.id)).to be true
end
end
describe 'POST /api/v1/accounts/{account.id}/integrations/hooks' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do

View File

@@ -0,0 +1,130 @@
require 'rails_helper'
RSpec.describe 'Stripe Integration API', type: :request do
let(:account) { create(:account) }
let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
let!(:hook) { create(:integrations_hook, app_id: 'stripe', account: account, reference_id: 'acct_sandbox', access_token: 'private-credentials') }
let(:path) { "/api/v1/accounts/#{account.id}/integrations/stripe" }
let(:conversation) { create(:conversation, account: account) }
let(:summary) { instance_double(Integrations::Stripe::CustomerSummary, perform: { customers: [] }) }
before do
account.enable_features!('stripe_integration')
allow(Integrations::Stripe::Oauth).to receive(:configured?).and_return(true)
end
it 'returns account details to administrators without credentials' do
get path, headers: admin.create_new_auth_token
expect(response).to have_http_status(:ok)
expect(response.parsed_body).to include('account_id' => 'acct_sandbox', 'mode' => 'sandbox')
expect(response.body).not_to include('private-credentials', 'access_token', 'refresh_token')
end
it 'rejects unauthenticated access' do
get path
expect(response).to have_http_status(:unauthorized)
end
it 'returns the authorization date rather than hook creation or token update dates' do
connected_at = 2.days.ago.iso8601
hook.update!(settings: { 'connected_at' => connected_at })
get path, headers: admin.create_new_auth_token
expect(response.parsed_body['connected_at']).to eq(connected_at)
end
it 'reports the stored live environment without exposing credentials' do
hook.update!(settings: { 'livemode' => true })
get path, headers: admin.create_new_auth_token
expect(response.parsed_body).to include('mode' => 'live')
expect(response.body).not_to include('private-credentials', 'access_token', 'refresh_token')
end
it 'prevents agents from reading installation details or connecting' do
headers = agent.create_new_auth_token
get path, headers: headers
expect(response).to have_http_status(:unauthorized)
post "#{path}/auth", headers: headers
expect(response).to have_http_status(:unauthorized)
end
it 'starts OAuth for the current account administrator' do
url = "https://marketplace.stripe.com/test?state=#{'b' * 64}"
expect(Integrations::Stripe::Oauth).to receive(:authorize_url).with(account: account, user: admin).and_return(url)
post "#{path}/auth", headers: admin.create_new_auth_token
expect(response.parsed_body).to eq('url' => url)
end
it 'allows administrators to disconnect only their own hook' do
other = create(:integrations_hook, app_id: 'stripe')
delete path, headers: admin.create_new_auth_token
expect(response).to have_http_status(:no_content)
expect(Integrations::Hook.exists?(hook.id)).to be false
expect(Integrations::Hook.exists?(other.id)).to be true
end
it 'prevents agents from disconnecting' do
delete path, headers: agent.create_new_auth_token
expect(response).to have_http_status(:unauthorized)
expect(hook.reload).to be_present
end
it 'exposes administrator cleanup without enabling billing when the feature is disabled' do
account.disable_features!('stripe_integration')
get "/api/v1/accounts/#{account.id}/integrations/apps", headers: admin.create_new_auth_token
stripe = response.parsed_body.fetch('payload').find { |app| app['id'] == 'stripe' }
expect(stripe).to include('cleanup_only' => true, 'enabled' => false)
get "#{path}/customer", params: { conversation_id: conversation.display_id }, headers: admin.create_new_auth_token
expect(response).to have_http_status(:not_found)
delete path, headers: agent.create_new_auth_token
expect(response).to have_http_status(:unauthorized)
delete path, headers: admin.create_new_auth_token
expect(response).to have_http_status(:no_content)
expect(Integrations::Hook.exists?(hook.id)).to be false
end
it 'uses the authorized conversation contact rather than a supplied email' do
expect(Integrations::Stripe::CustomerSummary).to receive(:new)
.with(connection: instance_of(Integrations::Stripe::Connection), contact: conversation.contact).and_return(summary)
get "#{path}/customer", params: { conversation_id: conversation.display_id, email: 'unrelated@example.com' }, headers: admin.create_new_auth_token
expect(response).to have_http_status(:ok)
end
it 'denies agents without access to the conversation inbox' do
expect(Integrations::Stripe::CustomerSummary).not_to receive(:new)
get "#{path}/customer", params: { conversation_id: conversation.display_id }, headers: agent.create_new_auth_token
expect(response).to have_http_status(:unauthorized)
end
it 'allows agents to read billing details for an accessible conversation' do
create(:inbox_member, inbox: conversation.inbox, user: agent)
allow(Integrations::Stripe::CustomerSummary).to receive(:new).and_return(summary)
get "#{path}/customer", params: { conversation_id: conversation.display_id }, headers: agent.create_new_auth_token
expect(response).to have_http_status(:ok)
end
it 'does not look up a conversation in another account' do
other = create(:conversation)
get "#{path}/customer", params: { conversation_id: other.display_id }, headers: admin.create_new_auth_token
expect(response).to have_http_status(:not_found)
end
it 'returns a safe error on provider failure' do
allow(Integrations::Stripe::CustomerSummary).to receive(:new).and_return(summary)
allow(summary).to receive(:perform).and_raise(Stripe::APIConnectionError.new('sensitive provider details'))
get "#{path}/customer", params: { conversation_id: conversation.display_id }, headers: admin.create_new_auth_token
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body).to eq('error' => 'stripe_unavailable')
end
it 'allows cleanup but not authorization when installation settings are missing' do
allow(Integrations::Stripe::Oauth).to receive(:configured?).and_return(false)
get path, headers: admin.create_new_auth_token
expect(response).to have_http_status(:ok)
post "#{path}/auth", headers: admin.create_new_auth_token
expect(response).to have_http_status(:not_found)
delete path, headers: admin.create_new_auth_token
expect(response).to have_http_status(:no_content)
expect(Integrations::Hook.exists?(hook.id)).to be false
end
end

View File

@@ -0,0 +1,102 @@
require 'rails_helper'
RSpec.describe 'Stripe OAuth callback', type: :request do
let(:account) { create(:account) }
let(:admin) { create(:user, account: account, role: :administrator) }
let(:state) { 'b' * 64 }
let(:token) do
instance_double(OAuth2::AccessToken, token: 'stripe-access', refresh_token: 'stripe-refresh',
params: { 'livemode' => false, 'stripe_user_id' => 'acct_test' })
end
let(:strategy) { instance_double(OAuth2::Strategy::AuthCode, get_token: token) }
let(:client) { instance_double(OAuth2::Client, auth_code: strategy) }
around do |example|
with_modified_env FRONTEND_URL: 'http://www.example.com' do
example.run
end
end
before do
account.enable_features!('stripe_integration')
allow(Integrations::Stripe::Oauth).to receive_messages(configured?: true, client: client, livemode?: false)
allow(Integrations::Stripe::Oauth).to receive(:authorize_url).and_return("https://marketplace.stripe.com/oauth/v2/authorize?state=#{state}")
post "/api/v1/accounts/#{account.id}/integrations/stripe/auth", headers: admin.create_new_auth_token
allow(Integrations::Stripe::Oauth).to receive(:consume_state).with(state)
.and_return('account_id' => account.id, 'user_id' => admin.id, 'livemode' => false)
end
it 'saves OAuth credentials in one integration hook and redirects back' do
get '/stripe/callback', params: { state: state, code: 'authorization-code' }
expect(response).to redirect_to(%r{/app/accounts/#{account.id}/settings/integrations/stripe\z})
hook = account.hooks.find_by!(app_id: 'stripe')
expect(hook.reference_id).to eq('acct_test')
expect(hook.settings).to include('livemode' => false)
expect(JSON.parse(hook.access_token)).to include('access_token' => 'stripe-access', 'refresh_token' => 'stripe-refresh')
end
it 'updates an existing hook instead of creating a duplicate' do
hook = create(:integrations_hook, app_id: 'stripe', account: account, status: :disabled,
settings: { 'connected_at' => 1.month.ago.iso8601 })
previous_connection = hook.settings['connected_at']
expect { get '/stripe/callback', params: { state: state, code: 'authorization-code' } }.not_to change(Integrations::Hook, :count)
expect(hook.reload).to be_enabled
expect(hook.reference_id).to eq('acct_test')
expect(hook.settings['connected_at']).not_to eq(previous_connection)
expect(Time.iso8601(hook.settings['connected_at'])).to be_within(2.seconds).of(Time.current)
end
it 'rejects invalid or already consumed state' do
allow(Integrations::Stripe::Oauth).to receive(:consume_state).with(state).and_return(nil)
expect(strategy).not_to receive(:get_token)
get '/stripe/callback', params: { state: state, code: 'authorization-code' }
expect(response).to have_http_status(:bad_request)
end
it 'rejects installation after the initiating user loses administrator access' do
account.account_users.find_by!(user: admin).update!(role: :agent)
expect(strategy).not_to receive(:get_token)
get '/stripe/callback', params: { state: state, code: 'authorization-code' }
expect(response).to have_http_status(:forbidden)
end
it 'does not exchange a code when consent is denied' do
expect(strategy).not_to receive(:get_token)
get '/stripe/callback', params: { state: state, error: 'access_denied' }
expect(response).to redirect_to(/error=authorization_failed\z/)
expect(account.hooks.where(app_id: 'stripe')).to be_empty
end
it 'rejects live-mode tokens for a sandbox authorization' do
allow(token).to receive(:params).and_return('livemode' => true, 'stripe_user_id' => 'acct_live')
get '/stripe/callback', params: { state: state, code: 'authorization-code' }
expect(response).to redirect_to(/error=authorization_failed\z/)
expect(account.hooks.where(app_id: 'stripe')).to be_empty
end
it 'stores a live connection after live authorization' do
allow(Integrations::Stripe::Oauth).to receive(:livemode?).and_return(true)
allow(Integrations::Stripe::Oauth).to receive(:consume_state).with(state)
.and_return('account_id' => account.id, 'user_id' => admin.id, 'livemode' => true)
allow(token).to receive(:params).and_return('livemode' => true, 'stripe_user_id' => 'acct_live')
get '/stripe/callback', params: { state: state, code: 'authorization-code' }
expect(response).to redirect_to(%r{/app/accounts/#{account.id}/settings/integrations/stripe\z})
expect(account.hooks.find_by!(app_id: 'stripe').settings).to include('livemode' => true)
end
it 'rejects sandbox tokens for a live authorization' do
allow(Integrations::Stripe::Oauth).to receive(:livemode?).and_return(true)
allow(Integrations::Stripe::Oauth).to receive(:consume_state).with(state)
.and_return('account_id' => account.id, 'user_id' => admin.id, 'livemode' => true)
get '/stripe/callback', params: { state: state, code: 'authorization-code' }
expect(response).to redirect_to(/error=authorization_failed\z/)
expect(account.hooks.where(app_id: 'stripe')).to be_empty
end
it 'does not exchange a code after the configured environment changes' do
allow(Integrations::Stripe::Oauth).to receive(:livemode?).and_return(true)
expect(strategy).not_to receive(:get_token)
get '/stripe/callback', params: { state: state, code: 'authorization-code' }
expect(response).to have_http_status(:bad_request)
end
end

View File

@@ -0,0 +1,38 @@
require 'rails_helper'
RSpec.describe SuperAdmin::AppConfigsController, type: :request do
let(:super_admin) { create(:super_admin) }
let(:path) { '/super_admin/app_config?config=stripe' }
it 'requires Super Admin authentication' do
get path
expect(response).to have_http_status(:redirect)
end
it 'exposes Stripe settings with a masked secret field' do
sign_in(super_admin, scope: :super_admin)
get path
expect(response).to have_http_status(:ok)
expect(response.body).to include('Stripe App Authorization URL', 'Stripe App Secret Key')
expect(response.body).to include('type="password"')
end
it 'saves only the allowed Stripe installation settings' do
sign_in(super_admin, scope: :super_admin)
post path, params: { app_config: { STRIPE_APP_AUTHORIZE_URL: 'https://marketplace.stripe.com/oauth/v2/authorize?client_id=ca_test',
STRIPE_APP_SECRET_KEY: 'sk_test_example', UNRELATED_SETTING: 'ignored' } }
expect(response).to have_http_status(:redirect)
expect(InstallationConfig.find_by!(name: 'STRIPE_APP_SECRET_KEY').value).to eq('sk_test_example')
expect(GlobalConfig.get_value('STRIPE_APP_AUTHORIZE_URL')).to eq('https://marketplace.stripe.com/oauth/v2/authorize?client_id=ca_test')
expect(InstallationConfig.exists?(name: 'UNRELATED_SETTING')).to be false
end
it 'rejects malformed settings without persisting either value' do
sign_in(super_admin, scope: :super_admin)
post path, params: { app_config: { STRIPE_APP_AUTHORIZE_URL: 'https://example.com/authorize', STRIPE_APP_SECRET_KEY: 'pk_live_example' } }
expect(response).to redirect_to(super_admin_app_config_path(config: 'stripe'))
expect(flash[:alert]).to include('HTTPS Stripe Marketplace URL', 'sk_test_ or sk_live_')
expect(InstallationConfig.find_by(name: 'STRIPE_APP_SECRET_KEY')&.value).to be_blank
expect(InstallationConfig.find_by(name: 'STRIPE_APP_AUTHORIZE_URL')&.value).to be_blank
end
end

View File

@@ -182,7 +182,8 @@ RSpec.describe Account do
feature_captain_classifier: 1 << 7,
feature_conversation_monitors: 1 << 8,
feature_campaign_analytics: 1 << 9,
feature_company_enrichment: 1 << 10
feature_company_enrichment: 1 << 10,
feature_stripe_integration: 1 << 11
)
expect(described_class.flag_mapping['feature_flags_ext_1'][:feature_whatsapp_manual_transfer]).to eq(1)
expect(described_class.flag_mapping['feature_flags_ext_1'][:feature_data_import]).to eq(2)

View File

@@ -0,0 +1,68 @@
require 'rails_helper'
RSpec.describe Integrations::Stripe::Connection do
let(:credentials) { { access_token: 'existing-access', refresh_token: 'existing-refresh', expires_at: 30.minutes.from_now.to_i } }
let(:hook) { create(:integrations_hook, app_id: 'stripe', access_token: credentials.to_json) }
let(:connection) { described_class.new(hook) }
before do
allow(GlobalConfig).to receive(:get_value).and_call_original
allow(GlobalConfig).to receive(:get_value).with('STRIPE_APP_SECRET_KEY').and_return('sk_test_app')
end
it 'reuses a valid token without calling Stripe' do
expect(connection.api_token).to eq('existing-access')
expect(WebMock).not_to have_requested(:post, 'https://api.stripe.com/v1/oauth/token')
end
it 'refreshes an expiring token and persists the rotated refresh token' do
hook.update!(access_token: credentials.merge(expires_at: 30.seconds.from_now.to_i).to_json)
request = stub_request(:post, 'https://api.stripe.com/v1/oauth/token')
.with(body: hash_including('grant_type' => 'refresh_token', 'refresh_token' => 'existing-refresh'))
.to_return(status: 200, headers: { 'Content-Type' => 'application/json' },
body: { access_token: 'new-access', refresh_token: 'new-refresh', token_type: 'bearer' }.to_json)
expect(connection.api_token).to eq('new-access')
expect(JSON.parse(hook.reload.access_token)).to include('refresh_token' => 'new-refresh')
expect(connection.api_token).to eq('new-access')
expect(request).to have_been_requested.once
end
it 'preserves credentials when refresh fails' do
hook.update!(access_token: credentials.merge(expires_at: 1.minute.ago.to_i).to_json)
original = hook.access_token
stub_request(:post, 'https://api.stripe.com/v1/oauth/token').to_return(status: 400, body: '{"error":"invalid_grant"}')
expect { connection.api_token }.to raise_error(OAuth2::Error)
expect(hook.reload.access_token).to eq(original)
end
it 'refreshes live connections using the live developer key and preserves their environment' do
allow(GlobalConfig).to receive(:get_value).with('STRIPE_APP_SECRET_KEY').and_return('sk_live_app')
hook.update!(settings: { 'livemode' => true }, access_token: credentials.merge(expires_at: 1.minute.ago.to_i).to_json)
request = stub_request(:post, 'https://api.stripe.com/v1/oauth/token')
.with(basic_auth: ['sk_live_app', ''])
.to_return(status: 200, headers: { 'Content-Type' => 'application/json' },
body: { access_token: 'live-access', refresh_token: 'live-refresh', token_type: 'bearer' }.to_json)
expect(connection.api_token).to eq('live-access')
expect(hook.reload.settings).to include('livemode' => true)
expect(request).to have_been_requested.once
end
it 'does not refresh legacy sandbox connections using a live key' do
allow(GlobalConfig).to receive(:get_value).with('STRIPE_APP_SECRET_KEY').and_return('sk_live_app')
hook.update!(access_token: credentials.merge(expires_at: 1.minute.ago.to_i).to_json)
expect { connection.api_token }.to raise_error(Integrations::Stripe::Connection::ReauthorizationRequired, /environment/)
expect(WebMock).not_to have_requested(:post, 'https://api.stripe.com/v1/oauth/token')
expect(JSON.parse(hook.reload.access_token)).to include('refresh_token' => 'existing-refresh')
end
it 'encrypts both tokens at rest' do
skip 'Encryption keys are required for this check' unless Chatwoot.encryption_configured?
token = OAuth2::AccessToken.new(Integrations::Stripe::Oauth.client, 'a' * 128, refresh_token: 'r' * 128)
connection.store_token!(token)
expect(JSON.parse(hook.reload.access_token)).to include('access_token' => 'a' * 128, 'refresh_token' => 'r' * 128)
expect(hook.access_token_before_type_cast).not_to include('a' * 128, 'r' * 128)
hook.update!(access_token: credentials.to_json)
expect(hook.reload.access_token_before_type_cast).not_to include('existing-access', 'existing-refresh')
expect(JSON.parse(hook.access_token)).to include('access_token' => 'existing-access', 'refresh_token' => 'existing-refresh')
end
end

View File

@@ -0,0 +1,102 @@
require 'rails_helper'
RSpec.describe Integrations::Stripe::CustomerSummary do
let(:connection) { instance_double(Integrations::Stripe::Connection, api_token: 'sandbox-token') }
let(:contact) { build(:contact, email: 'alfred@example.com') }
let(:service) { described_class.new(connection: connection, contact: contact) }
let(:customer) { { id: 'cus_alfred', name: 'Alfred', email: contact.email, metadata: { private: 'hidden' } } }
let(:customers) { [customer] }
before do
stub_request(:get, 'https://api.stripe.com/v1/customers/search')
.with(query: { query: "email:#{contact.email.to_json}", limit: 100 }, headers: { 'Authorization' => 'Bearer sandbox-token' })
.to_return(headers: { 'Content-Type' => 'application/json' }, body: { data: customers, has_more: false }.to_json)
stub_request(:get, 'https://api.stripe.com/v1/subscriptions')
.with(query: { customer: 'cus_alfred', status: 'all', limit: 5 })
.to_return(headers: { 'Content-Type' => 'application/json' }, body: { data: [{ id: 'sub_test', status: 'active', metadata: {} }] }.to_json)
stub_request(:get, 'https://api.stripe.com/v1/invoices')
.with(query: { customer: 'cus_alfred', limit: 5 })
.to_return(headers: { 'Content-Type' => 'application/json' },
body: { data: [{ id: 'in_test', total: 1000, currency: 'usd', status: 'paid', metadata: {} }] }.to_json)
end
it 'matches the contact email and returns only sidebar fields' do
result = service.perform
expect(result[:customer]).to eq(customer.except(:metadata))
expect(result[:subscriptions]).to eq([{ id: 'sub_test', status: 'active', items: [], has_more_items: false }])
expect(result[:invoices]).to eq([{ id: 'in_test', total: 1000, currency: 'usd', status: 'paid' }])
end
it 'does not call Stripe when the contact has no email' do
contact.email = nil
expect(connection).not_to receive(:api_token)
expect(service.perform).to eq(customers: [], missing_email: true)
end
it 'includes product pricing and item billing periods without exposing metadata' do
item = { id: 'si_test', quantity: 3, current_period_start: 100, current_period_end: 200,
price: { product: 'prod_test', unit_amount_decimal: '4900', currency: 'usd', billing_scheme: 'per_unit',
recurring: { interval: 'month', interval_count: 1, usage_type: 'licensed' } } }
stub_request(:get, 'https://api.stripe.com/v1/subscriptions')
.with(query: { customer: 'cus_alfred', status: 'all', limit: 5 })
.to_return(headers: { 'Content-Type' => 'application/json' },
body: { data: [{ id: 'sub_test', status: 'active', cancel_at_period_end: true, cancel_at: 200,
items: { data: [item], has_more: false } }] }.to_json)
stub_request(:get, 'https://api.stripe.com/v1/products')
.with(query: { ids: ['prod_test'], limit: 100 })
.to_return(headers: { 'Content-Type' => 'application/json' },
body: { data: [{ id: 'prod_test', name: 'Support Pro', metadata: { private: 'hidden' } }] }.to_json)
subscription = service.perform[:subscriptions].first
expect(subscription).to include(cancel_at_period_end: true, cancel_at: 200)
expect(subscription[:items]).to eq([{ id: 'si_test', name: 'Support Pro', quantity: 3, unit_amount: '4900', currency: 'usd',
billing_scheme: 'per_unit', recurring: item[:price][:recurring],
current_period_start: 100, current_period_end: 200 }])
stub_request(:get, 'https://api.stripe.com/v1/products')
.with(query: { ids: ['prod_test'], limit: 100 })
.to_return(status: 403, headers: { 'Content-Type' => 'application/json' },
body: { error: { type: 'invalid_request_error', message: 'Missing product_read permission' } }.to_json)
expect(service.perform[:subscriptions].first[:items].first).to include(name: nil, unit_amount: '4900')
end
context 'without a matching customer' do
let(:customers) { [] }
it 'returns an empty list without billing details' do
expect(service.perform).to eq(customers: [], has_more_customers: false)
end
end
context 'with duplicate email addresses' do
let(:customers) { [customer, customer.merge(id: 'cus_second')] }
it 'requires an explicit choice' do
expect(service.perform).not_to have_key(:customer)
expect(service.perform(customer_id: 'cus_alfred')[:customer][:id]).to eq('cus_alfred')
end
end
context 'with mixed-case and partial search matches' do
let(:customers) { [customer.merge(email: 'Alfred@Example.COM'), customer.merge(id: 'cus_other', email: 'alfred@example.com.au')] }
it 'matches case-insensitively while rejecting partial email matches' do
result = service.perform
expect(result[:customers].pluck(:id)).to eq(['cus_alfred'])
expect(result[:customer][:id]).to eq('cus_alfred')
expect { service.perform(customer_id: 'cus_other') }.to raise_error(ActiveRecord::RecordNotFound)
end
end
context 'with quotes in the email' do
let(:contact) { build(:contact, email: '"alfred"@example.com') }
it 'escapes the email in the search query' do
expect(service.perform[:customer][:id]).to eq('cus_alfred')
end
end
it 'rejects a customer ID that does not match the contact email' do
expect { service.perform(customer_id: 'cus_unrelated') }.to raise_error(ActiveRecord::RecordNotFound)
end
end

View File

@@ -0,0 +1,102 @@
require 'rails_helper'
RSpec.describe Integrations::Stripe::Oauth do
let(:account) { create(:account) }
let(:user) { create(:user, account: account, role: :administrator) }
let(:authorize_url) { 'https://marketplace.stripe.com/oauth/v2/authorize?client_id=ca_test' }
around do |example|
with_modified_env FRONTEND_URL: 'https://local.example.com' do
example.run
end
end
before do
allow(GlobalConfig).to receive(:get_value).and_call_original
allow(GlobalConfig).to receive(:get_value).with('STRIPE_APP_AUTHORIZE_URL').and_return(authorize_url)
allow(GlobalConfig).to receive(:get_value).with('STRIPE_APP_SECRET_KEY').and_return('sk_test_app')
end
it 'requires both installation settings and encryption' do
allow(Chatwoot).to receive(:encryption_configured?).and_return(true)
expect(described_class.configured?).to be true
allow(GlobalConfig).to receive(:get_value).with('STRIPE_APP_SECRET_KEY').and_return(nil)
expect(described_class.configured?).to be false
end
it 'is unavailable without encryption' do
allow(Chatwoot).to receive(:encryption_configured?).and_return(false)
expect(described_class.configured?).to be false
end
it 'does not advertise malformed settings as configured' do
allow(Chatwoot).to receive(:encryption_configured?).and_return(true)
allow(GlobalConfig).to receive(:get_value).with('STRIPE_APP_AUTHORIZE_URL').and_return('not a URL')
expect(described_class.configured?).to be false
end
it 'allows clearing configuration but rejects incomplete and malformed values' do
expect(described_class.configuration_errors(url: '', key: '')).to be_empty
expect(described_class.configuration_errors(url: authorize_url, key: '')).not_to be_empty
expect(described_class.configuration_errors(url: authorize_url, key: 'sk_live_example')).to be_empty
expect(described_class.configuration_errors(url: 'https://user@marketplace.stripe.com/authorize', key: 'sk_test_example')).not_to be_empty
end
it 'uses the Stripe Apps token endpoint with test and live keys' do
expect(described_class.client.token_url).to eq('https://api.stripe.com/v1/oauth/token')
expect(described_class.livemode?).to be false
allow(GlobalConfig).to receive(:get_value).with('STRIPE_APP_SECRET_KEY').and_return('sk_live_app')
expect(described_class.client.id).to eq('sk_live_app')
expect(described_class.livemode?).to be true
end
it 'rejects non-secret keys' do
allow(GlobalConfig).to receive(:get_value).with('STRIPE_APP_SECRET_KEY').and_return('pk_live_app')
expect { described_class.client }.to raise_error(ArgumentError)
end
it 'preserves install parameters and creates a one-use state bound to the initiating administrator' do
with_modified_env FRONTEND_URL: 'https://local.example.com' do
query = URI.decode_www_form(URI(described_class.authorize_url(account: account, user: user)).query).to_h
expect(query).to include('client_id' => 'ca_test', 'redirect_uri' => 'https://local.example.com/stripe/callback')
expect(query['state']).to match(/\A[0-9a-f]{64}\z/)
expect(described_class.consume_state(query['state'])).to eq('account_id' => account.id, 'user_id' => user.id, 'livemode' => false)
expect(described_class.consume_state(query['state'])).to be_nil
end
end
it 'rejects state consumed by another callback after reading the payload' do
query = URI.decode_www_form(URI(described_class.authorize_url(account: account, user: user)).query).to_h
state = query.fetch('state')
key = "stripe_app:oauth:#{state}"
allow(Redis::Alfred).to receive(:get).with(key).and_wrap_original do |original, *args|
payload = original.call(*args)
Redis::Alfred.delete(key)
payload
end
expect(described_class.consume_state(state)).to be_nil
end
it 'binds live authorizations to live-mode state' do
allow(GlobalConfig).to receive(:get_value).with('STRIPE_APP_SECRET_KEY').and_return('sk_live_app')
query = URI.decode_www_form(URI(described_class.authorize_url(account: account, user: user)).query).to_h
expect(described_class.consume_state(query.fetch('state'))).to include('livemode' => true)
end
it 'rejects malformed and unknown state' do
expect(described_class.consume_state(nil)).to be_nil
expect(described_class.consume_state('invalid')).to be_nil
expect(described_class.consume_state('a' * 64)).to be_nil
end
it 'assigns a ten-minute lifetime to OAuth state' do
expect(Redis::Alfred).to receive(:setex).with(/\Astripe_app:oauth:[0-9a-f]{64}\z/, anything, 600).and_call_original
described_class.authorize_url(account: account, user: user)
end
it 'rejects authorization links outside the Stripe marketplace' do
allow(GlobalConfig).to receive(:get_value).with('STRIPE_APP_AUTHORIZE_URL').and_return('https://example.com/authorize')
expect { described_class.authorize_url(account: account, user: user) }.to raise_error(ArgumentError)
end
end