Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 12 additions & 92 deletions src/api/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,74 +2,23 @@ import type { TenantPaymentDetails } from 'src/types';

import pLimit from 'p-limit';

import { supabaseClient } from 'src/context/GlobalProviders';
import { FUNCTIONS, invokeSupabase, TABLES } from 'src/services/supabase';
import { formatDateForApi } from 'src/utils/billing-utils';
import { FUNCTIONS, invokeSupabase } from 'src/services/supabase';

const OPERATIONS = {
SETUP_INTENT: 'setup-intent',
GET_TENANT_PAYMENT_METHODS: 'get-tenant-payment-methods',
DELETE_TENANT_PAYMENT_METHODS: 'delete-tenant-payment-method',
SET_PRIMARY: 'set-tenant-primary-payment-method',
GET_TENANT_INVOICE: 'get-tenant-invoice',
};

export interface StripeInvoice {
id: string;
amount_due: number;
invoice_pdf: string;
hosted_invoice_url: string;
status: 'open' | 'paid' | 'void' | 'uncollectable';
}

export const getTenantInvoice = (
tenant: string,
date_start: string,
date_end: string,
type: 'manual' | 'final'
) => {
return invokeSupabase<{ invoice?: StripeInvoice | null }>(
FUNCTIONS.BILLING,
{
operation: OPERATIONS.GET_TENANT_INVOICE,
tenant,
date_start,
date_end,
type,
}
);
};

export const getSetupIntentSecret = (tenant: string) => {
return invokeSupabase<any>(FUNCTIONS.BILLING, {
operation: OPERATIONS.SETUP_INTENT,
tenant,
});
};

