Skip to content

Commit

Permalink
Feature/stripe backend (estuary#1013)
Browse files Browse the repository at this point in the history
  • Loading branch information
jshearer authored Apr 26, 2023
1 parent c537b45 commit 13768f2
Show file tree
Hide file tree
Showing 14 changed files with 328 additions and 36 deletions.
5 changes: 5 additions & 0 deletions .vscode/extensions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"recommendations": [
"denoland.vscode-deno"
]
}
65 changes: 36 additions & 29 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,30 +1,37 @@
{
"files.watcherExclude": {
"**/.build/**": true,
"**/target/**": true
},
"yaml.schemas": {
"https://json-schema.org/draft/2019-09/schema": [
"schema.yaml",
"*.schema.yaml"
]
},
"sqltools.format": {
"linesBetweenQueries": 2,
"reservedWordCase": "upper",
"language": "sql"
},
"files.trimTrailingWhitespace": true,
"editor.formatOnSave": true,
"cSpell.words": [
"airbyte",
"Firebolt",
"schemalate"
],
"[sql]": {
// Disable SQL formatting because it's - frankly - terrible.
"editor.formatOnSave": false
},
"deno.enable": true,
"deno.path": ".build/package/bin/deno"
}
"files.watcherExclude": {
"**/.build/**": true,
"**/target/**": true
},
"yaml.schemas": {
"https://json-schema.org/draft/2019-09/schema": [
"schema.yaml",
"*.schema.yaml"
]
},
"sqltools.format": {
"linesBetweenQueries": 2,
"reservedWordCase": "upper",
"language": "sql"
},
"files.trimTrailingWhitespace": true,
"editor.formatOnSave": true,
"cSpell.words": [
"airbyte",
"Firebolt",
"schemalate"
],
"[sql]": {
// Disable SQL formatting because it's - frankly - terrible.
"editor.formatOnSave": false
},
"[typescript]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "denoland.vscode-deno"
},
"deno.enablePaths": ["./supabase/functions"],
"deno.config": "./deno.jsonc",
"deno.importMap": "./supabase/functions/import-map.json",
"deno.lint": true,
"deno.path": ".build/package/bin/deno"
}
5 changes: 2 additions & 3 deletions Tiltfile
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ local_resource('config-encryption', serve_cmd='%s/config-encryption/target/debug

local_resource(
'edge-functions',
serve_cmd='cd %s/flow && supabase functions serve oauth --env-file supabase/env.local --debug' % REPO_BASE,
serve_cmd=serve_cmd='cd %s/flow && supabase functions serve --env-file supabase/env.local --import-map supabase/functions/import-map.json' % REPO_BASE,
deps=['config-encryption'])

local_resource('ui', serve_dir='%s/ui' % REPO_BASE, serve_cmd='BROWSER=none npm start', deps=[], links='http://localhost:3000')
Expand All @@ -105,8 +105,7 @@ local_resource('data-plane-gateway',
--broker-address=localhost:8080 \
--consumer-address=localhost:9000 \
--log.level=debug \
--inference-address=localhost:9090 \
--control-plane-auth-url=http://localhost:3000' % (
--inference-address=localhost:9090' % (
DPG_TLS_KEY_PATH,
DPG_TLS_CERT_PATH
),
Expand Down
8 changes: 8 additions & 0 deletions deno.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"fmt": {
"options": {
"indentWidth": 4,
"lineWidth": 160
}
}
}
32 changes: 32 additions & 0 deletions deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion supabase/env.local
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
CONFIG_ENCRYPTION_URL=http://host.docker.internal:8765/v1/encrypt-config
CONFIG_ENCRYPTION_URL=http://host.docker.internal:8765/v1/encrypt-config
# Pull this from 1password: "Stripe API Key"."Local Dev Test Key"
# STRIPE_API_KEY=rk_test_...
6 changes: 3 additions & 3 deletions supabase/functions/_shared/supabaseClient.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createClient } from "https://esm.sh/@supabase/supabase-js";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.21.0";

export const supabaseClient = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
);
26 changes: 26 additions & 0 deletions supabase/functions/billing/delete_tenant_payment_method.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { customerQuery, StripeClient } from "./shared.ts";

