Skip to content

Commit 20bf932

Browse files
authored
fix(billing): auto-link accounts to Hyperline customer (close SCR-68 gap) (#19)
SCR-68 migrated billing from Stripe to Hyperline but never wired up customer creation — signup, the portal handler, and ops tooling all assumed someone would backfill manually. Existing accounts (e.g. internal dogfooding teams created pre-migration) ended up stranded with hyperline_customer_id IS NULL and a permanent 404 from GET /account/billing/portal, with no self-serve path forward. This commit: - HyperlineClient::create_customer + find_customer_by_external_id — POST /v1/customers and a GET filter that recovers customers orphaned by a partial link (network drop between Hyperline POST and our DB UPDATE). - GET /account/billing/portal lazy-links on first hit when hyperline_customer_id IS NULL. Reuses an existing Hyperline customer with the same external_id (= accounts.id) if one is found, otherwise creates. The UPDATE uses COALESCE so concurrent linkers converge on a single id. - POST /auth/signup eager-links via tokio::spawn — fire-and-forget so the signup latency budget is unaffected. The lazy-create path is the backstop if the spawn task fails. - bins/scrapix-api/src/bin/backfill_hyperline_customers.rs — one-shot ops binary for the existing NULL rows. Idempotent on re-run (looks up by external_id first), per-account logging so a single bad row doesn't abort the batch.
1 parent 3a94103 commit 20bf932

4 files changed

Lines changed: 332 additions & 8 deletions

File tree

bins/scrapix-api/src/auth/handlers/auth.rs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ use axum_extra::extract::CookieJar;
77
use sha2::{Digest, Sha256};
88
use sqlx::Row;
99
use std::sync::Arc;
10-
use tracing::info;
10+
use tracing::{info, warn};
1111

12+
use super::billing::link_account_to_hyperline;
1213
use super::{
1314
build_session_cookie, clear_session_cookie, err, AccountResponse, ApiError, ErrorBody,
1415
ForgotPasswordRequest, LoginRequest, MessageResponse, ResetPasswordRequest, SignupRequest,
@@ -156,6 +157,27 @@ pub(crate) async fn signup(
156157

157158
info!(user_id = %user_id, email = %req.email, "New user signed up");
158159

160+
// Best-effort: create the Hyperline customer in the background so the
161+
// first GET /account/billing/portal call doesn't pay the latency. If
162+
// this fails (Hyperline outage, etc.), the portal handler's
163+
// lazy-create path will retry on next click.
164+
if let Some(client) = state.hyperline_client.clone() {
165+
let pool = state.pool.clone();
166+
let name = account_name.clone();
167+
let email = req.email.clone();
168+
tokio::spawn(async move {
169+
if let Err(e) =
170+
link_account_to_hyperline(&pool, &client, account_id, &name, &email).await
171+
{
172+
warn!(
173+
account_id = %account_id,
174+
error = %e,
175+
"hyperline eager-link on signup failed — will retry lazily on first portal click"
176+
);
177+
}
178+
});
179+
}
180+
159181
// Auto-accept pending invites for this email
160182
let pending_invites = sqlx::query(
161183
"SELECT id, account_id, role FROM account_invites \

bins/scrapix-api/src/auth/handlers/billing.rs

Lines changed: 123 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ use axum::{
33
http::StatusCode,
44
Json,
55
};
6-
use sqlx::Row;
6+
use scrapix_billing_hyperline::HyperlineClient;
7+
use sqlx::{PgPool, Row};
78
use std::sync::Arc;
89
use tracing::{error, info};
910

@@ -14,6 +15,87 @@ use super::{
1415
};
1516
use crate::auth::{AuthState, AuthenticatedUser};
1617

18+
/// Lazily link an account to a Hyperline customer.
19+
///
20+
/// Looks up `(account_name, owner_email)`, queries Hyperline for an
21+
/// existing customer with `external_id = account_id` (recovers from
22+
/// crashes that orphaned a customer mid-link), creates one if absent,
23+
/// then `UPDATE accounts SET hyperline_customer_id = COALESCE(...)` so
24+
/// concurrent linkers converge on a single value.
25+
///
26+
/// Returns the linked customer id. Network/auth failures bubble up;
27+
/// the caller decides which HTTP status to map to.
28+
pub(crate) async fn link_account_to_hyperline(
29+
pool: &PgPool,
30+
client: &HyperlineClient,
31+
account_id: uuid::Uuid,
32+
account_name: &str,
33+
owner_email: &str,
34+
) -> Result<String, scrapix_billing_hyperline::HyperlineError> {
35+
let external_id = account_id.to_string();
36+
37+
let customer = match client.find_customer_by_external_id(&external_id).await? {
38+
Some(existing) => {
39+
info!(
40+
account_id = %account_id,
41+
customer_id = %existing.id,
42+
"hyperline: recovered orphaned customer by external_id"
43+
);
44+
existing
45+
}
46+
None => {
47+
let created = client
48+
.create_customer(&external_id, account_name, owner_email)
49+
.await?;
50+
info!(
51+
account_id = %account_id,
52+
customer_id = %created.id,
53+
"hyperline: created customer"
54+
);
55+
created
56+
}
57+
};
58+
59+
// COALESCE handles the race where another request linked first; we
60+
// adopt whatever id won and discard our just-created one (it stays
61+
// in Hyperline as an orphan, but `find_customer_by_external_id`
62+
// will reuse it on any future link attempt for this account).
63+
let linked_id: String = sqlx::query_scalar(
64+
"UPDATE accounts \
65+
SET hyperline_customer_id = COALESCE(hyperline_customer_id, $1) \
66+
WHERE id = $2 RETURNING hyperline_customer_id",
67+
)
68+
.bind(&customer.id)
69+
.bind(account_id)
70+
.fetch_one(pool)
71+
.await
72+
.map_err(|e| scrapix_billing_hyperline::HyperlineError::InvalidConfig(format!("db: {e}")))?;
73+
74+
Ok(linked_id)
75+
}
76+
77+
/// Owner email for an account — used as the Hyperline `email` field
78+
/// on lazy customer creation. Picks the oldest 'owner' membership so
79+
/// the result is stable across reruns.
80+
pub(crate) async fn account_owner_email(
81+
pool: &PgPool,
82+
account_id: uuid::Uuid,
83+
) -> Result<Option<(String, String)>, sqlx::Error> {
84+
let row = sqlx::query(
85+
"SELECT a.name AS account_name, u.email AS owner_email \
86+
FROM accounts a \
87+
JOIN account_members m ON m.account_id = a.id \
88+
JOIN users u ON u.id = m.user_id \
89+
WHERE a.id = $1 AND m.role = 'owner' \
90+
ORDER BY m.joined_at ASC \
91+
LIMIT 1",
92+
)
93+
.bind(account_id)
94+
.fetch_optional(pool)
95+
.await?;
96+
Ok(row.map(|r| (r.get("account_name"), r.get("owner_email"))))
97+
}
98+
1799
#[utoipa::path(
18100
get,
19101
path = "/account/billing",
@@ -142,12 +224,46 @@ pub(crate) async fn get_billing_portal(
142224
})?
143225
.flatten();
144226

145-
let Some(customer_id) = customer_id else {
146-
return Err(err(
147-
StatusCode::NOT_FOUND,
148-
"Account is not linked to Hyperline yet",
149-
"not_linked",
150-
));
227+
let customer_id = match customer_id {
228+
Some(id) => id,
229+
None => {
230+
// First portal request for this account — lazy-link to Hyperline.
231+
// Closes the gap left by SCR-68 (no automated customer creation):
232+
// accounts created before the migration, or whose eager-create at
233+
// signup failed, would otherwise be stuck with a 404 forever.
234+
let Some((account_name, owner_email)) = account_owner_email(&state.pool, account_id)
235+
.await
236+
.map_err(|e| {
237+
error!(account_id = %account_id, error = %e, "owner lookup failed");
238+
err(
239+
StatusCode::INTERNAL_SERVER_ERROR,
240+
"Database error",
241+
"internal_error",
242+
)
243+
})?
244+
else {
245+
return Err(err(
246+
StatusCode::NOT_FOUND,
247+
"Account has no owner — cannot link to Hyperline",
248+
"no_owner",
249+
));
250+
};
251+
252+
link_account_to_hyperline(&state.pool, client, account_id, &account_name, &owner_email)
253+
.await
254+
.map_err(|e| {
255+
error!(
256+
account_id = %account_id,
257+
error = %e,
258+
"hyperline lazy-link failed"
259+
);
260+
err(
261+
StatusCode::BAD_GATEWAY,
262+
"Failed to link account to Hyperline",
263+
"hyperline_upstream",
264+
)
265+
})?
266+
}
151267
};
152268

153269
match client.get_portal_url(&customer_id).await {
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
//! Backfill `accounts.hyperline_customer_id` for rows that survived the
2+
//! SCR-68 Stripe → Hyperline migration with NULL.
3+
//!
4+
//! Walks every account where `hyperline_customer_id IS NULL`, looks up
5+
//! the oldest 'owner' membership for `(name, email)`, and either reuses
6+
//! an existing Hyperline customer (matched by `external_id =
7+
//! accounts.id`) or creates a new one — then writes the id back.
8+
//!
9+
//! Safe to re-run: each step is idempotent. Failures are logged per
10+
//! account and the loop continues; rerun to retry only the survivors.
11+
//!
12+
//! ```sh
13+
//! DATABASE_URL=postgres://… \
14+
//! HYPERLINE_API_KEY=prod_… \
15+
//! cargo run -p scrapix-api --bin backfill_hyperline_customers
16+
//! ```
17+
18+
use std::error::Error;
19+
20+
use scrapix_billing_hyperline::HyperlineClient;
21+
use sqlx::{postgres::PgPoolOptions, PgPool, Row};
22+
use tracing::{error, info, warn};
23+
use uuid::Uuid;
24+
25+
#[tokio::main]
26+
async fn main() -> Result<(), Box<dyn Error>> {
27+
tracing_subscriber::fmt()
28+
.with_env_filter(
29+
tracing_subscriber::EnvFilter::try_from_default_env()
30+
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
31+
)
32+
.init();
33+
34+
let database_url = std::env::var("DATABASE_URL")?;
35+
let url = if !database_url.contains("sslmode=") {
36+
let sep = if database_url.contains('?') { "&" } else { "?" };
37+
format!("{database_url}{sep}sslmode=require")
38+
} else {
39+
database_url
40+
};
41+
let pool = PgPoolOptions::new()
42+
.max_connections(2)
43+
.connect(&url)
44+
.await?;
45+
46+
let client = HyperlineClient::from_env()?;
47+
let target = if client.config().is_sandbox() {
48+
"sandbox"
49+
} else {
50+
"PRODUCTION"
51+
};
52+
info!("Backfilling Hyperline customers into {target}…");
53+
54+
// We page through accounts; new ones get linked as the loop runs so
55+
// restarting from the top each iteration would be wasteful. Capture
56+
// the snapshot once and walk it.
57+
let rows = sqlx::query(
58+
"SELECT a.id AS account_id, a.name AS account_name, u.email AS owner_email \
59+
FROM accounts a \
60+
JOIN account_members m ON m.account_id = a.id AND m.role = 'owner' \
61+
JOIN users u ON u.id = m.user_id \
62+
WHERE a.hyperline_customer_id IS NULL \
63+
GROUP BY a.id, a.name, u.email, m.joined_at \
64+
ORDER BY a.id, m.joined_at ASC",
65+
)
66+
.fetch_all(&pool)
67+
.await?;
68+
69+
// GROUP BY + ORDER BY gives duplicates per account if multiple
70+
// owners. Dedup by account_id, keeping first (= oldest owner).
71+
let mut seen = std::collections::HashSet::new();
72+
let mut targets: Vec<(Uuid, String, String)> = Vec::new();
73+
for row in rows {
74+
let id: Uuid = row.get("account_id");
75+
if !seen.insert(id) {
76+
continue;
77+
}
78+
targets.push((id, row.get("account_name"), row.get("owner_email")));
79+
}
80+
81+
info!("Found {} unlinked account(s)", targets.len());
82+
83+
let mut linked = 0usize;
84+
let mut reused = 0usize;
85+
let mut failed = 0usize;
86+
87+
for (account_id, account_name, owner_email) in targets {
88+
match link_one(&pool, &client, account_id, &account_name, &owner_email).await {
89+
Ok(LinkOutcome::Created) => {
90+
linked += 1;
91+
info!(account_id = %account_id, "linked (created in Hyperline)");
92+
}
93+
Ok(LinkOutcome::Reused) => {
94+
reused += 1;
95+
info!(account_id = %account_id, "linked (reused existing Hyperline customer)");
96+
}
97+
Err(e) => {
98+
failed += 1;
99+
error!(account_id = %account_id, error = %e, "link failed");
100+
}
101+
}
102+
}
103+
104+
info!("Done. created={linked} reused={reused} failed={failed}");
105+
if failed > 0 {
106+
warn!("{failed} account(s) still unlinked — rerun after addressing the root cause");
107+
std::process::exit(1);
108+
}
109+
Ok(())
110+
}
111+
112+
enum LinkOutcome {
113+
Created,
114+
Reused,
115+
}
116+
117+
async fn link_one(
118+
pool: &PgPool,
119+
client: &HyperlineClient,
120+
account_id: Uuid,
121+
account_name: &str,
122+
owner_email: &str,
123+
) -> Result<LinkOutcome, Box<dyn Error>> {
124+
let external_id = account_id.to_string();
125+
126+
let (customer_id, outcome) = match client.find_customer_by_external_id(&external_id).await? {
127+
Some(existing) => (existing.id, LinkOutcome::Reused),
128+
None => {
129+
let created = client
130+
.create_customer(&external_id, account_name, owner_email)
131+
.await?;
132+
(created.id, LinkOutcome::Created)
133+
}
134+
};
135+
136+
sqlx::query(
137+
"UPDATE accounts \
138+
SET hyperline_customer_id = COALESCE(hyperline_customer_id, $1) \
139+
WHERE id = $2",
140+
)
141+
.bind(&customer_id)
142+
.bind(account_id)
143+
.execute(pool)
144+
.await?;
145+
146+
Ok(outcome)
147+
}

crates/scrapix-billing-hyperline/src/client.rs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,45 @@ impl HyperlineClient {
200200
Ok(())
201201
}
202202

203+
/// Look up a customer by its `external_id`. Returns `None` if no row
204+
/// matches. Used by the lazy-link path on `GET /account/billing/portal`
205+
/// to recover from a prior partial link (Hyperline customer created but
206+
/// the `UPDATE accounts` step never landed — race or crash). Without
207+
/// this we'd orphan a customer in Hyperline and create a duplicate on
208+
/// the next retry.
209+
pub async fn find_customer_by_external_id(
210+
&self,
211+
external_id: &str,
212+
) -> Result<Option<Customer>, HyperlineError> {
213+
let page: ListResponse<Customer> = self
214+
.get_json(
215+
"/v1/customers",
216+
&[("external_id__equals", external_id), ("limit", "1")],
217+
)
218+
.await?;
219+
Ok(page.data.into_iter().next())
220+
}
221+
222+
/// Create a Hyperline customer. `external_id` is the local
223+
/// `accounts.id` (UUID) so we can recover the mapping if our DB row
224+
/// gets cleared but Hyperline's record survives. A 4xx (e.g.
225+
/// duplicate `external_id`) is surfaced as `HyperlineError::Api` —
226+
/// the lazy-create path checks `find_customer_by_external_id` first
227+
/// so this should only fire on genuinely-new customers.
228+
pub async fn create_customer(
229+
&self,
230+
external_id: &str,
231+
name: &str,
232+
email: &str,
233+
) -> Result<Customer, HyperlineError> {
234+
let body = serde_json::json!({
235+
"external_id": external_id,
236+
"name": name,
237+
"email": email,
238+
});
239+
self.post_json("/v1/customers", &body).await
240+
}
241+
203242
/// Fetches a wallet by its Hyperline id.
204243
///
205244
/// Wallets are not auto-provisioned — if the customer has no wallet

0 commit comments

Comments
 (0)