Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update stripe extension #15265

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open

Conversation

bradgarropy
Copy link

@bradgarropy bradgarropy commented Nov 7, 2024

Description

Hey @pradeepb28 and @farisaziz12,
I'm @bradgarropy and I work at Stripe. I ported over our internal Fill Checkout command to this public Raycast extension. We thought it would be good to spread this to other developers!

Screencast

fill-checkout.mov

fill-checkout
cards
card-search
card-actions

Checklist

- update changelog.
- implement the fill checkout command. GIT_VALID_PII_OVERRIDE
- add bradgarropy as a contributor. remove react dev tools. add the fill checkout command.
- Initial commit
@raycastbot raycastbot added extension fix / improvement Label for PRs with extension's fix improvements extension: stripe Issues related to the stripe extension labels Nov 7, 2024
@raycastbot
Copy link
Collaborator

raycastbot commented Nov 7, 2024

Thank you for your contribution! 🎉

🔔 @pradeepb28 @farisaziz12 you might want to have a look.

You can use this guide to learn how to check out the Pull Request locally in order to test it.

You can expect an initial review within five business days.

@bradgarropy
Copy link
Author

The checks are failing due to some type errors that were present in the application before my changes.

Would y'all like me to fix those as well?

Copy link
Contributor

@farisaziz12 farisaziz12 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this awesome addition @bradgarropy! Really cool to see someone from the Stripe team introduce new functionality to the Raycast extension.

I haven't touched the extension in a while, so I'm not sure where the type errors have originated from but I'm entirely happy for you to fix them or I can make a commit to your branch with the required changes to get the CI passing. Whatever is easiest 😄

Other than that all looks good to me, I just left a few non-blocking comments as food for thought.

extensions/stripe/package.json Show resolved Hide resolved
extensions/stripe/src/fill-checkout.tsx Outdated Show resolved Hide resolved
return expiryDate;
};

const createAppleScript = (card: Card) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thought:

This works great while Raycast is consistently used in the MacOS ecosystem, this might be something we need to revisit down the line when Raycast comes to Windows. We can go with this approach for now.

@farisaziz12
Copy link
Contributor

Thanks for this awesome addition @bradgarropy! Really cool to see someone from the Stripe team introduce new functionality to the Raycast extension.

I haven't touched the extension in a while, so I'm not sure where the type errors have originated from but I'm entirely happy for you to fix them or I can make a commit to your branch with the required changes to get the CI passing. Whatever is easiest 😄

Other than that all looks good to me, I just left a few non-blocking comments as food for thought.

@bradgarropy I went ahead and adjusted the code to fix the TS issues and ran the build locally on my end to validate all is good. I don't believe I have write access to your fork so feel free to copy the below code for the respective files to fix the issue:

extensions/stripe/src/connected-accounts.tsx

import React from "react";
import { Action, ActionPanel, Color, Icon, List } from "@raycast/api";
import omit from "lodash/omit";
import { useStripeApi, useStripeDashboard } from "./hooks";
import { convertTimestampToDate, titleCase, resolveMetadataValue } from "./utils";
import { STRIPE_ENDPOINTS } from "./enums";
import { ListContainer, withEnvContext } from "./components";

type ConnectedAccountResp = {
  id: string;
  created: number;
  cancelled_at: number | null;
  currency: string;
  status: string;
  business_profile: any;
  capabilities: any;
  default_currency: string;
  email: string;
  company: {
    address: {
      city: string | null;
      country: string | null;
      line1: string | null;
      line2: string | null;
      postal_code: string | null;
      state: string | null;
    };
  };
  individual: {
    first_name: string;
    last_name: string;
    dob: {
      day: number;
      month: number;
      year: number;
    };
  };
};

type ConnectedAccount = {
  id: string;
  email: string;
  created_at: string;
  first_name: string;
  last_name: string;
  cancelled_at: string;
  currency: string;
  capabilities: string;
  default_currency: string;
  company_address: string;
  dob: string;
};

const omittedFields = ["client_secret"];

