forked from estuary/flow
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Feature/stripe backend (estuary#1013)
- Loading branch information
Showing
14 changed files
with
328 additions
and
36 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
{ | ||
"recommendations": [ | ||
"denoland.vscode-deno" | ||
] | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
{ | ||
"fmt": { | ||
"options": { | ||
"indentWidth": 4, | ||
"lineWidth": 160 | ||
} | ||
} | ||
} |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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_... |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
26
supabase/functions/billing/delete_tenant_payment_method.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}]; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}]; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
26
supabase/functions/billing/set_tenant_primary_payment_method.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}]; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}]; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}"`; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} | ||
} |