export interface DeleteTenantPaymentMethodsParams {
tenant: string;
id: string;
}

export async function deleteTenantPaymentMethod(
req_body: DeleteTenantPaymentMethodsParams,
full_req: Request,
): Promise<ConstructorParameters<typeof Response>> {
await StripeClient.paymentMethods.detach(req_body.id);

const customer = (await StripeClient.customers.search({ query: customerQuery(req_body.tenant) })).data[0];
if (customer) {
const methods = (await StripeClient.customers.listPaymentMethods(customer.id)).data;
const validMethod = methods.filter((m: { id: string }) => m.id !== req_body.id)[0];
if (validMethod) {
await StripeClient.customers.update(customer.id, { invoice_settings: { default_payment_method: validMethod.id } });
}
}
return [JSON.stringify({ status: "ok" }), {
headers: { "Content-Type": "application/json" },
status: 200,
}];
}
25 changes: 25 additions & 0 deletions supabase/functions/billing/get_tenant_payment_methods.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { customerQuery, StripeClient } from "./shared.ts";

export interface getTenantPaymentMethodsParams {
tenant: string;
}

export async function getTenantPaymentMethods(
req_body: getTenantPaymentMethodsParams,
full_req: Request,
): Promise<ConstructorParameters<typeof Response>> {
const customer = (await StripeClient.customers.search({ query: customerQuery(req_body.tenant) })).data[0];
if (customer) {
const methods = (await StripeClient.customers.listPaymentMethods(customer.id)).data;

return [JSON.stringify({ payment_methods: methods, primary: customer.invoice_settings.default_payment_method }), {
headers: { "Content-Type": "application/json" },
status: 200,
}];
} else {
return [JSON.stringify({ payment_methods: [], primary: null }), {
headers: { "Content-Type": "application/json" },
status: 200,
}];
}
}
75 changes: 75 additions & 0 deletions supabase/functions/billing/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { serve } from "https://deno.land/[email protected]/http/server.ts";
import { corsHeaders } from "../_shared/cors.ts";
import { setupIntent } from "./setup_intent.ts";
import { getTenantPaymentMethods } from "./get_tenant_payment_methods.ts";
import { deleteTenantPaymentMethod } from "./delete_tenant_payment_method.ts";
import { setTenantPrimaryPaymentMethod } from "./set_tenant_primary_payment_method.ts";
import { createClient } from "https://esm.sh/@supabase/[email protected]";

// Now that the supabase CLI supports multiple edge functions,
// we should refactor this into individual functions instead
// of multiplexing endpoints via the request body
serve(async (req) => {
let res: ConstructorParameters<typeof Response> = [null, {}];
try {
if (req.method === "OPTIONS") {
res = ["ok", { status: 200 }];
} else {
const request = await req.json();

const requested_tenant = request.tenant;
// Create a Supabase client with the Auth context of the logged in user.
// This is required in order to get the user's name and email address
const supabaseClient = createClient(
Deno.env.get("SUPABASE_URL") ?? "",
Deno.env.get("SUPABASE_ANON_KEY") ?? "",
{
global: {
headers: { Authorization: req.headers.get("Authorization")! },
},
},
);

const {
data: { user },
} = await supabaseClient.auth.getUser();

if (!user) {
throw new Error("User not found");
}

const grants = await supabaseClient.from("combined_grants_ext").select("*").eq("capability", "admin").eq("user_id", user.id);

if (!(grants.data ?? []).find((grant) => grant.object_role === requested_tenant)) {
res = [JSON.stringify({ error: `Not authorized to requested grant` }), {
headers: { "Content-Type": "application/json" },
status: 401,
}];
} else {
if (request.operation === "setup-intent") {
res = await setupIntent(request, req, supabaseClient);
} else if (request.operation === "get-tenant-payment-methods") {
res = await getTenantPaymentMethods(request, req);
} else if (request.operation === "delete-tenant-payment-method") {
res = await deleteTenantPaymentMethod(request, req);
} else if (request.operation === "set-tenant-primary-payment-method") {
res = await setTenantPrimaryPaymentMethod(request, req);
} else {
res = [JSON.stringify({ error: "unknown_operation" }), {
headers: { "Content-Type": "application/json" },
status: 400,
}];
}
}
}
} catch (e) {
res = [JSON.stringify({ error: e.message }), {
headers: { "Content-Type": "application/json" },
status: 400,
}];
}

res[1] = { ...res[1], headers: { ...res[1]?.headers || {}, ...corsHeaders } };

return new Response(...res);
});
26 changes: 26 additions & 0 deletions supabase/functions/billing/set_tenant_primary_payment_method.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { customerQuery, StripeClient } from "./shared.ts";