const resolveConnectedAccount = ({
  currency = "",
  default_currency = "",
  created,
  cancelled_at,
  ...rest
}: ConnectedAccountResp): ConnectedAccount => {
  const { month, year, day } = rest.individual?.dob ?? {};
  const dateOfBirth = day && month && year ? `${day}/${month}/${year}` : "";
  const { city, country, line1, postal_code, state } = rest.company?.address ?? {};
  const companyAddress = [line1, city, state, postal_code, country].filter(Boolean).join(", ");

  return {
    ...rest,
    currency: currency.toUpperCase(),
    default_currency: default_currency.toUpperCase(),
    created_at: convertTimestampToDate(created),
    cancelled_at: convertTimestampToDate(cancelled_at),
    dob: dateOfBirth,
    company_address: companyAddress,
    capabilities: Object.keys(rest.capabilities ?? {}).join(", "),
    first_name: titleCase(rest.individual?.first_name ?? ""),
    last_name: titleCase(rest.individual?.last_name ?? ""),
    email: rest?.email ?? "",
  };
};

const ConnectedAccounts = () => {
  const { isLoading, data } = useStripeApi(STRIPE_ENDPOINTS.CONNECTED_ACCOUNTS, true);
  const { dashboardUrl } = useStripeDashboard();
  const formattedConnectedAccounts = data.map(resolveConnectedAccount);

  const renderConnectedAccounts = (connectedAccount: ConnectedAccount) => {
    const { email, id } = connectedAccount;
    const fields = omit(connectedAccount, omittedFields);

    return (
      <List.Item
        key={id}
        title={email}
        icon={{ source: Icon.Person, tintColor: Color.PrimaryText }}
        actions={
          <ActionPanel title="Actions">
            <Action.OpenInBrowser title="View Connected Account" url={`${dashboardUrl}/connect/accounts/${id}`} />
            <Action.CopyToClipboard title="Copy Connected Account ID" content={id} />
            <Action.CopyToClipboard title="Copy Connected Account Email" content={email} />
          </ActionPanel>
        }
        detail={
          <List.Item.Detail
            metadata={
              <List.Item.Detail.Metadata>
                <List.Item.Detail.Metadata.Label title="Metadata" />
                <List.Item.Detail.Metadata.Separator />
                {Object.entries(fields).map(([type, value]) => {
                  const resolvedValue = resolveMetadataValue(value);
                  if (!resolvedValue) return null;

                  return <List.Item.Detail.Metadata.Label key={type} title={titleCase(type)} text={resolvedValue} />;
                })}
              </List.Item.Detail.Metadata>
            }
          />
        }
      />
    );
  };

  return (
    <ListContainer isLoading={isLoading} isShowingDetail={!isLoading}>
      <List.Section title="Connected Accounts">{formattedConnectedAccounts.map(renderConnectedAccounts)}</List.Section>
    </ListContainer>
  );
};

export default withEnvContext(ConnectedAccounts);

extensions/stripe/src/charges.tsx

import React from "react";
import { Action, ActionPanel, Color, Icon, List } from "@raycast/api";
import omit from "lodash/omit";
import { useStripeApi, useStripeDashboard } from "./hooks";
import { STRIPE_ENDPOINTS } from "./enums";
import { convertAmount, convertTimestampToDate, titleCase, resolveMetadataValue } from "./utils";
import { ListContainer, withEnvContext } from "./components";

type ChargeResp = {
  id: string;
  amount: number;
  amount_captured: number;
  amount_refunded: number;
  payment_intent: string;
  created: number;
  currency: string;
  description: string | null;
  status: string;
  receipt_url: string | null;
  billing_details: {
    address: {
      city: string | null;
      country: string | null;
      line1: string | null;
      line2: string | null;
      postal_code: string | null;
      state: string | null;
    };
    email: string | null;
    name: string | null;
    phone: string | null;
  };
  payment_method_details: {
    card: {
      brand: string | null;
      country: string | null;
      exp_month: number;
      exp_year: number;
      last4: string;
      network: string | null;
      three_d_secure: null;
    };
    type: string;
  };
};

type Charge = {
  id: string;
  amount: number;
  amount_captured: number;
  amount_refunded: number;
  payment_intent: string;
  created_at: string;
  currency: string;
  description: string | null;
  receipt_url: string | null;
  billing_address: string;
  billing_email: string;
  billing_name: string;
  payment_method_type: string;
  payment_method_brand: string;
  payment_method_last4: string;
  payment_method_exp_month: string;
  payment_method_exp_year: string;
  payment_method_network: string;
  payment_method_three_d_secure: boolean;
  payment_method_country: string;
};

