Skip to content

Commit d0b5b87

Browse files
authored
Add invalid billing address notification (#19349)
* Add webhook events * Properly set AutomaticTax * Use address element * 💄 * Update susbcription on address update * Try scroll modal * Fix * try fix modal scroll * Add toast notification * Add invalidBillingAddress column to d_b_stripe_customer * 💄 * 💄 * Fix * Try fix update * Address feedback
1 parent f87f766 commit d0b5b87

File tree

20 files changed

+840
-244
lines changed

20 files changed

+840
-244
lines changed

components/dashboard/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@
1717
"@radix-ui/react-select": "^2.0.0",
1818
"@radix-ui/react-switch": "^1.0.3",
1919
"@radix-ui/react-tooltip": "^1.0.7",
20-
"@stripe/react-stripe-js": "^1.7.2",
21-
"@stripe/stripe-js": "^1.29.0",
20+
"@stripe/react-stripe-js": "^2.4.0",
21+
"@stripe/stripe-js": "^2.4.0",
2222
"@tanstack/query-async-storage-persister": "^4.29.19",
2323
"@tanstack/react-query": "^4.29.19",
2424
"@tanstack/react-query-devtools": "^4.29.19",

components/dashboard/src/AppNotifications.tsx

Lines changed: 55 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ import { trackEvent } from "./Analytics";
1313
import { useUpdateCurrentUserMutation } from "./data/current-user/update-mutation";
1414
import { User as UserProtocol } from "@gitpod/gitpod-protocol";
1515
import { User } from "@gitpod/public-api/lib/gitpod/v1/user_pb";
16+
import { useCurrentOrg } from "./data/organizations/orgs-query";
17+
import { AttributionId } from "@gitpod/gitpod-protocol/lib/attribution";
18+
import { getGitpodService } from "./service/service";
1619

1720
const KEY_APP_DISMISSED_NOTIFICATIONS = "gitpod-app-notifications-dismissed";
1821
const PRIVACY_POLICY_LAST_UPDATED = "2023-12-20";
@@ -59,26 +62,66 @@ const UPDATED_PRIVACY_POLICY = (updateUser: (user: Partial<UserProtocol>) => Pro
5962
} as Notification;
6063
};
6164

65+
const INVALID_BILLING_ADDRESS = () => {
66+
return {
67+
id: "invalid-billing-address",
68+
type: "warning",
69+
preventDismiss: true,
70+
message: (
71+
<span className="text-md">
72+
Your billing address is invalid, taxes (if applicable) won't be calculated. Please update your billing
73+
address.
74+
</span>
75+
),
76+
} as Notification;
77+
};
78+
6279
export function AppNotifications() {
6380
const [topNotification, setTopNotification] = useState<Notification | undefined>(undefined);
6481
const { user, loading } = useUserLoader();
6582
const { mutateAsync } = useUpdateCurrentUserMutation();
6683

84+
const currentOrg = useCurrentOrg().data;
85+
const attrId = currentOrg ? AttributionId.createFromOrganizationId(currentOrg.id) : undefined;
86+
const attributionId = attrId && AttributionId.render(attrId);
87+
6788
useEffect(() => {
68-
const notifications = [];
69-
if (!loading && isGitpodIo()) {
70-
if (
71-
!user?.profile?.acceptedPrivacyPolicyDate ||
72-
new Date(PRIVACY_POLICY_LAST_UPDATED) > new Date(user.profile.acceptedPrivacyPolicyDate)
73-
) {
74-
notifications.push(UPDATED_PRIVACY_POLICY((u: Partial<UserProtocol>) => mutateAsync(u)));
89+
let ignore = false;
90+
91+
const updateNotifications = async () => {
92+
const notifications = [];
93+
if (!loading) {
94+
if (
95+
isGitpodIo() &&
96+
(!user?.profile?.acceptedPrivacyPolicyDate ||
97+
new Date(PRIVACY_POLICY_LAST_UPDATED) > new Date(user.profile.acceptedPrivacyPolicyDate))
98+
) {
99+
notifications.push(UPDATED_PRIVACY_POLICY((u: Partial<UserProtocol>) => mutateAsync(u)));
100+
}
101+
102+
if (attributionId) {
103+
const [subscriptionId, invalidBillingAddress] = await Promise.all([
104+
getGitpodService().server.findStripeSubscriptionId(attributionId),
105+
getGitpodService().server.isCustomerBillingAddressInvalid(attributionId),
106+
]);
107+
if (subscriptionId && invalidBillingAddress) {
108+
notifications.push(INVALID_BILLING_ADDRESS());
109+
}
110+
}
75111
}
76-
}
77112

78-
const dismissedNotifications = getDismissedNotifications();
79-
const topNotification = notifications.find((n) => !dismissedNotifications.includes(n.id));
80-
setTopNotification(topNotification);
81-
}, [loading, mutateAsync, user]);
113+
if (!ignore) {
114+
const dismissedNotifications = getDismissedNotifications();
115+
const topNotification = notifications.find((n) => !dismissedNotifications.includes(n.id));
116+
setTopNotification(topNotification);
117+
}
118+
};
119+
updateNotifications();
120+
121+
return () => {
122+
ignore = true;
123+
};
124+
}, [loading, mutateAsync, user, attributionId]);
82125