export interface SetTenantPrimaryPaymentMethodParams {
tenant: string;
id: string;
}

export async function setTenantPrimaryPaymentMethod(
req_body: SetTenantPrimaryPaymentMethodParams,
full_req: Request,
): Promise<ConstructorParameters<typeof Response>> {
const customer = (await StripeClient.customers.search({ query: customerQuery(req_body.tenant) })).data[0];
if (customer) {
await StripeClient.customers.update(customer.id, { invoice_settings: { default_payment_method: req_body.id } });

return [JSON.stringify({ status: "ok" }), {
headers: { "Content-Type": "application/json" },
status: 200,
}];
} else {
return [JSON.stringify({ payment_methods: [], primary: null }), {
headers: { "Content-Type": "application/json" },
status: 200,
}];
}
}
71 changes: 71 additions & 0 deletions supabase/functions/billing/setup_intent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { SupabaseClient, User } from "https://esm.sh/@supabase/[email protected]";
import { customerQuery, StripeClient, TENANT_METADATA_KEY } from "./shared.ts";

async function findOrCreateCustomer(tenant: string, user: User) {
if (!user.email) {
throw new Error("Missing user email address");
}
const query = customerQuery(tenant);
const existing = await StripeClient.customers.search({
query,
});
if (existing?.data?.length === 1) {
console.log(`Found existing customer, reusing `);
return existing.data[0];
} else if (existing?.data?.length || 0 > 1) {
console.log(`Found existing customer, reusing `);
// Should we bail?
return existing.data[0];
} else {
console.log(`Unable to find customer, creating new`);
const customer = await StripeClient.customers.create({
email: user.email,
name: tenant,
description: `Represents the billing entity for Flow tenant '${tenant}'`,

metadata: {
[TENANT_METADATA_KEY]: tenant,
"created_by_user_email": user.email,
"created_by_user_name": user.user_metadata.name,
},
});

return customer;
}
}

export interface SetupIntentRequest {
tenant: string;
}

export async function setupIntent(
req_body: SetupIntentRequest,
full_req: Request,
supabase_client: SupabaseClient,
): Promise<ConstructorParameters<typeof Response>> {
// Now we can get the session or user object
const {
data: { user },
} = await supabase_client.auth.getUser();

if (!user) {
return [JSON.stringify({ error: "User not found" }), {
headers: { "Content-Type": "application/json" },
status: 400,
}];
}
const customer = await findOrCreateCustomer(req_body.tenant, user);

const intent = await StripeClient.setupIntents.create({
customer: customer.id,
description: "Store your payment details",
usage: "off_session",
// payment_method_types: ["card", "us_bank_account"],
payment_method_types: ["card"],
});

return [JSON.stringify({ intent_secret: intent.client_secret }), {
headers: { "Content-Type": "application/json" },
status: 200,
}];
}
11 changes: 11 additions & 0 deletions supabase/functions/billing/shared.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import Stripe from "stripe";

const STRIPE_API = Deno.env.get("STRIPE_API_KEY");
if (!STRIPE_API) {
throw new Error("Unable to locate STRIPE_API_KEY environment variable");
}
// deno-lint-ignore no-explicit-any
export const StripeClient = new Stripe(STRIPE_API, { apiVersion: "2022-11-15" }) as any;

export const TENANT_METADATA_KEY = "estuary.dev/tenant_name";
export const customerQuery = (tenant: string) => `metadata["${TENANT_METADATA_KEY}"]:"${tenant}"`;
5 changes: 5 additions & 0 deletions supabase/functions/import-map.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"imports": {
"stripe": "https://esm.sh/v117/[email protected]?target=deno&no-dts"
}
}

0 comments on commit 13768f2

Please sign in to comment.