use anyhow::{Result, bail};
use regex::Regex;
use super::{Capability, CapabilityEdit};
use crate::infra::in_place;
pub(crate) static PAYMENTS: Capability = Capability {
id: "payments",
resource_type: "payment",
summary: "Stripe Connect through the platform: the instance's own connected account, no Stripe key in the app",
registration_key: "StripeClient",
receives_events: true,
workers: true,
add,
remove,
};
const STRIPE_VERSION: &str = "^22.6.2";
const CORE_HTTP: &str = "@forklaunch/core/http";
const KEYS: &[&str] = &["STRIPE_API_KEY", "STRIPE_SECRET_KEY", "STRIPE_WEBHOOK_SECRET"];
const RUNTIME_BLOCK: &str = "const configInjector = createConfigInjector(SchemaValidator(), {
StripeClient: {
lifetime: Lifetime.Singleton,
type: Stripe,
factory: () => createStripeClient({ Stripe })
}
});";
const SWITCHED_FACTORY: &str =
"factory: ({ STRIPE_API_KEY }) => createStripeClient({ Stripe, apiKey: STRIPE_API_KEY })";
fn original_factory() -> Regex {
Regex::new(
r"factory:\s*\(\s*\{\s*STRIPE_API_KEY\s*\}\s*\)\s*=>\s*new\s+Stripe\(\s*STRIPE_API_KEY\s*\)",
)
.unwrap()
}
fn switched_factory() -> Regex {
Regex::new(
r"factory:\s*\(\s*\{\s*STRIPE_API_KEY\s*\}\s*\)\s*=>\s*createStripeClient\(\s*\{\s*Stripe,\s*apiKey:\s*STRIPE_API_KEY\s*\}\s*\)",
)
.unwrap()
}
fn injected_factory() -> Regex {
Regex::new(r"factory:\s*\(\s*\)\s*=>\s*createStripeClient\(\s*\{\s*Stripe\s*\}\s*\)").unwrap()
}
fn retype_keys(text: &str, from: &str, to: &str) -> String {
let mut out = text.to_string();
for key in KEYS {
let re = Regex::new(&format!(
r"(\b{key}\s*:\s*\{{[^{{}}]*?\btype\s*:\s*){}(\s*[,\n}}])",
regex::escape(from)
))
.unwrap();
out = re.replace(&out, format!("${{1}}{to}${{2}}")).into_owned();
}
out
}
pub(crate) fn ensure_named_import(text: &str, name: &str, source: &str) -> String {
in_place::add_named_imports(text, source, &[name])
}
fn has_default_stripe_import(text: &str) -> bool {
Regex::new(r#"import\s+(?:\{\s*default\s+as\s+Stripe\s*\}|Stripe)\s+from\s+['"]stripe['"]"#)
.unwrap()
.is_match(text)
}
fn schema_source(text: &str) -> Option<String> {
let re = Regex::new(r#"(?s)import\s*\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]"#).unwrap();
re.captures_iter(text)
.find(|c| c[1].split(',').any(|n| n.trim() == "string"))
.map(|c| c[2].to_string())
}
fn add(edit: &mut CapabilityEdit) -> Result<()> {
let path = edit.registrations_path();
let Some(mut text) = edit.read(&path)? else {
bail!("{path:?} not found");
};
let existing = text.contains("StripeClient:");
let native = existing && text.contains("isManagedInstance()") && text.contains("createStripeClient(");
if native {
} else if existing {
if switched_factory().is_match(&text) {
} else if original_factory().is_match(&text) {
text = original_factory()
.replace(&text, SWITCHED_FACTORY)
.into_owned();
} else {
bail!(
"{path:?} registers a StripeClient with a factory `infra add` does not recognise; \
build it with createStripeClient({{ Stripe, apiKey }}) from @forklaunch/core/http by hand"
);
}
text = retype_keys(&text, "string", "optional(string)");
if let Some(source) = schema_source(&text) {
text = ensure_named_import(&text, "optional", &source);
}
text = ensure_named_import(&text, "createStripeClient", CORE_HTTP);
edit.write(path.clone(), text);
let controller = edit.service_path.join("api/controllers/webhook.controller.ts");
if let Some(source) = edit.read(&controller)? {
let resolved = "ci.resolve(tokens.STRIPE_WEBHOOK_SECRET);";
if source.contains(resolved) {
edit.write(
controller,
source.replace(resolved, "ci.resolve(tokens.STRIPE_WEBHOOK_SECRET) ?? '';"),
);
}
}
} else {
if !has_default_stripe_import(&text) {
text = in_place::add_default_import(&text, "Stripe", "stripe");
}
text = ensure_named_import(&text, "createStripeClient", CORE_HTTP);
edit.write(path, text);
edit.inject_registration("", "stripe", None, Some(RUNTIME_BLOCK))?;
ensure_stripe_dependency(edit)?;
}
let stub = if existing && text_has_webhook_service(edit)? {
BILLING_HANDLER_STUB
} else {
HANDLER_STUB
};
edit.ensure_platform_events("payments", stub)?;
Ok(())
}
fn text_has_webhook_service(edit: &CapabilityEdit) -> Result<bool> {
Ok(edit
.read(&edit.registrations_path())?
.is_some_and(|t| t.contains("WebhookService:") && t.contains("StripeWebhookService")))
}
fn ensure_stripe_dependency(edit: &mut CapabilityEdit) -> Result<()> {
let path = edit.service_path.join("package.json");
let Some(text) = edit.read(&path)? else {
return Ok(());
};
let changed = in_place::add_dependency(&text, "stripe", STRIPE_VERSION)?;
if changed != text {
edit.write(path, changed);
}
Ok(())
}
fn remove(edit: &mut CapabilityEdit) -> Result<()> {
let path = edit.registrations_path();
let Some(before) = edit.read(&path)? else {
return Ok(());
};
if switched_factory().is_match(&before) {
let mut text = switched_factory()
.replace(&before, "factory: ({ STRIPE_API_KEY }) => new Stripe(STRIPE_API_KEY)")
.into_owned();
text = retype_keys(&text, "optional(string)", "string");
text = in_place::drop_orphaned_imports(&before, &text, &["createStripeClient"]);
edit.write(path, text);
let controller = edit.service_path.join("api/controllers/webhook.controller.ts");
if let Some(source) = edit.read(&controller)? {
let switched = "ci.resolve(tokens.STRIPE_WEBHOOK_SECRET) ?? '';";
if source.contains(switched) {
edit.write(
controller,
source.replace(switched, "ci.resolve(tokens.STRIPE_WEBHOOK_SECRET);"),
);
}
}
} else if injected_factory().is_match(&before) {
edit.remove_registration(None, &[], &["StripeClient"])?;
drop_stripe_dependency_if_unused(edit)?;
}
Ok(())
}
fn drop_stripe_dependency_if_unused(edit: &mut CapabilityEdit) -> Result<()> {
fn uses_stripe(dir: &std::path::Path, edit: &CapabilityEdit) -> bool {
let Ok(entries) = std::fs::read_dir(dir) else {
return false;
};
for entry in entries.flatten() {
let p = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
if p.is_dir() {
if !["node_modules", "dist"].contains(&name.as_str()) && uses_stripe(&p, edit) {
return true;
}
} else if name.ends_with(".ts") {
let text = edit.read(&p).ok().flatten().unwrap_or_default();
if text.contains("from 'stripe'") || text.contains("from \"stripe\"") {
return true;
}
}
}
false
}
if uses_stripe(&edit.service_path.clone(), edit) {
return Ok(());
}
let path = edit.service_path.join("package.json");
let Some(text) = edit.read(&path)? else {
return Ok(());
};
let changed = in_place::remove_dependency(&text, "stripe")?;
if changed != text {
edit.write(path, changed);
}
Ok(())
}
const HANDLER_STUB: &str = "import type { PlatformEvent } from '@forklaunch/core/http';
/**
* Stripe events for this instance's connected account, relayed by the
* ForkLaunch platform after it verified Stripe's signature (`forklaunch infra
* add <service> payments`).
*
* event.id the Stripe event id (stable across redeliveries)
* event.type the Stripe event type: checkout.session.completed,
* invoice.paid, customer.subscription.updated, account.updated, …
* event.data the event's object (a Checkout Session, an Invoice, …)
*
* Deliveries can repeat: dedupe on event.id. The in-memory set below only
* covers one process; record processed ids in the database for more.
*/
const processed = new Set<string>();
export async function handle(event: PlatformEvent): Promise<void> {
if (processed.has(event.id)) return;
switch (event.type) {
case 'checkout.session.completed':
// TODO: mark the order / appointment in event.data.metadata as paid.
break;
case 'account.updated':
// TODO: onboarding progressed; payments.status() has the details.
break;
default:
break;
}
processed.add(event.id);
}
";
const BILLING_HANDLER_STUB: &str = "import type { PlatformEvent } from '@forklaunch/core/http';
import type Stripe from 'stripe';
import { ci, tokens } from '../../bootstrapper';
const webhookServiceFactory = ci.scopedResolver(tokens.WebhookService);
/**
* Stripe events for this instance's connected account, relayed by the
* ForkLaunch platform after it verified Stripe's signature (`forklaunch infra
* add <service> payments`). They go to the same StripeWebhookService the
* key-verified /webhook route uses outside managed mode.
*
* event.id the Stripe event id (stable across redeliveries)
* event.type the Stripe event type
* event.data the event's object
*
* Deliveries can repeat: dedupe on event.id. The in-memory set below only
* covers one process; record processed ids in the database for more.
*/
const processed = new Set<string>();
export async function handle(event: PlatformEvent): Promise<void> {
if (processed.has(event.id)) return;
await webhookServiceFactory().handleWebhookEvent({
id: event.id,
object: 'event',
type: event.type,
created: Math.floor(new Date(event.occurredAt).getTime() / 1000),
data: { object: event.data }
} as unknown as Stripe.Event);
// TODO: anything else this app records about a payment.
processed.add(event.id);
}
";
#[cfg(test)]
mod tests;