const omittedFields = ["receipt_url"];

const resolveCharge = ({
  amount = 0,
  amount_captured = 0,
  amount_refunded = 0,
  currency = "",
  description = "",
  created,
  ...rest
}: ChargeResp): Charge => {
  const uppercaseCurrency = currency.toUpperCase();
  const billingDetails = rest.billing_details ?? {};
  const billingAddressObj = billingDetails.address ?? {};
  const paymentMethodDetails = rest.payment_method_details ?? {};
  const paymentMethodCard = paymentMethodDetails.card ?? {};
  const { city, country, line1, postal_code, state } = billingAddressObj;
  const billingAddress = [line1, city, state, postal_code, country].filter(Boolean).join(", ");

  return {
    ...rest,
    amount_captured: convertAmount(amount_captured),
    amount_refunded: convertAmount(amount_refunded),
    amount: convertAmount(amount),
    currency: uppercaseCurrency,
    description,
    created_at: convertTimestampToDate(created),
    billing_address: billingAddress,
    billing_email: billingDetails.email ?? "",
    billing_name: billingDetails.name ?? "",
    payment_method_type: paymentMethodDetails.type ?? "",
    payment_method_brand: paymentMethodCard.brand ?? "",
    payment_method_last4: paymentMethodCard.last4 ?? "",
    payment_method_exp_month: String(paymentMethodCard.exp_month ?? ""),
    payment_method_exp_year: String(paymentMethodCard.exp_year ?? ""),
    payment_method_network: paymentMethodCard.network ?? "",
    payment_method_three_d_secure: paymentMethodCard.three_d_secure ?? false,
    payment_method_country: paymentMethodCard.country ?? "",
  };
};

const Charges = () => {
  const { isLoading, data } = useStripeApi(STRIPE_ENDPOINTS.CHARGES, true);
  const { dashboardUrl } = useStripeDashboard();
  const formattedCharges = data.map(resolveCharge);

  const renderCharges = (charge: Charge) => {
    const { amount, currency, id, receipt_url, payment_intent } = charge;
    const fields = omit(charge, omittedFields);

    return (
      <List.Item
        key={id}
        title={`${currency} ${amount}`}
        icon={{ source: Icon.CreditCard, tintColor: Color.Red }}
        actions={
          <ActionPanel title="Actions">
            {payment_intent && (
              <Action.OpenInBrowser title="View Payment Intent" url={`${dashboardUrl}/payments/${payment_intent}`} />
            )}
            {receipt_url && <Action.OpenInBrowser title="View Receipt" url={receipt_url} />}
            <Action.CopyToClipboard title="Copy Charge ID" content={id} />
          </ActionPanel>
        }
        detail={
          <List.Item.Detail
            metadata={
              <List.Item.Detail.Metadata>
                <List.Item.Detail.Metadata.Label title="Metadata" />
                <List.Item.Detail.Metadata.Separator />
                {Object.entries(fields).map(([type, value]) => {
                  const resolvedValue = resolveMetadataValue(value);
                  if (!resolvedValue) return null;

                  return <List.Item.Detail.Metadata.Label key={type} title={titleCase(type)} text={resolvedValue} />;
                })}
              </List.Item.Detail.Metadata>
            }
          />
        }
      />
    );
  };

  return (
    <ListContainer isLoading={isLoading} isShowingDetail={!isLoading}>
      <List.Section title="Charges">{formattedCharges.map(renderCharges)}</List.Section>
    </ListContainer>
  );
};

export default withEnvContext(Charges);

@farisaziz12
Copy link
Contributor

Thoughts on a UX improvement if you would like to go the extra mile 😉

Currently, with each command of the extension, you can individually set the environment to test or live. If I set test as the env for the first command that I use, upon opening a second command to use the environment I previously selected does not carry over (e.g. first command I set to test, second command opens with env as live).

From what I remember, at the time of this extension's creation, we didn't have APIs to work around this as commands are their own individual views rather than being routed from a parent component, which means they could not be wrapped in a React context globally for example. We could use the newer Cache API to introduce saved environments across all commands since cache is shared across all commands. Happy to do this myself in a future PR but noting down the thought in this PR as it came up during my testing of the new functionality

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
extension fix / improvement Label for PRs with extension's fix improvements extension: stripe Issues related to the stripe extension
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants