mirror of
https://github.com/supabase/supabase.git
synced 2026-10-09 19:35:06 +03:00
## What kind of change does this PR introduce? UI changes for Stripe-managed billing surfaces. - Resolves DEPR-537 - Related to DEPR-538 ## What is the current behaviour? Stripe-connected organisations still look too self-serve in Studio. - Payment Methods still reads mostly like ordinary Supabase card management, even though billing is handled through a Shared Payment Token via Stripe Projects - invoice messaging still implies support is the path to changing payment methods, even for Stripe-managed orgs - the Subscription Plan flow still needs Stripe-specific guardrails so users are redirected to the correct upgrade path rather than trying to self-serve everything in Studio - the base branch now correctly separates `integration_source` from `billing_partner`, but this stacked work still needs to carry that split through the Stripe billing-token surfaces ## What is the new behaviour? This PR makes the Stripe-managed billing surfaces behave like Stripe-managed billing surfaces, while leaving AWS and Vercel on the existing `billing_partner` path. - Payment Methods now keeps the familiar saved-card row, but augments Stripe-managed rows with Shared Payment Token context, token status, and Stripe Projects affordances - Stripe-managed invoice messaging now points users to Stripe Projects rather than to support for payment-method changes - the Subscription Plan flow keeps the existing managed-billing shape, with Stripe-specific guardrails layered in where plan changes should be handled outside Studio - AWS and Vercel continue to use the existing partner-managed alerts and CTAs driven by `billing_partner` / `billing_via_partner` | Subscription plan sheet | | --- | | <img width="1780" height="448" alt="CleanShot 2026-04-24 at 17 21 43@2x" src="https://github.com/user-attachments/assets/34c0f3ba-fc42-4d07-97a2-0e4f4cefc55e" /> | | _Upgrade instructions_ | | <img width="1786" height="460" alt="CleanShot 2026-04-24 at 17 20 12@2x" src="https://github.com/user-attachments/assets/bb67c835-b9b2-4648-b0e1-9c2f8d2317d3" /> | | _Downgrade instructions_ | > [!NOTE] > The below screenshots are outdated. The _Shared Payment Token_ terminology has been removed in favour of more generic copy such as _Stripe Projects token_. | Stripe payment method states | | --- | | <img width="1436" height="234" alt="CleanShot 2026-04-23 at 19 03 49@2x" src="https://github.com/user-attachments/assets/52ed7a00-dfba-4b66-9a07-a6346692d3c8" /> | | _Healthy_ | | <img width="1434" height="224" alt="CleanShot 2026-04-23 at 19 04 50@2x" src="https://github.com/user-attachments/assets/94efd943-b7bf-4da2-9e1b-1828aae97126" /> | | _Card expiring soon_ | | <img width="1436" height="236" alt="CleanShot 2026-04-23 at 19 06 51@2x" src="https://github.com/user-attachments/assets/272cb707-c724-4629-890e-853972e53a18" /> | | _Card expired_ | | <img width="1308" height="238" alt="CleanShot 2026-04-23 at 19 07 21@2x" src="https://github.com/user-attachments/assets/3eadd2a9-def3-4f43-850e-7d82adfb0b57" /> | | _Token expired_ | ## Dependencies This PR is stacked on: - #44328 It also depends on the private platform work that exposes Stripe project connection state and SPT details: - https://github.com/supabase/platform/pull/31874 - https://github.com/supabase/platform/pull/31940 ## Platform dependency status Most of the remaining platform work for this stack is now covered by the private dependency below: - https://github.com/supabase/platform/pull/31940 That PR is expected to provide the SPT details and paid-flow fixes this Studio work depends on. In practice, the main caveat here is less “Studio still needs a bunch of new platform work” and more “do not merge this until `platform#31940` has landed and the end-to-end Stripe-managed flow has been rechecked”. ## Local testing Use the same local Stripe setup as the base branch, with `integration_source: 'stripe_projects'` returned consistently for: - `/platform/organizations` - `/platform/organizations/:slug/projects` - `/platform/projects/:ref` For payment method demos, the temporary local mock currently lives in private `platform` on: - `/platform/organizations/:slug/payments` That mock can be flipped between: - healthy token + healthy underlying card - healthy token + card expiring soon - healthy token + expired card - expired token Then verify: - the org and project connection affordances from #44328 still render correctly - Payment Methods shows Stripe-managed token context rather than implying ordinary self-serve card management - regression test ordinary non-Stripe payment methods too, to confirm the standard saved-card row still renders with the existing `Expires:` copy and no Shared Payment Token affordances - invoice messaging points Stripe-managed orgs to Stripe Projects rather than support - Subscription Plan keeps the managed-billing guardrails for Stripe - AWS and Vercel orgs still show the existing partner-managed messaging rather than the Stripe-specific notices <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Stripe-managed organizations show Stripe Projects billing guidance, replace in-app payment management with Stripe links, and adjust billing copy. * Payment methods support Shared Payment Tokens (SPTs): token expiry/status badges with tooltips, “Handled via Stripe Projects” indicator, token last4/expiry display, and disabled local update/delete actions for SPTs. * **API** * Payments response now includes optional shared payment token details for payment methods. * **Documentation** * Added links to Stripe Projects billing docs in relevant flows. * **Tests** * Updated and added tests covering Stripe-managed and SPT behaviors. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Raúl Barroso <code@raulb.dev>
197 lines
5.8 KiB
TypeScript
197 lines
5.8 KiB
TypeScript
import { screen } from '@testing-library/react'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
import PaymentMethods from './PaymentMethods'
|
|
import { MANAGED_BY } from '@/lib/constants/infrastructure'
|
|
import { createMockOrganization, render } from '@/tests/helpers'
|
|
|
|
const { mockSelectedOrganization, mockPaymentMethodsQuery, mockSubscription } = vi.hoisted(() => ({
|
|
mockSelectedOrganization: vi.fn(),
|
|
mockPaymentMethodsQuery: vi.fn(),
|
|
mockSubscription: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('common', async (importOriginal) => {
|
|
const original = (await importOriginal()) as typeof import('common')
|
|
return {
|
|
...original,
|
|
useParams: () => ({ slug: 'stripe-org' }),
|
|
}
|
|
})
|
|
|
|
vi.mock('@/hooks/misc/useSelectedOrganization', () => ({
|
|
useSelectedOrganizationQuery: () => ({ data: mockSelectedOrganization() }),
|
|
}))
|
|
|
|
vi.mock('@/data/subscriptions/org-subscription-query', () => ({
|
|
useOrgSubscriptionQuery: () => ({ data: mockSubscription() }),
|
|
}))
|
|
|
|
vi.mock('@/data/organizations/organization-payment-methods-query', () => ({
|
|
useOrganizationPaymentMethodsQuery: () => mockPaymentMethodsQuery(),
|
|
}))
|
|
|
|
vi.mock('@/hooks/misc/useCheckPermissions', () => ({
|
|
useAsyncCheckPermissions: () => ({ can: true, isSuccess: true }),
|
|
}))
|
|
|
|
vi.mock('@/components/ui/PartnerManagedResource', () => ({
|
|
default: () => <div data-testid="partner-managed-resource" />,
|
|
}))
|
|
|
|
vi.mock('@/components/interfaces/Billing/Payment/AddNewPaymentMethodModal', () => ({
|
|
default: () => null,
|
|
}))
|
|
|
|
vi.mock('./ChangePaymentMethodModal', () => ({
|
|
default: () => null,
|
|
}))
|
|
|
|
vi.mock('./DeletePaymentMethodModal', () => ({
|
|
default: () => null,
|
|
}))
|
|
|
|
describe('PaymentMethods', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
mockSelectedOrganization.mockReturnValue(
|
|
createMockOrganization({
|
|
slug: 'stripe-org',
|
|
billing_partner: null,
|
|
integration_source: 'stripe_projects',
|
|
managed_by: MANAGED_BY.STRIPE_PROJECTS,
|
|
})
|
|
)
|
|
mockSubscription.mockReturnValue({
|
|
payment_method_type: 'card',
|
|
plan: { id: 'pro', name: 'Pro' },
|
|
})
|
|
mockPaymentMethodsQuery.mockReturnValue({
|
|
data: { data: [] },
|
|
error: null,
|
|
isPending: false,
|
|
isError: false,
|
|
isSuccess: true,
|
|
})
|
|
})
|
|
|
|
it('does not treat Stripe-connected orgs as partner-billed', () => {
|
|
render(<PaymentMethods />)
|
|
|
|
expect(screen.queryByTestId('partner-managed-resource')).not.toBeInTheDocument()
|
|
expect(screen.getByText('No payment methods')).toBeInTheDocument()
|
|
})
|
|
|
|
it('uses Stripe Projects guidance instead of support for invoice-based Stripe orgs', () => {
|
|
mockSubscription.mockReturnValue({
|
|
payment_method_type: 'invoice',
|
|
plan: { id: 'pro', name: 'Pro' },
|
|
})
|
|
|
|
render(<PaymentMethods />)
|
|
|
|
expect(screen.getByText('Payment is currently by invoice')).toBeInTheDocument()
|
|
expect(screen.getByText(/Manage payment methods through Stripe Projects/)).toBeInTheDocument()
|
|
expect(
|
|
screen.getByRole('link', {
|
|
name: 'View Stripe Projects docs',
|
|
})
|
|
).toBeInTheDocument()
|
|
expect(screen.queryByText('Contact support')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('still shows the partner-managed fallback for billing partners', () => {
|
|
mockSelectedOrganization.mockReturnValue(
|
|
createMockOrganization({
|
|
slug: 'aws-org',
|
|
billing_partner: 'aws_marketplace',
|
|
managed_by: MANAGED_BY.AWS_MARKETPLACE,
|
|
})
|
|
)
|
|
|
|
render(<PaymentMethods />)
|
|
|
|
expect(screen.getByTestId('partner-managed-resource')).toBeInTheDocument()
|
|
})
|
|
|
|
it('keeps the standard card row copy for non-Stripe payment methods', () => {
|
|
mockSelectedOrganization.mockReturnValue(
|
|
createMockOrganization({
|
|
slug: 'self-serve-org',
|
|
billing_partner: null,
|
|
integration_source: null,
|
|
managed_by: MANAGED_BY.SUPABASE,
|
|
})
|
|
)
|
|
mockPaymentMethodsQuery.mockReturnValue({
|
|
data: {
|
|
data: [
|
|
{
|
|
id: 'pm_123',
|
|
type: 'card',
|
|
is_default: true,
|
|
has_address: true,
|
|
created: 1_776_700_000,
|
|
card: {
|
|
brand: 'visa',
|
|
exp_month: 12,
|
|
exp_year: 2028,
|
|
last4: '4242',
|
|
},
|
|
shared_payment_token: null,
|
|
},
|
|
],
|
|
},
|
|
error: null,
|
|
isPending: false,
|
|
isError: false,
|
|
isSuccess: true,
|
|
})
|
|
|
|
render(<PaymentMethods />)
|
|
|
|
expect(screen.getByText('Expires: 12/2028')).toBeInTheDocument()
|
|
expect(screen.queryByText(/Managed via Stripe Projects/i)).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('shows Stripe Projects payment method details separately from the underlying card', () => {
|
|
mockPaymentMethodsQuery.mockReturnValue({
|
|
data: {
|
|
data: [
|
|
{
|
|
id: 'spt._1T0w6VJDPojXS6LNK3clVOLZ',
|
|
type: 'shared_payment_token',
|
|
is_default: true,
|
|
has_address: true,
|
|
created: 1_776_700_000,
|
|
card: {
|
|
brand: 'visa',
|
|
exp_month: 12,
|
|
exp_year: 2028,
|
|
last4: '4242',
|
|
},
|
|
shared_payment_token: {
|
|
last4: 'VOLZ',
|
|
expires_at: 1_860_883_200,
|
|
is_expired: false,
|
|
},
|
|
},
|
|
],
|
|
},
|
|
error: null,
|
|
isPending: false,
|
|
isError: false,
|
|
isSuccess: true,
|
|
})
|
|
|
|
render(<PaymentMethods />)
|
|
|
|
expect(screen.getByText('**** **** **** 4242')).toBeInTheDocument()
|
|
expect(screen.getByText('Expires: 12/2028')).toBeInTheDocument()
|
|
expect(screen.getByText(/Managed via Stripe Projects/i)).toBeInTheDocument()
|
|
expect(screen.getByText('VOLZ')).toBeInTheDocument()
|
|
expect(screen.getByText(/Expires 12\/2028/i, { selector: 'span' })).toBeInTheDocument()
|
|
expect(screen.getByText('Active')).toBeInTheDocument()
|
|
})
|
|
})
|