export const getTenantPaymentMethods = (tenant: string) => {
// The trial "missing payment method" warning fans this out across the user's
// tenants (see getPaymentMethodsForTenants) — the last edge-function caller.
// The billing admin page reads, sets, and deletes payment methods via GraphQL
// instead (src/api/gql/billing.ts).
const getTenantPaymentMethods = (tenant: string) => {
return invokeSupabase<any>(FUNCTIONS.BILLING, {
operation: OPERATIONS.GET_TENANT_PAYMENT_METHODS,
tenant,
});
};

export const deleteTenantPaymentMethod = (tenant: string, id: string) => {
return invokeSupabase<any>(FUNCTIONS.BILLING, {
operation: OPERATIONS.DELETE_TENANT_PAYMENT_METHODS,
tenant,
id,
});
};

export const setTenantPrimaryPaymentMethod = (tenant: string, id: string) => {
return invokeSupabase<any>(FUNCTIONS.BILLING, {
operation: OPERATIONS.SET_PRIMARY,
tenant,
id,
});
};

export interface InvoiceLineItem {
description: string;
count: number;
Expand All @@ -88,43 +37,14 @@ export interface Invoice {
processed_data_gb: number;
task_usage_hours: number;
};
// Stripe-sourced fields carried on the GQL invoice node. Absent on previews
// and on invoices that haven't been issued in Stripe yet.
status?: string | null;
invoice_pdf?: string | null;
hosted_invoice_url?: string | null;
receipt_url?: string | null;
}

const invoicesQuery = [
'billed_prefix',
'date_start',
'date_end',
'line_items',
'subtotal',
'invoice_type',
'extra',
].join(', ');

export const getInvoicesBetween = (
billed_prefix: string,
date_start: Date,
date_end: Date
) => {
const formattedStart = formatDateForApi(date_start);
const formattedEnd = formatDateForApi(date_end);

return supabaseClient
.from(TABLES.INVOICES_EXT)
.select(invoicesQuery)
.filter('billed_prefix', 'eq', billed_prefix)
.or(
`invoice_type.eq.manual,and(${[
`date_start.gte.${formattedStart}`,
`date_start.lte.${formattedEnd}`,
`date_end.gte.${formattedStart}`,
`date_end.lte.${formattedEnd}`,
].join(',')})`
)
.order('date_start', { ascending: false })
.throwOnError()
.returns<Invoice[]>();
};

export interface MultiplePaymentMethods {
responses: any[];
errors: any[];
Expand Down
109 changes: 109 additions & 0 deletions src/api/gql/billing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { graphql } from 'src/gql-types';

// Upper bound on invoices fetched per tenant. A tenant accrues ~12 invoices a
// year, so this comfortably covers the rolling six-month window the UI shows
// plus any older manual invoices, which the hook filters down client-side.
export const BILLING_INVOICE_FETCH_LIMIT = 100;

// `lineItems` and `extra` are opaque JSON scalars in the schema, so codegen
// types them as `unknown`; the hook casts them to the InvoiceLineItem[] / extra
// shapes the rest of the billing UI already expects. `billed_prefix` is not on
// the node because the tenant is implied by the `tenant(name:)` parent.
export const TENANT_BILLING_INVOICES_QUERY = graphql(`
query TenantBillingInvoices($tenant: String!, $first: Int) {
tenant(name: $tenant) {
billing {
invoices(first: $first) {
nodes {
dateStart
dateEnd
invoiceType
subtotal
lineItems
extra
status
invoicePdf
hostedInvoiceUrl
paymentDetails {
status
receiptUrl
}
}
}
}
}
}
`);

// The full payment-method set for a tenant plus which one is primary. `last4`
// is a String in the schema (zero-padded), and only one of `card` /
// `usBankAccount` is populated per method depending on `type`.
export const TENANT_BILLING_PAYMENT_METHODS_QUERY = graphql(`
query TenantBillingPaymentMethods($tenant: String!) {
tenant(name: $tenant) {
billing {
primaryPaymentMethod {
id
}
paymentMethods {
id
type
billingDetails {
name
}
card {
brand
last4
expMonth
expYear
}
usBankAccount {
bankName
last4
}
}
}
}
}
`);

// Creates the Stripe SetupIntent and returns its client secret; the secret is
// handed to the Stripe Elements form, which collects and confirms the card
// directly with Stripe.
export const CREATE_BILLING_SETUP_INTENT = graphql(`
mutation CreateBillingSetupIntent($tenant: String!) {
createBillingSetupIntent(tenant: $tenant) {
clientSecret
}
}
`);

// Promotes an existing payment method to primary. The list is re-fetched after
// this resolves, so the payload only confirms the new primary.
export const SET_BILLING_PAYMENT_METHOD = graphql(`
mutation SetBillingPaymentMethod($tenant: String!, $paymentMethodId: String!) {
setBillingPaymentMethod(
tenant: $tenant
paymentMethodId: $paymentMethodId
) {
primaryPaymentMethod {
id
}
}
}
`);

// Removes a payment method. As with set-primary, the list is re-fetched after
// this resolves.
export const DELETE_BILLING_PAYMENT_METHOD = graphql(`
mutation DeleteBillingPaymentMethod($tenant: String!, $paymentMethodId: String!) {
deleteBillingPaymentMethod(
tenant: $tenant
paymentMethodId: $paymentMethodId
) {
primaryPaymentMethod {
id
}
}
}
`);
89 changes: 89 additions & 0 deletions src/api/gql/refreshTokens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import type { RefreshTokenInfo } from 'src/gql-types/graphql';

import { useMemo } from 'react';

import { useMutation, useQuery } from 'urql';

import { graphql } from 'src/gql-types';

const DEFAULT_TOKENS: RefreshTokenInfo[] = [];

const REFRESH_TOKENS_PAGE_SIZE = 10;

const REFRESH_TOKENS_QUERY = graphql(`
query RefreshTokens($first: Int, $after: String) {
refreshTokens(first: $first, after: $after) {
edges {
node {
id
detail
createdAt
uses
expired
}
cursor
}
pageInfo {
...PageInfoFields
}
}
}
`);

export function useRefreshTokens(afterCursor?: string) {
const [{ fetching, data, error }] = useQuery({
query: REFRESH_TOKENS_QUERY,
variables: {
first: REFRESH_TOKENS_PAGE_SIZE,
after: afterCursor,
},
});

const refreshTokens = useMemo(
() =>
data?.refreshTokens?.edges?.map((edge) => edge.node) ??
DEFAULT_TOKENS,
[data]
);

const pageInfo = data?.refreshTokens?.pageInfo ?? null;

return {
refreshTokens,
fetching,
error,
pageInfo,
pageSize: REFRESH_TOKENS_PAGE_SIZE,
};
}

const CREATE_REFRESH_TOKEN = graphql(`
mutation CreateRefreshToken(
$detail: String
$multiUse: Boolean!
$validFor: String!
) {
createRefreshToken(
detail: $detail
multiUse: $multiUse
validFor: $validFor
) {
id
secret
}
}
`);

const REVOKE_REFRESH_TOKEN = graphql(`
mutation RevokeRefreshToken($id: Id!) {
revokeRefreshToken(id: $id)
}
`);

export function useCreateRefreshToken() {
return useMutation(CREATE_REFRESH_TOKEN);
}

export function useRevokeRefreshToken() {
return useMutation(REVOKE_REFRESH_TOKEN);
}
Loading