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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
71
app/controllers/stripe/callbacks_controller.rb
Normal file
71
app/controllers/stripe/callbacks_controller.rb
Normal 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
|
||||
@@ -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],
|
||||
|
||||
@@ -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'
|
||||
|
||||
25
app/javascript/dashboard/api/integrations/stripe.js
Normal file
25
app/javascript/dashboard/api/integrations/stripe.js
Normal 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();
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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' },
|
||||
]);
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
30
app/javascript/dashboard/helper/specs/stripeHelper.spec.js
Normal file
30
app/javascript/dashboard/helper/specs/stripeHelper.spec.js
Normal 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)
|
||||
);
|
||||
});
|
||||
});
|
||||
49
app/javascript/dashboard/helper/stripeHelper.js
Normal file
49
app/javascript/dashboard/helper/stripeHelper.js
Normal 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);
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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')"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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: {} }));
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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,
|
||||
|
||||
46
app/services/integrations/stripe/connection.rb
Normal file
46
app/services/integrations/stripe/connection.rb
Normal 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
|
||||
74
app/services/integrations/stripe/customer_summary.rb
Normal file
74
app/services/integrations/stripe/customer_summary.rb
Normal 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
|
||||
72
app/services/integrations/stripe/oauth.rb
Normal file
72
app/services/integrations/stripe/oauth.rb
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
9
public/dashboard/images/integrations/stripe-dark.svg
Normal file
9
public/dashboard/images/integrations/stripe-dark.svg
Normal 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 |
9
public/dashboard/images/integrations/stripe.svg
Normal file
9
public/dashboard/images/integrations/stripe.svg
Normal 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 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
102
spec/controllers/stripe/callbacks_controller_spec.rb
Normal file
102
spec/controllers/stripe/callbacks_controller_spec.rb
Normal 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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
68
spec/services/integrations/stripe/connection_spec.rb
Normal file
68
spec/services/integrations/stripe/connection_spec.rb
Normal 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
|
||||
102
spec/services/integrations/stripe/customer_summary_spec.rb
Normal file
102
spec/services/integrations/stripe/customer_summary_spec.rb
Normal 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
|
||||
102
spec/services/integrations/stripe/oauth_spec.rb
Normal file
102
spec/services/integrations/stripe/oauth_spec.rb
Normal 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
|
||||
Reference in New Issue
Block a user