src/services/stripe.ts
Rama main · réplica de lectura del repositorio original.
import type { Stripe } from '@stripe/stripe-js'
import { loadStripe } from '@stripe/stripe-js'
import { toast } from 'vue-sonner'
import { i18n } from '~/modules/i18n'
import { invokeCapgoApi } from '~/services/capgoApi'
import { getFormatLocale } from '~/services/formatLocale'
import { stripeAmountToMajorUnits } from '../../supabase/functions/shared/stripe-amount.ts'
interface CheckoutSessionResponse {
url?: string | null
clientSecret?: string | null
publishableKey?: string
sessionId?: string
}
// Embedded sessions mount in Capgo; hosted is only returned by the local Stripe emulator.
export type CheckoutSession
= | { kind: 'embedded', clientSecret: string, publishableKey: string, sessionId: string }
| { kind: 'hosted', url: string }
function toCheckoutSession(data: CheckoutSessionResponse): CheckoutSession | null {
if (data.clientSecret && data.publishableKey && data.sessionId)
return { kind: 'embedded', clientSecret: data.clientSecret, publishableKey: data.publishableKey, sessionId: data.sessionId }
if (data.url)
return { kind: 'hosted', url: data.url }
return null
}
const stripeInstances = new Map<string, Promise<Stripe | null>>()
// One Stripe.js instance per account key (EE and US orgs use different accounts).
export function getStripeJs(publishableKey: string) {
let instance = stripeInstances.get(publishableKey)
if (!instance) {
// Forget failed loads (network error, blocker) so a retry can try again.
instance = loadStripe(publishableKey).then((stripe) => {
if (!stripe)
stripeInstances.delete(publishableKey)
return stripe
}, (error) => {
stripeInstances.delete(publishableKey)
throw error
})
stripeInstances.set(publishableKey, instance)
}
return instance
}
async function getCookieValue(name: string) {
if ('cookieStore' in globalThis && globalThis.cookieStore)
return (await globalThis.cookieStore.get(name))?.value
if (typeof document === 'undefined')
return undefined
const encodedName = `${encodeURIComponent(name)}=`
const cookie = document.cookie.split('; ').find(cookie => cookie.startsWith(encodedName))
return cookie ? decodeURIComponent(cookie.slice(encodedName.length)) : undefined
}
export async function getDatafastAttribution() {
return {
visitorId: await getCookieValue('datafast_visitor_id'),
sessionId: await getCookieValue('datafast_session_id'),
}
}
export async function getAffonsoReferral() {
try {
const referral = await getCookieValue('affonso_referral')
// Stripe metadata values are capped at 500 characters.
return referral && referral.length <= 500 ? referral : ''
}
catch {
return ''
}
}
export interface SubscriptionCheckoutRequest {
priceId: string
successUrl: string
cancelUrl: string
isYear: boolean
orgId: string
extraMau?: number
// One of the currencies returned by private/plan_prices.
currency?: string
// Buy the full price instead of the regional discount.
standardPrice?: boolean
}
export async function startSubscriptionCheckout(request: SubscriptionCheckoutRequest): Promise<CheckoutSession | null> {
const datafastAttribution = await getDatafastAttribution()
const affonsoReferral = await getAffonsoReferral()
const { data, error } = await invokeCapgoApi<CheckoutSessionResponse>('private/stripe_checkout', {
body: JSON.stringify({
priceId: request.priceId,
successUrl: request.successUrl,
cancelUrl: request.cancelUrl,
recurrence: request.isYear ? 'year' : 'month',
orgId: request.orgId,
attributionId: datafastAttribution.visitorId,
datafastVisitorId: datafastAttribution.visitorId,
datafastSessionId: datafastAttribution.sessionId,
affonsoReferral,
embedded: true,
...(request.extraMau ? { extraMau: request.extraMau } : {}),
...(request.currency ? { currency: request.currency } : {}),
...(request.standardPrice ? { standardPrice: true } : {}),
}),
})
if (error || !data) {
console.error('Cannot start subscription checkout', error)
return null
}
return toCheckoutSession(data)
}
export async function startCreditTopUp(orgId: string, quantity = 100): Promise<CheckoutSession> {
const datafastAttribution = await getDatafastAttribution()
const affonsoReferral = await getAffonsoReferral()
const { data, error } = await invokeCapgoApi<CheckoutSessionResponse>('private/credits/start-top-up', {
body: JSON.stringify({
orgId,
quantity,
datafastVisitorId: datafastAttribution.visitorId,
datafastSessionId: datafastAttribution.sessionId,
affonsoReferral,
embedded: true,
}),
})
const session = !error && data ? toCheckoutSession(data) : null
if (!session) {
console.error('Cannot start credit top-up checkout', error ?? data)
toast.error(i18n.global.t('billing-checkout-error'))
throw error ?? new Error('Missing checkout session')
}
return session
}
export async function completeCreditTopUp(orgId: string, sessionId?: string | null) {
if (!orgId)
return null
try {
const { data, error } = await invokeCapgoApi('private/credits/complete-top-up', {
body: JSON.stringify({
orgId,
...(sessionId ? { sessionId } : {}),
}),
})
if (error) {
console.error('Failed to complete credit top-up', error)
throw error
}
return data?.grant ?? null
}
catch (error) {
console.error('Cannot complete credit top-up', error)
toast.error('Cannot finalize credit checkout')
throw error
}
}
export async function getCreditAutoTopUp(orgId: string) {
if (!orgId)
return null
const { data, error } = await invokeCapgoApi(`private/credits/auto-top-up?orgId=${encodeURIComponent(orgId)}`, {
method: 'GET',
})
if (error) {
console.error('Failed to load credit auto top-up', error)
throw error
}
return data as {
enabled: boolean
threshold: number
hasPaymentMethod: boolean
availableCredits: number
monthlyLimit: number
monthlyTotal: number | null
} | null
}
export async function saveCreditAutoTopUp(orgId: string, enabled: boolean, threshold: number, monthlyLimit: number) {
if (!orgId)
return null
const { data, error } = await invokeCapgoApi('private/credits/auto-top-up', {
body: JSON.stringify({ orgId, enabled, threshold, monthlyLimit }),
})
if (error) {
console.error('Failed to save credit auto top-up', error)
throw error
}
return data as {
enabled: boolean
threshold: number
hasPaymentMethod: boolean
availableCredits: number
monthlyLimit: number
monthlyTotal: number | null
} | null
}
interface PastDueInvoice {
hosted_invoice_url: string | null
amount_due: number
currency: string
attempt_count: number
next_payment_attempt: string | null
}
export interface PastDueStatus {
past_due: boolean
invoice: PastDueInvoice | null
}
export async function getPastDueStatus(orgId: string): Promise<PastDueStatus | null> {
if (!orgId)
return null
const { data, error } = await invokeCapgoApi<PastDueStatus>('private/stripe_past_due', {
body: JSON.stringify({ orgId }),
})
if (error || !data)
return null
return data
}
export interface BillingCard {
id: string
brand: string
last4: string
expMonth: number
expYear: number
}
export interface BillingSubscription {
status: string
cancelAtPeriodEnd: boolean
cancelAt: string | null
currentPeriodEnd: string | null
}
export interface BillingInvoice {
id: string
number: string | null
created: string
total: number
amountDue: number
currency: string
status: 'draft' | 'open' | 'paid' | 'uncollectible' | 'void' | null
pdfUrl: string | null
}
// The onboarding plan that starts (and is first charged) when the free trial ends.
export interface TrialPlan {
planName: string
productId: string
amount: number
currency: string
interval: 'month'
startsAt: string
scheduled: boolean
}
// Solo / Maker / Team choices shown when saving a card to auto-start a plan.
export interface TrialPlanOption {
productId: string
planName: string
amount: number
currency: string
// Full price when a regional discount applies (it needs a card and billing address from that region).
regularAmount?: number
}
export interface BillingOverview {
publishableKey: string
card: BillingCard | null
// Charged automatically when the default card fails a renewal.
backupCard: BillingCard | null
subscription: BillingSubscription | null
invoices: BillingInvoice[]
openInvoice: { id: string, amountDue: number, currency: string } | null
trialPlan: TrialPlan | null
}
export const CANCEL_REASONS = [
'too_expensive',
'missing_features',
'switched_service',
'unused',
'too_complex',
'low_quality',
'customer_service',
'other',
] as const
export type CancelReason = typeof CANCEL_REASONS[number]
export interface IntentSecret {
clientSecret: string
publishableKey: string
}
type PayInvoiceResponse
= | { status: 'paid' }
| { status: 'requires_action', clientSecret: string, publishableKey: string }
| { status: 'requires_payment_method', message: string }
export type PayInvoiceResult = Exclude<PayInvoiceResponse, { status: 'requires_action' }>
async function callBilling<T>(path: string, body: Record<string, unknown>) {
const { data, error } = await invokeCapgoApi<T>(`private/stripe_billing/${path}`, {
body: JSON.stringify(body),
})
if (error || !data)
throw error ?? new Error(`Empty billing response for ${path}`)
return data
}
export function getBillingOverview(orgId: string) {
return callBilling<BillingOverview>('overview', { orgId })
}
export function getTrialPlan(orgId: string) {
return callBilling<{ card: BillingCard | null, plan: TrialPlan | null, plans: TrialPlanOption[] }>('trial-plan', { orgId })
}
export async function scheduleTrialPlan(orgId: string, productId?: string) {
const datafastAttribution = await getDatafastAttribution()
const affonsoReferral = await getAffonsoReferral()
return callBilling<{ plan: TrialPlan }>('trial-plan/schedule', {
orgId,
...(productId ? { productId } : {}),
affonsoReferral,
datafastVisitorId: datafastAttribution.visitorId,
datafastSessionId: datafastAttribution.sessionId,
})
}
// Starts the trial plan today: charges the saved card now and restarts the billing cycle.
export async function startTrialPlanNow(orgId: string, productId?: string) {
const datafastAttribution = await getDatafastAttribution()
const affonsoReferral = await getAffonsoReferral()
return callBilling<{ plan: TrialPlan, subscriptionId: string }>('trial-plan/start-now', {
orgId,
...(productId ? { productId } : {}),
affonsoReferral,
datafastVisitorId: datafastAttribution.visitorId,
datafastSessionId: datafastAttribution.sessionId,
})
}
export function cancelTrialPlan(orgId: string) {
return callBilling<{ plan: TrialPlan | null }>('trial-plan/cancel', { orgId })
}
export function createCardSetupIntent(orgId: string) {
return callBilling<IntentSecret>('setup-intent', { orgId })
}
export function getInvoicePaymentIntent(orgId: string) {
return callBilling<IntentSecret>('invoice-payment-intent', { orgId })
}
// intentId is the confirmed SetupIntent (new card) or PaymentIntent (invoice paid with a new card).
export function savePaymentMethod(orgId: string, intentId: string, role: 'default' | 'backup' = 'default') {
return callBilling<{ card: BillingCard | null }>('payment-method', { orgId, intentId, role })
}
export function removeBackupCard(orgId: string, paymentMethodId: string) {
return callBilling<{ card: BillingCard | null, backupCard: BillingCard | null }>('remove-backup-card', { orgId, paymentMethodId })
}
// Discount offered to stay instead of canceling a monthly plan.
export interface CancelSaveOffer {
id: string
percentOff: number
durationMonths: number
// Minor units, like every Stripe amount.
monthlyAmount: number
discountedAmount: number
currency: string
}
export function getCancelSaveOffer(orgId: string, reason: CancelReason) {
return callBilling<{ offer: CancelSaveOffer | null }>('cancel-offer', { orgId, reason })
}
export function acceptCancelSaveOffer(orgId: string, offerId: string) {
return callBilling<{ discountEnd: string, subscription: BillingSubscription | null }>('cancel-offer/accept', { orgId, offerId })
}
// offerId: the save offer the customer turned down before canceling.
export function cancelSubscriptionWithReason(orgId: string, reason: CancelReason, comment: string, offerId?: string) {
return callBilling<{ subscription: BillingSubscription | null }>('cancel', { orgId, reason, comment, ...(offerId ? { offerId } : {}) })
}
export function resumeSubscription(orgId: string) {
return callBilling<{ subscription: BillingSubscription | null }>('resume', { orgId })
}
// Pays the open renewal invoice with the saved card and finishes 3DS in place when asked.
export async function payOpenInvoice(orgId: string): Promise<PayInvoiceResult> {
const result = await callBilling<PayInvoiceResponse>('pay-invoice', { orgId })
if (result.status !== 'requires_action')
return result
const stripe = await getStripeJs(result.publishableKey)
if (!stripe)
return { status: 'requires_payment_method', message: i18n.global.t('billing-stripe-load-error') }
const { error, paymentIntent } = await stripe.handleNextAction({ clientSecret: result.clientSecret })
if (error || paymentIntent?.status !== 'succeeded')
return { status: 'requires_payment_method', message: error?.message ?? i18n.global.t('billing-payment-failed') }
return { status: 'paid' }
}
export function formatStripeAmount(amount: number, currency: string) {
const value = stripeAmountToMajorUnits(amount, currency)
try {
return new Intl.NumberFormat(getFormatLocale(), { style: 'currency', currency: currency.toUpperCase() }).format(value)
}
catch {
return `${value} ${currency.toUpperCase()}`
}
}