83126
const dismissNotification = useCallback(() => {
84127
if (!topNotification) {

components/dashboard/src/components/Modal.tsx

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -77,22 +77,25 @@ export const Modal: FC<Props> = ({
7777
return (
7878
<Portal>
7979
{/* backdrop overlay */}
80-
<div className="fixed top-0 left-0 bg-black bg-opacity-70 z-50 w-screen h-screen focus:ring-0" tabIndex={0}>
80+
<div
81+
className="fixed top-0 left-0 bg-black bg-opacity-70 z-50 h-full w-full overflow-y-auto overflow-x-hidden outline-none focus:ring-0"
82+
tabIndex={0}
83+
>
8184
{/* Modal outer-container for positioning */}
82-
<div className="flex justify-center items-center w-screen h-screen">
85+
<div className="pointer-events-none relative h-[calc(100%-1rem)] w-auto min-[576px]:mx-auto min-[576px]:mt-7 min-[576px]:h-[calc(100%-3.5rem)] min-[576px]:max-w-[500px]">
8386
<FocusOn
8487
autoFocus={autoFocus}
8588
onClickOutside={handleClickOutside}
8689
onEscapeKey={handleEscape}
8790
focusLock={!disableFocusLock}
91+
className="relative max-h-[100%] w-full"
8892
>
8993
{/* Visible Modal */}
9094
<div
9195
className={cn(
92-
"relative flex flex-col max-h-screen max-w-screen",
93-
"w-screen h-screen sm:w-auto sm:h-auto sm:max-w-lg",
96+
"pointer-events-auto max-h-[100%] w-full flex-col overflow-hidden",
97+
"bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 outline-none",
9498
"p-6 text-left",
95-
"bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800",
9699
"filter drop-shadow-xl",
97100
"rounded-none sm:rounded-xl",
98101
className,
@@ -167,17 +170,18 @@ export const ModalHeader: FC<ModalHeaderProps> = ({ children }) => {
167170
type ModalBodyProps = {
168171
children: ReactNode;
169172
hideDivider?: boolean;
170-
noScroll?: boolean;
171173
};
172174

173-
export const ModalBody: FC<ModalBodyProps> = ({ children, hideDivider = false, noScroll = false }) => {
175+
export const ModalBody: FC<ModalBodyProps> = ({ children, hideDivider = false }) => {
174176
return (
175177
// Allows the first tabbable element in the body to receive focus on mount
176178
<AutoFocusInside
177-
className={cn("md:flex-grow relative border-gray-200 dark:border-gray-800 -mx-6 px-6 pb-6", {
178-
"border-t border-b mt-2 py-4": !hideDivider,
179-
"overflow-y-auto": !noScroll,
180-
})}
179+
className={cn(
180+
"md:flex-grow relative border-gray-200 dark:border-gray-800 -mx-6 px-6 pb-6 overflow-y-auto",
181+
{
182+
"border-t border-b mt-2 py-4": !hideDivider,
183+
},
184+
)}
181185
>
182186
{children}
183187
</AutoFocusInside>

components/dashboard/src/components/billing/AddPaymentMethodModal.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
*/
66

77
import { AttributionId } from "@gitpod/gitpod-protocol/lib/attribution";
8-
import { Elements, PaymentElement, useElements, useStripe } from "@stripe/react-stripe-js";
8+
import { Elements, PaymentElement, AddressElement, useElements, useStripe } from "@stripe/react-stripe-js";
99
import { FC, useCallback, useMemo } from "react";
1010
import Modal, { ModalBody, ModalFooter, ModalFooterAlert, ModalHeader } from "../Modal";
1111
import { ReactComponent as Spinner } from "../../icons/Spinner.svg";
@@ -101,6 +101,7 @@ function AddPaymentMethodForm({ attributionId }: { attributionId: string }) {
101101
release in order to verify your payment method.
102102
</Alert>
103103
<PaymentElement id="payment-element" />
104+
<AddressElement id="address-element" options={{ mode: "billing", display: { name: "organization" } }} />
104105
</ModalBody>
105106
<ModalFooter
106107
className="justify-between"

components/gitpod-db/go/stripe_customer.go

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,11 @@ import (
1414
)
1515

1616
type StripeCustomer struct {
17-
StripeCustomerID string `gorm:"primary_key;column:stripeCustomerId;type:char;size:255;" json:"stripeCustomerId"`
18-
AttributionID AttributionID `gorm:"column:attributionId;type:varchar;size:255;" json:"attributionId"`
19-
CreationTime VarcharTime `gorm:"column:creationTime;type:varchar;size:255;" json:"creationTime"`
20-
Currency string `gorm:"column:currency;type:varchar;size:3;" json:"currency"`
17+
StripeCustomerID string `gorm:"primary_key;column:stripeCustomerId;type:char;size:255;" json:"stripeCustomerId"`
18+
AttributionID AttributionID `gorm:"column:attributionId;type:varchar;size:255;" json:"attributionId"`
19+
CreationTime VarcharTime `gorm:"column:creationTime;type:varchar;size:255;" json:"creationTime"`
20+
Currency string `gorm:"column:currency;type:varchar;size:3;" json:"currency"`
21+
InvalidBillingAddress *bool `gorm:"column:invalidBillingAddress;type:tinyint;default:null;" json:"invalidBillingAddress"`
2122

2223
LastModified time.Time `gorm:"->;column:_lastModified;type:timestamp;default:CURRENT_TIMESTAMP(6);" json:"_lastModified"`
2324
// deleted is reserved for use by periodic deleter
@@ -76,3 +77,18 @@ func GetStripeCustomerByAttributionID(ctx context.Context, conn *gorm.DB, attrib
7677

7778
return customer, nil
7879
}
80+
81+
func UpdateStripeCustomerInvalidBillingAddress(ctx context.Context, conn *gorm.DB, stripeCustomerID string, invalidBillingAddress bool) (StripeCustomer, error) {
82+
tx := conn.
83+
WithContext(ctx).
84+
Model(&StripeCustomer{}).
85+
Where("stripeCustomerId = ?", stripeCustomerID).
86+
Where("deleted = ?", 0).
87+
Update("invalidBillingAddress", BoolPointer(invalidBillingAddress))
88+
89+
if err := tx.Error; err != nil {
90+
return StripeCustomer{}, fmt.Errorf("failed to update stripe customer with ID %s: %w", stripeCustomerID, err)
91+
}
92+
93+
return GetStripeCustomer(ctx, conn, stripeCustomerID)
94+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/**
2+
* Copyright (c) 2024 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License.AGPL.txt in the project root for license information.
5+
*/
6+
7+
import { MigrationInterface, QueryRunner } from "typeorm";
8+
import { columnExists } from "./helper/helper";
9+
10+
const TABLE_NAME = "d_b_stripe_customer";
11+
const COLUMN_NAME = "invalidBillingAddress";
12+
13+
export class AddInvalidBillingAddressToStripeCustomer1706212918085 implements MigrationInterface {
14+
public async up(queryRunner: QueryRunner): Promise<void> {
15+
if (!(await columnExists(queryRunner, TABLE_NAME, COLUMN_NAME))) {
16+
await queryRunner.query(
17+
`ALTER TABLE ${TABLE_NAME} ADD COLUMN \`${COLUMN_NAME}\` tinyint(4) NULL DEFAULT NULL, ALGORITHM=INPLACE, LOCK=NONE`,
18+
);
19+
}
20+
}
21+
22+
public async down(queryRunner: QueryRunner): Promise<void> {}
23+
}

components/gitpod-protocol/src/gitpod-service.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,7 @@ export interface GitpodServer extends JsonRpcServer<GitpodClient>, AdminServer,
255255
getCostCenter(attributionId: string): Promise<CostCenterJSON | undefined>;
256256
setUsageLimit(attributionId: string, usageLimit: number): Promise<void>;
257257
getUsageBalance(attributionId: string): Promise<number>;
258+
isCustomerBillingAddressInvalid(attributionId: string): Promise<boolean>;
258259

259260
listUsage(req: ListUsageRequest): Promise<ListUsageResponse>;
260261

components/public-api-server/pkg/billingservice/client.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ type Interface interface {
1919
FinalizeInvoice(ctx context.Context, invoiceId string) error
2020
CancelSubscription(ctx context.Context, subscriptionId string) error
2121
OnChargeDispute(ctx context.Context, disputeID string) error
22+
UpdateCustomerSubscriptionsTaxState(ctx context.Context, customerID string) error
2223
}
2324

2425
type Client struct {
@@ -62,3 +63,14 @@ func (c *Client) OnChargeDispute(ctx context.Context, disputeID string) error {
6263

6364
return nil
6465
}
66+
67+
func (c *Client) UpdateCustomerSubscriptionsTaxState(ctx context.Context, customerID string) error {
68+
_, err := c.b.UpdateCustomerSubscriptionsTaxState(ctx, &v1.UpdateCustomerSubscriptionsTaxStateRequest{
69+
CustomerId: customerID,
70+
})
71+
if err != nil {
72+
return fmt.Errorf("failed RPC to billing service: %s", err)
73+
}
74+
75+
return nil
76+
}

components/public-api-server/pkg/billingservice/mock_billingservice/billingservice.go

Lines changed: 15 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

components/public-api-server/pkg/billingservice/noop.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,7 @@ func (c *NoOpClient) CancelSubscription(ctx context.Context, subscriptionId stri
1919
func (c *NoOpClient) OnChargeDispute(ctx context.Context, disputeID string) error {
2020
return nil
2121
}
22+
23+
func (c *NoOpClient) UpdateCustomerSubscriptionsTaxState(ctx context.Context, customerID string) error {
24+
return nil
25+
}

0 commit comments

Comments
 (0)