Skip to content

Commit c02eef4

Browse files
feat(billing): _admin page for viewing Contract information (#109853)
depends on getsentry/getsentry#19461 Creates new internal page for viewing `Contracts`
1 parent da10de4 commit c02eef4

6 files changed

Lines changed: 508 additions & 0 deletions

File tree

static/app/utils/api/knownGetsentryApiUrls.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export type KnownGetsentryApiUrls =
2020
| '/copilot/'
2121
| '/copilot/webhook/'
2222
| '/customers/'
23+
| '/_admin/customers/$organizationIdOrSlug/contract/'
2324
| '/customers/$organizationIdOrSlug/'
2425
| '/customers/$organizationIdOrSlug/balancechanges/'
2526
| '/customers/$organizationIdOrSlug/billing-config/'

static/gsAdmin/routes.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import BillingAdmins from 'admin/views/billingAdmins';
77
import BillingPlans from 'admin/views/billingPlans';
88
import BroadcastDetails from 'admin/views/broadcastDetails';
99
import Broadcasts from 'admin/views/broadcasts';
10+
import CustomerContractDetails from 'admin/views/customerContractDetails';
1011
import CustomerDetails from 'admin/views/customerDetails';
1112
import Customers from 'admin/views/customers';
1213
import CustomerUpgradeRequest from 'admin/views/customerUpgradeRequest';
@@ -93,6 +94,10 @@ function buildRoutes() {
9394
path: 'upgrade-request/',
9495
component: CustomerUpgradeRequest,
9596
},
97+
{
98+
path: 'contract/',
99+
component: CustomerContractDetails,
100+
},
96101
{
97102
path: 'projects/:projectId/',
98103
component: ProjectDetails,

static/gsAdmin/types/index.tsx

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,66 @@ export type Relocation = {
7979
wantOrgSlugs: string[];
8080
wantUsernames: string[];
8181
};
82+
83+
export type ContractDate = {
84+
day?: number;
85+
month?: number;
86+
year?: number;
87+
};
88+
89+
type ContractPricingTier = {
90+
end?: string;
91+
ratePerUnitCpe?: string;
92+
start?: string;
93+
};
94+
95+
export type ContractTieredPricingRate = {
96+
tiers?: ContractPricingTier[];
97+
};
98+
99+
export type ContractSKUConfig = {
100+
basePriceCents?: string;
101+
paygBudgetCents?: string;
102+
paygRate?: ContractTieredPricingRate;
103+
reservedRate?: ContractTieredPricingRate;
104+
reservedVolume?: string;
105+
sku?: string;
106+
};
107+
108+
export type ContractSharedSKUBudget = {
109+
paygBudgetCents?: string;
110+
reservedBudgetCents?: string;
111+
skus?: string[];
112+
};
113+
114+
export type ContractMetadata = {
115+
id?: string;
116+
organizationId?: string;
117+
};
118+
119+
type ContractAddress = {
120+
countryCode?: string;
121+
};
122+
123+
export type ContractBillingConfig = {
124+
address?: ContractAddress;
125+
billingType?: string;
126+
channel?: string;
127+
contractEndDate?: ContractDate;
128+
contractStartDate?: ContractDate;
129+
};
130+
131+
export type ContractPricingConfig = {
132+
basePriceCents?: string;
133+
billingPeriodEndDate?: ContractDate;
134+
billingPeriodStartDate?: ContractDate;
135+
maxSpendCents?: string;
136+
sharedSkuBudgets?: ContractSharedSKUBudget[];
137+
skuConfigs?: ContractSKUConfig[];
138+
};
139+
140+
export type Contract = {
141+
billingConfig?: ContractBillingConfig;
142+
metadata?: ContractMetadata;
143+
pricingConfig?: ContractPricingConfig;
144+
};
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import {render, screen} from 'sentry-test/reactTestingLibrary';
2+
3+
import CustomerContractDetails from 'admin/views/customerContractDetails';
4+
5+
const MOCK_CONTRACT = {
6+
metadata: {id: 'contract-123', organizationId: 'org-456'},
7+
billingConfig: {
8+
billingType: 'BILLING_TYPE_CREDIT_CARD',
9+
channel: 'CHANNEL_SALES',
10+
address: {countryCode: 'US'},
11+
contractStartDate: {year: 2024, month: 1, day: 1},
12+
contractEndDate: {year: 2025, month: 1, day: 1},
13+
},
14+
pricingConfig: {
15+
skuConfigs: [
16+
{
17+
sku: 'SKU_ERRORS',
18+
basePriceCents: '10000',
19+
paygBudgetCents: '5000',
20+
reservedVolume: '100000',
21+
reservedRate: {tiers: [{ratePerUnitCpe: '100'}]},
22+
paygRate: {tiers: [{ratePerUnitCpe: '200'}]},
23+
},
24+
],
25+
sharedSkuBudgets: [],
26+
billingPeriodStartDate: {year: 2024, month: 1, day: 1},
27+
billingPeriodEndDate: {year: 2024, month: 2, day: 1},
28+
maxSpendCents: '50000',
29+
basePriceCents: '10000',
30+
},
31+
};
32+
33+
const ROUTER_CONFIG = {
34+
location: {pathname: '/_admin/customers/test-org/contract/'},
35+
route: '/_admin/customers/:orgId/contract/',
36+
};
37+
38+
describe('CustomerContractDetails', () => {
39+
beforeEach(() => {
40+
MockApiClient.clearMockResponses();
41+
});
42+
43+
it('renders contract data', async () => {
44+
MockApiClient.addMockResponse({
45+
url: '/_admin/customers/test-org/contract/',
46+
body: MOCK_CONTRACT,
47+
});
48+
49+
render(<CustomerContractDetails />, {
50+
initialRouterConfig: ROUTER_CONFIG,
51+
});
52+
53+
expect(await screen.findByText('Contract Overview')).toBeInTheDocument();
54+
55+
expect(screen.getByText('Billing Period:')).toBeInTheDocument();
56+
expect(screen.getByText('Contract Period:')).toBeInTheDocument();
57+
expect(screen.getAllByText('Base Price:').length).toBeGreaterThan(0);
58+
expect(screen.getByText('Max Spend:')).toBeInTheDocument();
59+
expect(screen.getByText('Contract ID:')).toBeInTheDocument();
60+
expect(screen.getByText('Type:')).toBeInTheDocument();
61+
expect(screen.getByText('Channel:')).toBeInTheDocument();
62+
expect(screen.getByText('Billing Country:')).toBeInTheDocument();
63+
64+
expect(screen.getByText('contract-123')).toBeInTheDocument();
65+
expect(screen.getByText('Credit Card')).toBeInTheDocument();
66+
expect(screen.getByText('Sales')).toBeInTheDocument();
67+
expect(screen.getByText('United States')).toBeInTheDocument();
68+
69+
expect(screen.getByText('SKU Pricing')).toBeInTheDocument();
70+
expect(screen.getByText('Errors')).toBeInTheDocument();
71+
expect(screen.getByText('Reserved Volume:')).toBeInTheDocument();
72+
expect(screen.getByText('100,000')).toBeInTheDocument();
73+
expect(screen.getByText('$0.00000100')).toBeInTheDocument();
74+
expect(screen.getByText('$0.00000200')).toBeInTheDocument();
75+
});
76+
77+
it('renders error state', async () => {
78+
MockApiClient.addMockResponse({
79+
url: '/_admin/customers/test-org/contract/',
80+
body: {detail: 'Not found'},
81+
statusCode: 404,
82+
});
83+
84+
render(<CustomerContractDetails />, {
85+
initialRouterConfig: ROUTER_CONFIG,
86+
});
87+
88+
expect(
89+
await screen.findByText('There was an error loading data.')
90+
).toBeInTheDocument();
91+
});
92+
93+
it('renders shared budgets when present', async () => {
94+
const contractWithBudgets = {
95+
...MOCK_CONTRACT,
96+
pricingConfig: {
97+
...MOCK_CONTRACT.pricingConfig,
98+
sharedSkuBudgets: [
99+
{
100+
skus: ['SKU_ERRORS', 'SKU_TRANSACTIONS'],
101+
reservedBudgetCents: '20000',
102+
paygBudgetCents: '10000',
103+
},
104+
],
105+
},
106+
};
107+
108+
MockApiClient.addMockResponse({
109+
url: '/_admin/customers/test-org/contract/',
110+
body: contractWithBudgets,
111+
});
112+
113+
render(<CustomerContractDetails />, {
114+
initialRouterConfig: ROUTER_CONFIG,
115+
});
116+
117+
expect(await screen.findByText('Shared Budgets')).toBeInTheDocument();
118+
expect(screen.getByText('Errors, Transactions')).toBeInTheDocument();
119+
expect(screen.getByText('Reserved Budget:')).toBeInTheDocument();
120+
expect(screen.getAllByText('PAYG Budget:').length).toBeGreaterThan(0);
121+
});
122+
123+
it('handles missing optional fields gracefully', async () => {
124+
const minimalContract = {
125+
metadata: {},
126+
billingConfig: {},
127+
pricingConfig: {},
128+
};
129+
130+
MockApiClient.addMockResponse({
131+
url: '/_admin/customers/test-org/contract/',
132+
body: minimalContract,
133+
});
134+
135+
render(<CustomerContractDetails />, {
136+
initialRouterConfig: ROUTER_CONFIG,
137+
});
138+
139+
expect(await screen.findByText('Contract Overview')).toBeInTheDocument();
140+
expect(screen.getAllByText('N/A').length).toBeGreaterThan(0);
141+
});
142+
});

0 commit comments

Comments
 (0)