feat: add apple billing flow for expo app

This commit is contained in:
Arthur Belleville 2026-05-03 09:28:46 +02:00
parent 5fba42f678
commit 66fa698e48
No known key found for this signature in database
32 changed files with 6284 additions and 1638 deletions

View file

@ -14,7 +14,7 @@ steps:
- '--region'
- 'europe-west1'
- '--set-env-vars'
- 'NODE_ENV=$_NODE_ENV,SUPABASE_URL=$_SUPABASE_URL,EMAIL_USER=$_EMAIL_USER,EMAIL_CLIENT_ID=$_EMAIL_CLIENT_ID,R2_ACCOUNT_ID=$_R2_ACCOUNT_ID,CORS_ORIGIN=$_CORS_ORIGIN,XTABLO_URL=$_XTABLO_URL,TASKS_SECRET=$_TASKS_SECRET,LOG_LEVEL=$_LOG_LEVEL,STRIPE_SOLO_PRICE_ID=$_STRIPE_SOLO_PRICE_ID,STRIPE_TEAM_PRICE_ID=$_STRIPE_TEAM_PRICE_ID,STRIPE_FOUNDER_PRICE_ID=$_STRIPE_FOUNDER_PRICE_ID,CLIENTS_URL=$_CLIENTS_URL,CLIENT_AUTH_COOKIE_DOMAIN=$_CLIENT_AUTH_COOKIE_DOMAIN,CLIENT_AUTH_COOKIE_NAME=$_CLIENT_AUTH_COOKIE_NAME,CLIENT_MAGIC_LINK_TTL_MINUTES=$_CLIENT_MAGIC_LINK_TTL_MINUTES,CLIENT_SESSION_TTL_DAYS=$_CLIENT_SESSION_TTL_DAYS'
- 'NODE_ENV=$_NODE_ENV,SUPABASE_URL=$_SUPABASE_URL,EMAIL_USER=$_EMAIL_USER,EMAIL_CLIENT_ID=$_EMAIL_CLIENT_ID,R2_ACCOUNT_ID=$_R2_ACCOUNT_ID,CORS_ORIGIN=$_CORS_ORIGIN,XTABLO_URL=$_XTABLO_URL,TASKS_SECRET=$_TASKS_SECRET,LOG_LEVEL=$_LOG_LEVEL,STRIPE_SOLO_PRICE_ID=$_STRIPE_SOLO_PRICE_ID,STRIPE_TEAM_PRICE_ID=$_STRIPE_TEAM_PRICE_ID,STRIPE_FOUNDER_PRICE_ID=$_STRIPE_FOUNDER_PRICE_ID,REVENUECAT_WEBHOOK_AUTH_HEADER=$_REVENUECAT_WEBHOOK_AUTH_HEADER,REVENUECAT_SOLO_PRODUCT_ID=$_REVENUECAT_SOLO_PRODUCT_ID,REVENUECAT_ANNUAL_PRODUCT_ID=$_REVENUECAT_ANNUAL_PRODUCT_ID,CLIENTS_URL=$_CLIENTS_URL,CLIENT_AUTH_COOKIE_DOMAIN=$_CLIENT_AUTH_COOKIE_DOMAIN,CLIENT_AUTH_COOKIE_NAME=$_CLIENT_AUTH_COOKIE_NAME,CLIENT_MAGIC_LINK_TTL_MINUTES=$_CLIENT_MAGIC_LINK_TTL_MINUTES,CLIENT_SESSION_TTL_DAYS=$_CLIENT_SESSION_TTL_DAYS'
images:
- 'europe-west1-docker.pkg.dev/$_AR_PROJECT_ID/$_AR_REPOSITORY/xtablo-source/$_SERVICE_NAME:$COMMIT_SHA'

View file

@ -0,0 +1,79 @@
import { afterEach, describe, expect, it } from "vitest";
import { createConfig } from "../../config.js";
import type { Secrets } from "../../secrets.js";
const baseSecrets: Secrets = {
adminTokenSigningSecret: "admin-token-signing-secret",
clientAuthJwtSecret: "client-auth-jwt-secret",
supabaseServiceRoleKey: "service-role-from-secret-manager",
supabaseConnectionString: "postgres://secret-manager",
supabaseCaCert: "ca-cert",
emailClientSecret: "email-client-secret",
emailRefreshToken: "email-refresh-token",
r2AccessKeyId: "r2-access-key-id",
r2SecretAccessKey: "r2-secret-access-key",
stripeSecretKey: "sk_live_secret_manager",
stripeWebhookSecret: "whsec_live_secret_manager",
stripeSecretKeyStaging: "sk_live_staging_secret_manager",
stripeWebhookSecretStaging: "whsec_live_staging_secret_manager",
};
const originalEnv = { ...process.env };
const setRequiredBaseEnv = () => {
process.env.NODE_ENV = "test";
process.env.SUPABASE_URL = "https://example.supabase.co";
process.env.SUPABASE_SERVICE_ROLE_KEY = "service-role-key-for-tests";
process.env.SUPABASE_CONNECTION_STRING = "postgres://localhost/test";
process.env.SUPABASE_CA_CERT = "test-ca-cert";
process.env.STRIPE_SECRET_KEY = "sk_test_env_override";
process.env.STRIPE_WEBHOOK_SECRET = "whsec_test_env_override";
process.env.STRIPE_SOLO_PRICE_ID = "price_solo";
process.env.STRIPE_TEAM_PRICE_ID = "price_team";
process.env.STRIPE_FOUNDER_PRICE_ID = "price_founder";
process.env.EMAIL_USER = "test@xtablo.com";
process.env.EMAIL_CLIENT_ID = "client-id";
process.env.EMAIL_CLIENT_SECRET = "email-client-secret";
process.env.EMAIL_REFRESH_TOKEN = "email-refresh-token";
process.env.R2_ACCOUNT_ID = "r2-account";
process.env.R2_ACCESS_KEY_ID = "r2-access-key";
process.env.R2_SECRET_ACCESS_KEY = "r2-secret";
process.env.ADMIN_TOKEN_SIGNING_SECRET = "admin-signing-secret";
process.env.REVENUECAT_WEBHOOK_AUTH_HEADER = "Bearer revenuecat-secret";
process.env.REVENUECAT_SOLO_PRODUCT_ID = "solo_ios_monthly";
process.env.REVENUECAT_ANNUAL_PRODUCT_ID = "annual_ios";
};
afterEach(() => {
process.env = { ...originalEnv };
});
describe("createConfig revenuecat env", () => {
it("loads revenuecat webhook auth and product ids in test mode", () => {
setRequiredBaseEnv();
const config = createConfig(baseSecrets);
expect(config.REVENUECAT_WEBHOOK_AUTH_HEADER).toBe("Bearer revenuecat-secret");
expect(config.REVENUECAT_SOLO_PRODUCT_ID).toBe("solo_ios_monthly");
expect(config.REVENUECAT_ANNUAL_PRODUCT_ID).toBe("annual_ios");
});
it("throws when the revenuecat webhook auth header is missing", () => {
setRequiredBaseEnv();
process.env.REVENUECAT_WEBHOOK_AUTH_HEADER = "";
expect(() => createConfig(baseSecrets)).toThrow(
"Missing required environment variable: REVENUECAT_WEBHOOK_AUTH_HEADER"
);
});
it("throws when the annual product id is missing", () => {
setRequiredBaseEnv();
process.env.REVENUECAT_ANNUAL_PRODUCT_ID = "";
expect(() => createConfig(baseSecrets)).toThrow(
"Missing required environment variable: REVENUECAT_ANNUAL_PRODUCT_ID"
);
});
});

View file

@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import {
mapAppleProductToPlan,
normalizeAppleSubscriptionStatus,
toAppleBillingCandidate,
type AppleBillingCandidate,
} from "../../helpers/appleBilling.js";
describe("apple billing helpers", () => {
const productConfig = {
annualProductId: "annual_ios",
soloProductId: "solo_ios_monthly",
};
it("maps the configured solo product id to the solo billing plan", () => {
expect(mapAppleProductToPlan("solo_ios_monthly", productConfig)).toBe("solo");
});
it("maps the configured annual product id to the annual billing plan", () => {
expect(mapAppleProductToPlan("annual_ios", productConfig)).toBe("annual");
});
it("returns null for unmapped products", () => {
expect(mapAppleProductToPlan("team_ios_monthly", productConfig)).toBeNull();
});
it("normalizes expiration-like events to expired", () => {
expect(normalizeAppleSubscriptionStatus("EXPIRATION", false)).toBe("expired");
expect(normalizeAppleSubscriptionStatus("CANCELLATION", true)).toBe("canceled");
});
it("treats initial purchases as active until further lifecycle changes arrive", () => {
expect(normalizeAppleSubscriptionStatus("INITIAL_PURCHASE", false)).toBe("active");
expect(normalizeAppleSubscriptionStatus("RENEWAL", false)).toBe("active");
});
it("always shapes Apple billing candidates with quantity one", () => {
const candidate = toAppleBillingCandidate({
currentPeriodEnd: "2026-06-01T00:00:00.000Z",
plan: "annual",
status: "active",
}) as AppleBillingCandidate;
expect(candidate).toMatchObject({
plan: "annual",
quantity: 1,
status: "active",
});
expect(candidate.currentPeriodEnd).toBe(Date.parse("2026-06-01T00:00:00.000Z") / 1000);
});
});

View file

@ -4,6 +4,7 @@ import {
getOrganizationOwner,
getTrialWindow,
parseTrialRolloutDate,
selectBestBillingCandidate,
} from "../../helpers/billing.js";
describe("billing helpers", () => {
@ -91,4 +92,54 @@ describe("billing helpers", () => {
expect(result.trialEndDate.toISOString()).toBe("2026-03-26T00:00:00.000Z");
expect(result.isTrialExpired).toBe(true);
});
it("prefers annual access over lower-tier plans when selecting the winning entitlement", () => {
const winner = selectBestBillingCandidate(
[
{
currentPeriodEnd: 100,
plan: "solo",
quantity: 1,
status: "active",
},
{
currentPeriodEnd: 50,
plan: "annual",
quantity: 1,
status: "canceled",
},
],
"solo"
);
expect(winner).toEqual({ plan: "annual", quantity: 1 });
});
it("prefers team over solo when both are valid", () => {
const winner = selectBestBillingCandidate(
[
{
currentPeriodEnd: 100,
plan: "solo",
quantity: 1,
status: "active",
},
{
currentPeriodEnd: 80,
plan: "team",
quantity: 3,
status: "active",
},
],
"solo"
);
expect(winner).toEqual({ plan: "team", quantity: 3 });
});
it("returns no active subscription when no billing candidates exist", () => {
const winner = selectBestBillingCandidate([], "team");
expect(winner).toEqual({ plan: null, quantity: 0 });
});
});

View file

@ -0,0 +1,108 @@
import { describe, expect, it, vi } from "vitest";
import { loadOrganizationMembers } from "../../helpers/organizationMembers.js";
const buildSupabase = ({
fallbackResult,
primaryResult,
}: {
primaryResult: { data: unknown; error: { message: string } | null };
fallbackResult?: { data: unknown; error: { message: string } | null };
}) => {
const single = vi.fn().mockResolvedValue(
fallbackResult ?? {
data: null,
error: { message: "fallback failed" },
}
);
const order = vi.fn().mockResolvedValue(primaryResult);
const primaryEq = vi.fn(() => ({ order }));
const fallbackEq = vi.fn(() => ({ single }));
const select = vi
.fn()
.mockImplementationOnce(() => ({ eq: primaryEq }))
.mockImplementationOnce(() => ({ eq: fallbackEq }));
const from = vi.fn(() => ({ select }));
return {
supabase: {
from,
},
spies: {
from,
order,
primaryEq,
fallbackEq,
select,
single,
},
};
};
describe("loadOrganizationMembers", () => {
it("returns organization members when the primary query succeeds", async () => {
const members = [{ email: "owner@example.com", id: "owner-id" }];
const { spies, supabase } = buildSupabase({
primaryResult: {
data: members,
error: null,
},
});
const result = await loadOrganizationMembers(supabase as never, {
organizationId: 42,
userId: "owner-id",
});
expect(result.data).toEqual(members);
expect(result.error).toBeNull();
expect(spies.from).toHaveBeenCalledTimes(1);
expect(spies.primaryEq).toHaveBeenCalledWith("organization_id", 42);
expect(spies.order).toHaveBeenCalledWith("created_at", { ascending: true });
});
it("falls back to the current user profile when the members query fails", async () => {
const fallbackMember = { email: "owner@example.com", id: "owner-id" };
const { spies, supabase } = buildSupabase({
primaryResult: {
data: null,
error: { message: "members query failed" },
},
fallbackResult: {
data: fallbackMember,
error: null,
},
});
const result = await loadOrganizationMembers(supabase as never, {
organizationId: 42,
userId: "owner-id",
});
expect(result.data).toEqual([fallbackMember]);
expect(result.error).toBeNull();
expect(spies.from).toHaveBeenCalledTimes(2);
expect(spies.fallbackEq).toHaveBeenCalledWith("id", "owner-id");
expect(spies.single).toHaveBeenCalledTimes(1);
});
it("returns the fallback error when both queries fail", async () => {
const { supabase } = buildSupabase({
primaryResult: {
data: null,
error: { message: "members query failed" },
},
fallbackResult: {
data: null,
error: { message: "fallback profile failed" },
},
});
const result = await loadOrganizationMembers(supabase as never, {
organizationId: 42,
userId: "owner-id",
});
expect(result.data).toBeNull();
expect(result.error).toEqual({ message: "fallback profile failed" });
});
});

View file

@ -0,0 +1,145 @@
import { createClient } from "@supabase/supabase-js";
import { testClient } from "hono/testing";
import { beforeEach, describe, expect, it } from "vitest";
import { createConfig } from "../../config.js";
import { MiddlewareManager } from "../../middlewares/middleware.js";
import { getMainRouter } from "../../routers/index.js";
import { getTestUser } from "../helpers/testUtils.js";
describe("RevenueCat Webhook Endpoint", () => {
const config = createConfig();
MiddlewareManager.initialize(config);
const app = getMainRouter(config);
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
const client = testClient(app) as any;
const ownerUser = getTestUser("owner");
const adminClient = createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY, {
auth: {
autoRefreshToken: false,
persistSession: false,
},
});
beforeEach(async () => {
await adminClient.from("apple_subscription_events").delete().neq("id", 0);
await adminClient.from("apple_subscriptions").delete().eq("owner_user_id", ownerUser.userId);
await adminClient.from("apple_customers").delete().eq("user_id", ownerUser.userId);
});
it("rejects webhook calls without the configured authorization header", async () => {
const res = await client["revenuecat-webhook"].$post({
json: {
event: {
id: "unauthorized-event",
type: "INITIAL_PURCHASE",
},
},
});
expect(res.status).toBe(401);
});
it("persists a mapped Apple subscription once and treats retries as duplicates", async () => {
const eventId = `event-${Date.now()}`;
const originalTransactionId = `orig-${Date.now()}`;
const payload = {
event: {
aliases: [],
app_user_id: ownerUser.userId,
environment: "SANDBOX",
expiration_at_ms: Date.now() + 86400000,
id: eventId,
original_app_user_id: ownerUser.userId,
original_transaction_id: originalTransactionId,
product_id: config.REVENUECAT_SOLO_PRODUCT_ID,
purchased_at_ms: Date.now(),
store: "APP_STORE",
transaction_id: `txn-${Date.now()}`,
type: "INITIAL_PURCHASE",
},
};
const firstRes = await client["revenuecat-webhook"].$post(
{ json: payload },
{
headers: {
authorization: config.REVENUECAT_WEBHOOK_AUTH_HEADER,
"Content-Type": "application/json",
},
}
);
expect(firstRes.status).toBe(200);
const secondRes = await client["revenuecat-webhook"].$post(
{ json: payload },
{
headers: {
authorization: config.REVENUECAT_WEBHOOK_AUTH_HEADER,
"Content-Type": "application/json",
},
}
);
expect(secondRes.status).toBe(200);
expect(await secondRes.json()).toMatchObject({ duplicate: true, received: true });
const { data: appleSubscriptions } = await adminClient
.from("apple_subscriptions")
.select("owner_user_id, plan, status, original_transaction_id")
.eq("original_transaction_id", originalTransactionId);
expect(appleSubscriptions).toHaveLength(1);
expect(appleSubscriptions?.[0]).toMatchObject({
owner_user_id: ownerUser.userId,
plan: "solo",
status: "active",
original_transaction_id: originalTransactionId,
});
const { data: appleEvents } = await adminClient
.from("apple_subscription_events")
.select("event_id")
.eq("event_id", eventId);
expect(appleEvents).toHaveLength(1);
});
it("ignores unmapped products without creating entitlement rows", async () => {
const res = await client["revenuecat-webhook"].$post(
{
json: {
event: {
app_user_id: ownerUser.userId,
environment: "SANDBOX",
expiration_at_ms: Date.now() + 86400000,
id: `ignored-${Date.now()}`,
original_transaction_id: `orig-ignored-${Date.now()}`,
product_id: "team_ios_monthly",
purchased_at_ms: Date.now(),
store: "APP_STORE",
transaction_id: `txn-ignored-${Date.now()}`,
type: "INITIAL_PURCHASE",
},
},
},
{
headers: {
authorization: config.REVENUECAT_WEBHOOK_AUTH_HEADER,
"Content-Type": "application/json",
},
}
);
expect(res.status).toBe(200);
expect(await res.json()).toMatchObject({ ignored: true, reason: "unmapped_product" });
const { data: rows } = await adminClient
.from("apple_subscriptions")
.select("id")
.eq("owner_user_id", ownerUser.userId);
expect(rows ?? []).toHaveLength(0);
});
});

View file

@ -226,6 +226,84 @@ describe("User Endpoint", () => {
});
});
describe("GET /organization - Billing State", () => {
it("returns Apple-backed active access for the authenticated owner", async () => {
const adminClient = createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY, {
auth: {
autoRefreshToken: false,
persistSession: false,
},
});
const organizationName = `Apple Billing Org ${Date.now()}`;
const originalTransactionId = `org-apple-${Date.now()}`;
const { data: organization, error: organizationError } = await adminClient
.from("organizations")
.insert({
name: organizationName,
})
.select("id")
.single();
expect(organizationError).toBeNull();
const organizationId = organization?.id as number;
const { error: profileUpdateError } = await adminClient
.from("profiles")
.update({ organization_id: organizationId })
.eq("id", ownerUser.userId);
expect(profileUpdateError).toBeNull();
await adminClient.from("apple_subscriptions").delete().eq("owner_user_id", ownerUser.userId);
await adminClient.from("apple_customers").delete().eq("user_id", ownerUser.userId);
const { error: customerError } = await adminClient.from("apple_customers").insert({
revenuecat_app_user_id: ownerUser.userId,
user_id: ownerUser.userId,
});
expect(customerError).toBeNull();
const { error: subscriptionError } = await adminClient.from("apple_subscriptions").insert({
current_period_end: new Date(Date.now() + 86400000).toISOString(),
current_period_start: new Date().toISOString(),
environment: "SANDBOX",
original_transaction_id: originalTransactionId,
owner_user_id: ownerUser.userId,
plan: "annual",
revenuecat_app_user_id: ownerUser.userId,
status: "active",
store: "APP_STORE",
store_product_id: "annual_ios",
});
expect(subscriptionError).toBeNull();
const res = await client.users.organization.$get(
{},
{
headers: {
Authorization: `Bearer ${ownerUser.accessToken}`,
"Content-Type": "application/json",
},
}
);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.active_subscription_plan).toBe("annual");
expect(data.is_billing_owner).toBe(true);
await adminClient
.from("profiles")
.update({ organization_id: null })
.eq("id", ownerUser.userId);
await adminClient
.from("apple_subscriptions")
.delete()
.eq("original_transaction_id", originalTransactionId);
await adminClient.from("apple_customers").delete().eq("user_id", ownerUser.userId);
await adminClient.from("organizations").delete().eq("id", organizationId);
});
});
// DELETE /me must run last — it hard-deletes the auth user, making ownerUser unusable for subsequent tests
describe("DELETE /me - Delete Account", () => {
it("should return 401 when unauthenticated", async () => {

View file

@ -13,6 +13,9 @@ export interface AppConfig {
STRIPE_SOLO_PRICE_ID: string;
STRIPE_TEAM_PRICE_ID: string;
STRIPE_FOUNDER_PRICE_ID: string;
REVENUECAT_WEBHOOK_AUTH_HEADER: string;
REVENUECAT_SOLO_PRODUCT_ID: string;
REVENUECAT_ANNUAL_PRODUCT_ID: string;
EMAIL_USER: string;
EMAIL_CLIENT_ID: string;
EMAIL_CLIENT_SECRET: string;
@ -133,6 +136,18 @@ export function createConfig(secrets?: Secrets): AppConfig {
"STRIPE_FOUNDER_PRICE_ID",
process.env.STRIPE_FOUNDER_PRICE_ID
),
REVENUECAT_WEBHOOK_AUTH_HEADER: validateEnvVar(
"REVENUECAT_WEBHOOK_AUTH_HEADER",
process.env.REVENUECAT_WEBHOOK_AUTH_HEADER
),
REVENUECAT_SOLO_PRODUCT_ID: validateEnvVar(
"REVENUECAT_SOLO_PRODUCT_ID",
process.env.REVENUECAT_SOLO_PRODUCT_ID
),
REVENUECAT_ANNUAL_PRODUCT_ID: validateEnvVar(
"REVENUECAT_ANNUAL_PRODUCT_ID",
process.env.REVENUECAT_ANNUAL_PRODUCT_ID
),
EMAIL_USER: validateEnvVar("EMAIL_USER", process.env.EMAIL_USER),
EMAIL_CLIENT_ID: validateEnvVar("EMAIL_CLIENT_ID", process.env.EMAIL_CLIENT_ID),
EMAIL_CLIENT_SECRET: isTestMode

View file

@ -0,0 +1,105 @@
export type AppleBillingStatus =
| "active"
| "expired"
| "canceled"
| "refunded"
| "revoked"
| "unknown";
export type AppleBillingCandidate = {
currentPeriodEnd: number;
plan: "solo" | "annual";
quantity: 1;
status: AppleBillingStatus;
};
export const APPLE_ACCESSIBLE_STATUSES: AppleBillingStatus[] = ["active", "canceled"];
export function mapAppleProductToPlan(
productId: string | null | undefined,
config: {
annualProductId: string;
soloProductId: string;
}
): "solo" | "annual" | null {
if (!productId) {
return null;
}
if (productId === config.soloProductId) {
return "solo";
}
if (productId === config.annualProductId) {
return "annual";
}
return null;
}
export function normalizeAppleSubscriptionStatus(
eventType: string | null | undefined,
cancelAtPeriodEnd: boolean
): AppleBillingStatus {
const normalizedEventType = (eventType ?? "").trim().toUpperCase();
if (normalizedEventType === "EXPIRATION") {
return "expired";
}
if (normalizedEventType === "CANCELLATION") {
return cancelAtPeriodEnd ? "canceled" : "revoked";
}
if (normalizedEventType === "BILLING_ISSUE") {
return "canceled";
}
if (normalizedEventType === "SUBSCRIPTION_PAUSED") {
return "canceled";
}
if (normalizedEventType === "UNCANCELLATION") {
return "active";
}
if (
normalizedEventType === "INITIAL_PURCHASE" ||
normalizedEventType === "NON_RENEWING_PURCHASE" ||
normalizedEventType === "PRODUCT_CHANGE" ||
normalizedEventType === "RENEWAL" ||
normalizedEventType === "TRANSFER"
) {
return "active";
}
return "unknown";
}
export function toAppleBillingCandidate(input: {
currentPeriodEnd: string | null;
now?: Date;
plan: "solo" | "annual";
status: AppleBillingStatus;
}): AppleBillingCandidate | null {
if (!APPLE_ACCESSIBLE_STATUSES.includes(input.status)) {
return null;
}
const currentPeriodEndMs = input.currentPeriodEnd ? Date.parse(input.currentPeriodEnd) : NaN;
if (Number.isNaN(currentPeriodEndMs)) {
return null;
}
const now = input.now ?? new Date();
if (currentPeriodEndMs <= now.getTime()) {
return null;
}
return {
currentPeriodEnd: Math.floor(currentPeriodEndMs / 1000),
plan: input.plan,
quantity: 1,
status: input.status,
};
}

View file

@ -1,4 +1,5 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import { toAppleBillingCandidate, type AppleBillingCandidate } from "./appleBilling.js";
export type BillingPlan = "solo" | "team" | "annual";
export type RequiredBillingPlan = "solo" | "team";
@ -41,6 +42,19 @@ type StripeProductRow = {
metadata: Record<string, string | null> | null;
};
type AppleSubscriptionRow = {
plan: string;
status: string;
current_period_end: string | null;
};
export type BillingCandidate = {
currentPeriodEnd: number;
plan: BillingPlan;
quantity: number;
status: string | null;
};
export type OrganizationBillingState = {
owner_user_id: string;
member_count: number;
@ -56,6 +70,7 @@ export type OrganizationBillingState = {
const ACTIVE_BILLING_STATUSES = ["active", "trialing", "past_due"];
const DEFAULT_BILLING_TRIAL_DAYS = 14;
const DEFAULT_BILLING_TRIAL_ROLLOUT_AT = "2026-03-08T00:00:00.000Z";
const RESTRICTED_SCHEMA_ERROR_FRAGMENT = "The schema must be one of the following";
const parseTrialDays = () => {
const parsed = Number.parseInt(process.env.BILLING_TRIAL_DAYS ?? "", 10);
@ -114,6 +129,7 @@ const statusWeight = (status: string | null | undefined) => {
if (status === "active") return 3;
if (status === "past_due") return 2;
if (status === "trialing") return 1;
if (status === "canceled") return 1;
return 0;
};
@ -147,6 +163,20 @@ const inferBillingPlan = (planHint: string | null | undefined): BillingPlan | nu
return null;
};
const normalizeApplePlan = (plan: string | null | undefined): "solo" | "annual" | null => {
const normalized = (plan ?? "").toLowerCase();
if (normalized === "annual") {
return "annual";
}
if (normalized === "solo") {
return "solo";
}
return null;
};
const normalizeProfilePlan = (plan: string | null | undefined): BillingPlan => {
const normalized = (plan ?? "").toLowerCase();
@ -195,157 +225,202 @@ const getOrganizationProfiles = async (supabase: SupabaseClient, organizationId:
return { error: null, profiles };
};
const isRestrictedSchemaError = (message: string | null | undefined) =>
Boolean(message?.includes(RESTRICTED_SCHEMA_ERROR_FRAGMENT));
export const selectBestBillingCandidate = (
candidates: BillingCandidate[],
ownerFallbackPlan: BillingPlan
): { plan: BillingPlan | null; quantity: number } => {
if (candidates.length === 0) {
return { plan: null, quantity: 0 };
}
const sortedCandidates = [...candidates].sort((a, b) => {
const byPlanWeight = planWeight(b.plan) - planWeight(a.plan);
if (byPlanWeight !== 0) return byPlanWeight;
const byStatusWeight = statusWeight(b.status) - statusWeight(a.status);
if (byStatusWeight !== 0) return byStatusWeight;
return b.currentPeriodEnd - a.currentPeriodEnd;
});
const winner = sortedCandidates[0];
return {
plan: winner.plan ?? (ownerFallbackPlan === "solo" ? "team" : ownerFallbackPlan),
quantity: winner.quantity,
};
};
const resolveActiveSubscription = async (
supabase: SupabaseClient,
ownerUserId: string,
ownerProfilePlan: string | null
): Promise<{ plan: BillingPlan | null; quantity: number }> => {
const { data: customers, error: customersError } = await supabase
.schema("stripe")
.from("customers")
.select("id, metadata")
.limit(1000);
if (customersError) {
throw new Error(customersError.message);
}
const customer = (customers as StripeCustomerRow[] | null)?.find(
(candidate) => candidate.metadata?.user_id === ownerUserId
);
if (!customer) {
return { plan: null, quantity: 0 };
}
const { data: subscriptions, error: subscriptionsError } = await supabase
.schema("stripe")
.from("subscriptions")
.select("id, status, created, current_period_end")
.eq("customer", customer.id)
.in("status", ACTIVE_BILLING_STATUSES);
if (subscriptionsError) {
throw new Error(subscriptionsError.message);
}
const normalizedSubscriptions = (subscriptions ?? []) as StripeSubscriptionRow[];
if (normalizedSubscriptions.length === 0) {
return { plan: null, quantity: 0 };
}
const ownerFallbackPlan = normalizeProfilePlan(ownerProfilePlan);
const subscriptionIds = normalizedSubscriptions.map((subscription) => subscription.id);
const { data: subscriptionItems, error: subscriptionItemsError } = await supabase
.schema("stripe")
.from("subscription_items")
.select("subscription, price, quantity")
.in("subscription", subscriptionIds);
if (subscriptionItemsError) {
throw new Error(subscriptionItemsError.message);
}
const normalizedItems = (subscriptionItems ?? []) as StripeSubscriptionItemRow[];
const priceIds = Array.from(
new Set(normalizedItems.map((item) => item.price).filter((price): price is string => !!price))
);
let pricesById = new Map<string, StripePriceRow>();
let productsById = new Map<string, StripeProductRow>();
if (priceIds.length > 0) {
const { data: prices, error: pricesError } = await supabase
let stripeCandidates: BillingCandidate[] = [];
try {
const { data: customers, error: customersError } = await supabase
.schema("stripe")
.from("prices")
.select("id, lookup_key, metadata, product")
.in("id", priceIds);
.from("customers")
.select("id, metadata")
.limit(1000);
if (pricesError) {
throw new Error(pricesError.message);
if (customersError) {
throw new Error(customersError.message);
}
const normalizedPrices = (prices ?? []) as StripePriceRow[];
pricesById = new Map(normalizedPrices.map((price) => [price.id, price]));
const productIds = Array.from(
new Set(
normalizedPrices
.map((price) => price.product)
.filter((product): product is string => Boolean(product))
)
const customer = (customers as StripeCustomerRow[] | null)?.find(
(candidate) => candidate.metadata?.user_id === ownerUserId
);
if (productIds.length > 0) {
const { data: products, error: productsError } = await supabase
if (customer) {
const { data: subscriptions, error: subscriptionsError } = await supabase
.schema("stripe")
.from("products")
.select("id, name, metadata")
.in("id", productIds);
.from("subscriptions")
.select("id, status, created, current_period_end")
.eq("customer", customer.id)
.in("status", ACTIVE_BILLING_STATUSES);
if (productsError) {
throw new Error(productsError.message);
if (subscriptionsError) {
throw new Error(subscriptionsError.message);
}
const normalizedProducts = (products ?? []) as StripeProductRow[];
productsById = new Map(normalizedProducts.map((product) => [product.id, product]));
const normalizedSubscriptions = (subscriptions ?? []) as StripeSubscriptionRow[];
if (normalizedSubscriptions.length > 0) {
const subscriptionIds = normalizedSubscriptions.map((subscription) => subscription.id);
const { data: subscriptionItems, error: subscriptionItemsError } = await supabase
.schema("stripe")
.from("subscription_items")
.select("subscription, price, quantity")
.in("subscription", subscriptionIds);
if (subscriptionItemsError) {
throw new Error(subscriptionItemsError.message);
}
const normalizedItems = (subscriptionItems ?? []) as StripeSubscriptionItemRow[];
const priceIds = Array.from(
new Set(normalizedItems.map((item) => item.price).filter((price): price is string => !!price))
);
let pricesById = new Map<string, StripePriceRow>();
let productsById = new Map<string, StripeProductRow>();
if (priceIds.length > 0) {
const { data: prices, error: pricesError } = await supabase
.schema("stripe")
.from("prices")
.select("id, lookup_key, metadata, product")
.in("id", priceIds);
if (pricesError) {
throw new Error(pricesError.message);
}
const normalizedPrices = (prices ?? []) as StripePriceRow[];
pricesById = new Map(normalizedPrices.map((price) => [price.id, price]));
const productIds = Array.from(
new Set(
normalizedPrices
.map((price) => price.product)
.filter((product): product is string => Boolean(product))
)
);
if (productIds.length > 0) {
const { data: products, error: productsError } = await supabase
.schema("stripe")
.from("products")
.select("id, name, metadata")
.in("id", productIds);
if (productsError) {
throw new Error(productsError.message);
}
const normalizedProducts = (products ?? []) as StripeProductRow[];
productsById = new Map(normalizedProducts.map((product) => [product.id, product]));
}
}
stripeCandidates = normalizedSubscriptions.flatMap((subscription) => {
const relatedItems = normalizedItems.filter((item) => item.subscription === subscription.id);
if (relatedItems.length === 0) {
return [
{
currentPeriodEnd: subscription.current_period_end ?? subscription.created ?? 0,
plan: ownerFallbackPlan === "solo" ? "team" : ownerFallbackPlan,
quantity: 1,
status: subscription.status,
},
];
}
return relatedItems.map((item) => {
const price = item.price ? pricesById.get(item.price) : undefined;
const product = price?.product ? productsById.get(price.product) : undefined;
const hint = getPlanHint(price, product);
return {
currentPeriodEnd: subscription.current_period_end ?? subscription.created ?? 0,
plan:
inferBillingPlan(hint) ??
(ownerFallbackPlan === "solo" ? "team" : ownerFallbackPlan),
quantity: Math.max(1, item.quantity ?? 1),
status: subscription.status,
};
});
});
}
}
} catch (error) {
if (!isRestrictedSchemaError(error instanceof Error ? error.message : null)) {
throw error;
}
}
const candidates = normalizedSubscriptions.flatMap((subscription) => {
const relatedItems = normalizedItems.filter((item) => item.subscription === subscription.id);
const { data: appleSubscriptions, error: appleSubscriptionsError } = await supabase
.from("apple_subscriptions")
.select("plan, status, current_period_end")
.eq("owner_user_id", ownerUserId);
if (appleSubscriptionsError) {
throw new Error(appleSubscriptionsError.message);
}
const appleCandidates: BillingCandidate[] = ((appleSubscriptions ?? []) as AppleSubscriptionRow[])
.flatMap((subscription) => {
const plan = normalizeApplePlan(subscription.plan);
if (!plan) {
return [];
}
const candidate = toAppleBillingCandidate({
currentPeriodEnd: subscription.current_period_end,
plan,
status: subscription.status as AppleBillingCandidate["status"],
});
if (!candidate) {
return [];
}
if (relatedItems.length === 0) {
return [
{
subscription,
plan: null as BillingPlan | null,
quantity: 1,
currentPeriodEnd: candidate.currentPeriodEnd,
plan: candidate.plan,
quantity: candidate.quantity,
status: candidate.status,
},
];
}
return relatedItems.map((item) => {
const price = item.price ? pricesById.get(item.price) : undefined;
const product = price?.product ? productsById.get(price.product) : undefined;
const hint = getPlanHint(price, product);
return {
subscription,
plan: inferBillingPlan(hint),
quantity: Math.max(1, item.quantity ?? 1),
};
});
});
if (candidates.length === 0) {
return { plan: ownerFallbackPlan === "solo" ? "team" : ownerFallbackPlan, quantity: 1 };
}
candidates.sort((a, b) => {
const aPlan = a.plan ?? "team";
const bPlan = b.plan ?? "team";
const byPlanWeight = planWeight(bPlan) - planWeight(aPlan);
if (byPlanWeight !== 0) return byPlanWeight;
const byStatusWeight =
statusWeight(b.subscription.status) - statusWeight(a.subscription.status);
if (byStatusWeight !== 0) return byStatusWeight;
const aPeriodEnd = a.subscription.current_period_end ?? a.subscription.created ?? 0;
const bPeriodEnd = b.subscription.current_period_end ?? b.subscription.created ?? 0;
return bPeriodEnd - aPeriodEnd;
});
const winner = candidates[0];
return {
// Keep legacy fallback for unknown paid subscriptions but prefer owner profile plan
// so founder/beta organizations do not downgrade to team because of metadata gaps.
plan: winner.plan ?? (ownerFallbackPlan === "solo" ? "team" : ownerFallbackPlan),
quantity: winner.quantity,
};
return selectBestBillingCandidate([...stripeCandidates, ...appleCandidates], ownerFallbackPlan);
};
export const getOrganizationBillingState = async (

View file

@ -0,0 +1,56 @@
import type { SupabaseClient } from "@supabase/supabase-js";
export type OrganizationMember = {
avatar_url: string | null;
created_at: string;
email: string | null;
first_name: string | null;
id: string;
last_name: string | null;
name: string | null;
plan: string | null;
};
type LoadOrganizationMembersOptions = {
organizationId: number;
userId: string;
};
const organizationMemberSelect =
"id, email, name, first_name, last_name, avatar_url, created_at, plan";
export const loadOrganizationMembers = async (
supabase: SupabaseClient,
{ organizationId, userId }: LoadOrganizationMembersOptions
) => {
const { data, error } = await supabase
.from("profiles")
.select(organizationMemberSelect)
.eq("organization_id", organizationId)
.order("created_at", { ascending: true });
if (!error) {
return {
data: (data as OrganizationMember[] | null) ?? [],
error: null,
};
}
const fallback = await supabase
.from("profiles")
.select(organizationMemberSelect)
.eq("id", userId)
.single();
if (fallback.error || !fallback.data) {
return {
data: null,
error: fallback.error ?? error,
};
}
return {
data: [fallback.data as OrganizationMember],
error: null,
};
};

View file

@ -9,6 +9,7 @@ import { getPublicClientInvitesRouter } from "./clientInvites.js";
import { getClientPortalRouter } from "./clientPortal.js";
import { getMaybeAuthenticatedRouter } from "./maybeAuthRouter.js";
import { getPublicRouter } from "./public.js";
import { getRevenueCatWebhookRouter } from "./revenuecat.js";
import { getStripeWebhookRouter } from "./stripe.js";
import { getTaskRouter } from "./tasks.js";
@ -33,6 +34,7 @@ export const getMainRouter = (config: AppConfig) => {
mainRouter.route("/tasks", getTaskRouter());
// webhooks
mainRouter.route("/revenuecat-webhook", getRevenueCatWebhookRouter(config));
mainRouter.route("/stripe-webhook", getStripeWebhookRouter());
// admin routes

View file

@ -0,0 +1,223 @@
import { Hono } from "hono";
import { createFactory } from "hono/factory";
import type { AppConfig } from "../config.js";
import {
mapAppleProductToPlan,
normalizeAppleSubscriptionStatus,
} from "../helpers/appleBilling.js";
import type { BaseEnv } from "../types/app.types.js";
type RevenueCatWebhookEvent = {
aliases?: string[] | null;
app_user_id?: string | null;
environment?: string | null;
expiration_at_ms?: number | null;
id?: string | null;
original_app_user_id?: string | null;
original_transaction_id?: string | null;
product_id?: string | null;
purchased_at_ms?: number | null;
store?: string | null;
transaction_id?: string | null;
type?: string | null;
};
type RevenueCatWebhookBody = {
api_version?: string;
event?: RevenueCatWebhookEvent;
};
const factory = createFactory<BaseEnv>();
const UUID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const toIsoTimestamp = (value: number | null | undefined) =>
typeof value === "number" && Number.isFinite(value) ? new Date(value).toISOString() : null;
const collectCandidateAppUserIds = (event: RevenueCatWebhookEvent) => {
const uniqueIds = new Set<string>();
for (const rawValue of [event.app_user_id, event.original_app_user_id, ...(event.aliases ?? [])]) {
const value = rawValue?.trim();
if (!value || !UUID_PATTERN.test(value)) {
continue;
}
uniqueIds.add(value);
}
return Array.from(uniqueIds);
};
const resolveUserIdFromRevenueCatEvent = async (
supabase: BaseEnv["Variables"]["supabase"],
event: RevenueCatWebhookEvent
) => {
const candidateIds = collectCandidateAppUserIds(event);
if (candidateIds.length > 0) {
const { data: profiles, error } = await supabase
.from("profiles")
.select("id")
.in("id", candidateIds)
.limit(1);
if (error) {
throw new Error(error.message);
}
const directMatch = (profiles ?? [])[0];
if (directMatch?.id) {
return directMatch.id as string;
}
}
const lookupIds = [event.app_user_id, event.original_app_user_id, ...(event.aliases ?? [])].filter(
(value): value is string => Boolean(value?.trim())
);
if (lookupIds.length === 0) {
return null;
}
const { data: appleCustomers, error: appleCustomersError } = await supabase
.from("apple_customers")
.select("user_id, revenuecat_app_user_id")
.in("revenuecat_app_user_id", lookupIds)
.limit(1);
if (appleCustomersError) {
throw new Error(appleCustomersError.message);
}
return ((appleCustomers ?? [])[0]?.user_id as string | undefined) ?? null;
};
const handleRevenueCatWebhook = (config: AppConfig) =>
factory.createHandlers(async (c) => {
const authorization = c.req.header("authorization");
if (authorization !== config.REVENUECAT_WEBHOOK_AUTH_HEADER) {
return c.json({ error: "Unauthorized" }, 401);
}
const body = (await c.req.json()) as RevenueCatWebhookBody;
const event = body.event;
if (!event?.id || !event.type) {
return c.json({ error: "Invalid RevenueCat payload" }, 400);
}
const supabase = c.get("supabase");
const { data: existingEvent, error: existingEventError } = await supabase
.from("apple_subscription_events")
.select("id")
.eq("event_id", event.id)
.maybeSingle();
if (existingEventError) {
return c.json({ error: existingEventError.message }, 500);
}
if (existingEvent?.id) {
return c.json({ received: true, duplicate: true });
}
const { error: insertEventError } = await supabase.from("apple_subscription_events").insert({
environment: event.environment ?? null,
event_id: event.id,
event_type: event.type,
payload: body,
});
if (insertEventError) {
return c.json({ error: insertEventError.message }, 500);
}
const mappedPlan = mapAppleProductToPlan(event.product_id, {
annualProductId: config.REVENUECAT_ANNUAL_PRODUCT_ID,
soloProductId: config.REVENUECAT_SOLO_PRODUCT_ID,
});
if (!mappedPlan) {
await supabase
.from("apple_subscription_events")
.update({ processed_at: new Date().toISOString() })
.eq("event_id", event.id);
return c.json({ ignored: true, received: true, reason: "unmapped_product" });
}
const ownerUserId = await resolveUserIdFromRevenueCatEvent(supabase, event);
if (!ownerUserId) {
await supabase
.from("apple_subscription_events")
.update({ processed_at: new Date().toISOString() })
.eq("event_id", event.id);
return c.json({ ignored: true, received: true, reason: "unknown_user" });
}
const normalizedStatus = normalizeAppleSubscriptionStatus(event.type, event.type === "CANCELLATION");
const revenuecatAppUserId = event.app_user_id ?? event.original_app_user_id ?? ownerUserId;
const currentPeriodEnd = toIsoTimestamp(event.expiration_at_ms);
const currentPeriodStart = toIsoTimestamp(event.purchased_at_ms);
const { error: appleCustomerError } = await supabase.from("apple_customers").upsert(
{
last_seen_environment: event.environment ?? null,
original_app_user_id: event.original_app_user_id ?? null,
revenuecat_app_user_id: revenuecatAppUserId,
updated_at: new Date().toISOString(),
user_id: ownerUserId,
},
{ onConflict: "user_id" }
);
if (appleCustomerError) {
return c.json({ error: appleCustomerError.message }, 500);
}
if (event.original_transaction_id) {
const { error: appleSubscriptionError } = await supabase.from("apple_subscriptions").upsert(
{
cancel_at_period_end: normalizedStatus === "canceled",
current_period_end: currentPeriodEnd,
current_period_start: currentPeriodStart,
environment: event.environment ?? "PRODUCTION",
last_event_type: event.type,
original_transaction_id: event.original_transaction_id,
owner_user_id: ownerUserId,
plan: mappedPlan,
raw_customer_id: event.app_user_id ?? null,
revenuecat_app_user_id: revenuecatAppUserId,
revoked_at:
normalizedStatus === "revoked" || normalizedStatus === "refunded"
? new Date().toISOString()
: null,
status: normalizedStatus,
store: event.store ?? "APP_STORE",
store_product_id: event.product_id ?? "",
transaction_id: event.transaction_id ?? null,
updated_at: new Date().toISOString(),
},
{ onConflict: "original_transaction_id" }
);
if (appleSubscriptionError) {
return c.json({ error: appleSubscriptionError.message }, 500);
}
}
await supabase
.from("apple_subscription_events")
.update({ processed_at: new Date().toISOString() })
.eq("event_id", event.id);
return c.json({ received: true });
});
export const getRevenueCatWebhookRouter = (config: AppConfig) => {
const router = new Hono<BaseEnv>();
router.post("/", ...handleRevenueCatWebhook(config));
return router;
};

View file

@ -4,6 +4,7 @@ import { Hono } from "hono";
import { createFactory } from "hono/factory";
import { getOrganizationBillingState } from "../helpers/billing.js";
import { createInvitedUser, getOrganizationPlan, MAX_TABLO_LIMIT } from "../helpers/helpers.js";
import { loadOrganizationMembers } from "../helpers/organizationMembers.js";
import { deleteOrgIcons, uploadOrgIcons } from "../helpers/orgIcons.js";
import type { AuthEnv } from "../types/app.types.js";
@ -198,11 +199,10 @@ const getOrganization = factory.createHandlers(async (c) => {
return c.json({ error: "Organization not found" }, 404);
}
const { data: members, error: membersError } = await supabase
.from("profiles")
.select("id, email, name, first_name, last_name, avatar_url, created_at, plan")
.eq("organization_id", organizationId)
.order("created_at", { ascending: true });
const { data: members, error: membersError } = await loadOrganizationMembers(supabase, {
organizationId,
userId: user.id,
});
if (membersError) {
return c.json({ error: "Failed to load organization members" }, 500);

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,52 @@
create table public.apple_customers (
id bigint generated by default as identity primary key,
user_id uuid not null references public.profiles (id) on delete cascade,
revenuecat_app_user_id text not null,
original_app_user_id text,
last_seen_environment text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (user_id),
unique (revenuecat_app_user_id)
);
create table public.apple_subscriptions (
id bigint generated by default as identity primary key,
owner_user_id uuid not null references public.profiles (id) on delete cascade,
revenuecat_app_user_id text not null,
store_product_id text not null,
plan text not null,
status text not null,
environment text not null,
store text not null default 'app_store',
original_transaction_id text not null,
transaction_id text,
current_period_start timestamptz,
current_period_end timestamptz,
cancel_at_period_end boolean not null default false,
revoked_at timestamptz,
raw_customer_id text,
last_event_type text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (original_transaction_id)
);
create index idx_apple_subscriptions_owner_user_id
on public.apple_subscriptions (owner_user_id);
create index idx_apple_subscriptions_status
on public.apple_subscriptions (status);
create index idx_apple_subscriptions_current_period_end
on public.apple_subscriptions (current_period_end);
create table public.apple_subscription_events (
id bigint generated by default as identity primary key,
event_id text not null unique,
event_type text not null,
environment text,
payload jsonb not null,
received_at timestamptz not null default now(),
processed_at timestamptz
);

View file

@ -0,0 +1,101 @@
begin;
select plan(17);
-- ============================================================================
-- Table Existence
-- ============================================================================
SELECT has_table('public', 'apple_customers', 'apple_customers table should exist');
SELECT has_table('public', 'apple_subscriptions', 'apple_subscriptions table should exist');
SELECT has_table(
'public',
'apple_subscription_events',
'apple_subscription_events table should exist'
);
-- ============================================================================
-- Column Coverage
-- ============================================================================
SELECT has_column('public', 'apple_customers', 'user_id', 'apple_customers should have user_id');
SELECT has_column(
'public',
'apple_customers',
'revenuecat_app_user_id',
'apple_customers should have revenuecat_app_user_id'
);
SELECT has_column(
'public',
'apple_subscriptions',
'owner_user_id',
'apple_subscriptions should have owner_user_id'
);
SELECT has_column(
'public',
'apple_subscriptions',
'original_transaction_id',
'apple_subscriptions should have original_transaction_id'
);
SELECT has_column(
'public',
'apple_subscription_events',
'event_id',
'apple_subscription_events should have event_id'
);
-- ============================================================================
-- Primary Keys + Constraints
-- ============================================================================
SELECT has_pk('public', 'apple_customers', 'apple_customers should have primary key');
SELECT has_pk('public', 'apple_subscriptions', 'apple_subscriptions should have primary key');
SELECT has_pk(
'public',
'apple_subscription_events',
'apple_subscription_events should have primary key'
);
SELECT has_index(
'public',
'apple_customers',
'apple_customers_user_id_key',
'apple_customers.user_id unique constraint should exist'
);
SELECT has_index(
'public',
'apple_customers',
'apple_customers_revenuecat_app_user_id_key',
'apple_customers.revenuecat_app_user_id unique constraint should exist'
);
SELECT has_index(
'public',
'apple_subscriptions',
'apple_subscriptions_original_transaction_id_key',
'apple_subscriptions.original_transaction_id unique constraint should exist'
);
-- ============================================================================
-- Performance Indexes
-- ============================================================================
SELECT has_index(
'public',
'apple_subscriptions',
'idx_apple_subscriptions_owner_user_id',
'apple_subscriptions owner_user_id index should exist'
);
SELECT has_index(
'public',
'apple_subscriptions',
'idx_apple_subscriptions_status',
'apple_subscriptions status index should exist'
);
SELECT has_index(
'public',
'apple_subscriptions',
'idx_apple_subscriptions_current_period_end',
'apple_subscriptions current_period_end index should exist'
);
select * from finish();
rollback;

View file

@ -1,4 +1,4 @@
import React, { useState } from "react";
import React, { useEffect, useMemo, useState } from "react";
import {
View,
Text,
@ -9,6 +9,7 @@ import {
Switch,
Alert,
Linking,
ActivityIndicator,
} from "react-native";
import { LinearGradient } from "expo-linear-gradient";
import { useAuthStore } from "@/stores/auth";
@ -38,11 +39,26 @@ import {
} from "@/constants/colors";
import { useThemeColor } from "@/hooks/useThemeColor";
import { useColorScheme } from "@/hooks/useColorScheme";
import {
hasOrganizationBillingAccess,
shouldShowInAppBillingPaywall,
useOrganizationBilling,
} from "@/hooks/organization";
import { BillingPaywall } from "@/components/BillingPaywall";
import {
canUseInAppPurchases,
getBillingPackageOptions,
isPurchaseCancelledError,
purchaseBillingPackage,
restoreBillingPurchases,
type BillingPackageOption,
} from "@/lib/purchases";
export default function SettingsScreen() {
const signOut = useAuthStore((state) => state.signOut);
const user = useUser();
const colorScheme = useColorScheme();
const organizationBillingQuery = useOrganizationBilling();
// Theme-aware colors
const backgroundColor = useThemeColor({ light: "#f8fafc", dark: "#111827" }, "background");
@ -61,8 +77,72 @@ export default function SettingsScreen() {
const [pushNotifications, setPushNotifications] = useState(true);
const [emailNotifications, setEmailNotifications] = useState(true);
const [biometricAuth, setBiometricAuth] = useState(false);
const [billingPackages, setBillingPackages] = useState<BillingPackageOption[]>([]);
const [billingError, setBillingError] = useState<string | null>(null);
const [isLoadingPackages, setIsLoadingPackages] = useState(false);
const [isPurchasing, setIsPurchasing] = useState(false);
const [isRestoring, setIsRestoring] = useState(false);
const [isSyncingBilling, setIsSyncingBilling] = useState(false);
const isDeletingAccount = React.useRef(false);
const organizationBilling = organizationBillingQuery.data;
const hasBillingAccess = organizationBilling
? hasOrganizationBillingAccess(organizationBilling)
: false;
const canShowInAppPaywall = organizationBilling
? shouldShowInAppBillingPaywall(organizationBilling)
: false;
const visibleBillingPackages = useMemo(() => {
if (!organizationBilling) {
return [];
}
return billingPackages.filter((pkg) =>
organizationBilling.required_plan === "team" ? pkg.plan === "annual" : true
);
}, [billingPackages, organizationBilling]);
useEffect(() => {
let cancelled = false;
const loadBillingPackages = async () => {
if (!user.id || !canShowInAppPaywall || !canUseInAppPurchases()) {
setBillingPackages([]);
setBillingError(null);
setIsLoadingPackages(false);
return;
}
setBillingError(null);
setIsLoadingPackages(true);
try {
const packages = await getBillingPackageOptions(user.id);
if (!cancelled) {
setBillingPackages(packages);
}
} catch (error) {
console.error("Failed to load RevenueCat packages:", error);
if (!cancelled) {
setBillingError(
"Impossible de charger les offres Apple pour le moment. Réessayez dans quelques instants."
);
}
} finally {
if (!cancelled) {
setIsLoadingPackages(false);
}
}
};
loadBillingPackages();
return () => {
cancelled = true;
};
}, [canShowInAppPaywall, user.id]);
const handleSignOut = () => {
Alert.alert("Déconnexion", "Êtes-vous sûr de vouloir vous déconnecter ?", [
{
@ -197,6 +277,162 @@ export default function SettingsScreen() {
false
);
const waitForBillingSync = async () => {
setIsSyncingBilling(true);
setBillingError(null);
try {
for (let attempt = 0; attempt < 6; attempt += 1) {
const result = await organizationBillingQuery.refetch();
if (result.data && hasOrganizationBillingAccess(result.data)) {
return true;
}
if (attempt < 5) {
await new Promise((resolve) => setTimeout(resolve, 2000));
}
}
setBillingError(
"L'achat a bien ete recu, mais la synchronisation serveur prend plus de temps que prevu."
);
return false;
} finally {
setIsSyncingBilling(false);
}
};
const handlePurchase = async (pkg: BillingPackageOption) => {
if (!user.id || isPurchasing || isSyncingBilling) {
return;
}
setBillingError(null);
setIsPurchasing(true);
try {
await purchaseBillingPackage(user.id, pkg.package);
await waitForBillingSync();
} catch (error) {
if (!isPurchaseCancelledError(error)) {
console.error("Apple purchase failed:", error);
setBillingError("Le paiement Apple a echoue. Merci de reessayer.");
}
} finally {
setIsPurchasing(false);
}
};
const handleRestorePurchases = async () => {
if (!user.id || isRestoring || isSyncingBilling) {
return;
}
setBillingError(null);
setIsRestoring(true);
try {
await restoreBillingPurchases(user.id);
await waitForBillingSync();
} catch (error) {
console.error("Apple restore failed:", error);
setBillingError("La restauration des achats a echoue. Merci de reessayer.");
} finally {
setIsRestoring(false);
}
};
const renderBillingContent = () => {
if (organizationBillingQuery.isLoading) {
return (
<View style={styles.billingBox}>
<ActivityIndicator />
<Text style={[styles.billingHelperText, { color: subtitleColor }]}>
Chargement de votre statut d&apos;abonnement
</Text>
</View>
);
}
if (organizationBillingQuery.error || !organizationBilling) {
return (
<View style={styles.billingBox}>
<Text style={styles.billingErrorText}>
Impossible de charger la facturation de l&apos;organisation.
</Text>
</View>
);
}
if (hasBillingAccess && organizationBilling.active_subscription_plan) {
const subscriptionLabel =
organizationBilling.active_subscription_plan === "annual"
? "Annuel"
: organizationBilling.active_subscription_plan === "team"
? "Equipe"
: "Solo";
return renderSettingsItem(
<Shield size={20} color={PRIMARY} />,
"Abonnement actif",
`${subscriptionLabel}${organizationBilling.organization.name}`,
undefined,
undefined,
false
);
}
if (hasBillingAccess && !organizationBilling.is_trial_expired) {
const trialEndsAt = new Date(organizationBilling.trial_ends_at).toLocaleDateString("fr-FR", {
day: "numeric",
month: "long",
});
return renderSettingsItem(
<Shield size={20} color={PRIMARY} />,
"Essai actif",
`Votre acces d'essai reste actif jusqu'au ${trialEndsAt}.`,
undefined,
undefined,
false
);
}
if (!canUseInAppPurchases()) {
return (
<View style={styles.billingBox}>
<Text style={[styles.billingHelperText, { color: subtitleColor }]}>
Les achats integres ne sont disponibles que sur iPhone avec une build de developpement
native.
</Text>
</View>
);
}
return (
<View style={styles.billingBox}>
<BillingPaywall
canManageBillingInApp={canShowInAppPaywall}
errorMessage={
visibleBillingPackages.length === 0 &&
!isLoadingPackages &&
!billingError &&
canShowInAppPaywall
? "Aucune offre Apple n'est disponible pour ce compte pour le moment."
: billingError
}
isLoadingPackages={isLoadingPackages || isPurchasing}
isRestoring={isRestoring}
isSyncing={isSyncingBilling}
onPurchase={handlePurchase}
onRestore={handleRestorePurchases}
packages={visibleBillingPackages}
requiredPlan={organizationBilling.required_plan}
/>
</View>
);
};
return (
<View style={[styles.container, { backgroundColor }]}>
<StatusBar
@ -239,6 +475,8 @@ export default function SettingsScreen() {
</>
)}
{renderSettingsSection("Abonnement", renderBillingContent())}
{/* {renderSettingsSection(
"Notifications",
<>
@ -531,4 +769,18 @@ const styles = StyleSheet.create({
bottomSpacing: {
height: 100,
},
billingBox: {
paddingHorizontal: 20,
paddingVertical: 20,
},
billingErrorText: {
color: "#b91c1c",
fontSize: 14,
lineHeight: 20,
},
billingHelperText: {
fontSize: 14,
lineHeight: 20,
marginTop: 10,
},
});

View file

@ -1,5 +1,5 @@
import React, { useState } from "react";
import { StyleSheet, View, Text, Image } from "react-native";
import { Alert, StyleSheet, View, Text, Image } from "react-native";
import { Button, Input } from "@rn-vui/themed";
import { useAuthStore } from "@/stores/auth";
import { Link } from "expo-router";
@ -30,6 +30,19 @@ export default function SignUp() {
? require("@/assets/images/logo_white.png")
: require("@/assets/images/logo.png");
const handleSignUp = async () => {
try {
await signUp(email, password, firstName, lastName, companyName);
} catch (error) {
Alert.alert(
"Erreur",
error instanceof Error
? error.message
: "Impossible de créer votre compte pour le moment."
);
}
};
return (
<View style={[styles.container, { backgroundColor }]}>
<Image source={logo} style={styles.logo} />
@ -91,7 +104,7 @@ export default function SignUp() {
<Button
title="S'inscrire"
disabled={authLoading}
onPress={() => signUp(email, password, firstName, lastName, companyName)}
onPress={handleSignUp}
buttonStyle={styles.button}
titleStyle={styles.buttonTitle}
/>

View file

@ -0,0 +1,218 @@
import React from "react";
import {
ActivityIndicator,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
import { LinearGradient } from "expo-linear-gradient";
import type { RequiredOrganizationPlan } from "@/types/organization.types";
import type { BillingPackageOption } from "@/lib/purchases";
export type BillingPaywallProps = {
canManageBillingInApp: boolean;
errorMessage: string | null;
isLoadingPackages: boolean;
isRestoring: boolean;
isSyncing: boolean;
onPurchase: (pkg: BillingPackageOption) => void;
onRestore: () => void;
packages: BillingPackageOption[];
requiredPlan: RequiredOrganizationPlan;
};
export type { BillingPackageOption } from "@/lib/purchases";
export function BillingPaywall({
canManageBillingInApp,
errorMessage,
isLoadingPackages,
isRestoring,
isSyncing,
onPurchase,
onRestore,
packages,
requiredPlan,
}: BillingPaywallProps) {
if (!canManageBillingInApp) {
return (
<View style={styles.container}>
<Text style={styles.title}>Votre organisation n&apos;a pas encore d&apos;accès actif.</Text>
<Text style={styles.body}>
Pour activer l&apos;abonnement sur mobile, demandez au responsable de facturation de
gérer l&apos;offre depuis son compte.
</Text>
</View>
);
}
return (
<View style={styles.container}>
<Text style={styles.title}>Débloquer XTablo sur iPhone</Text>
<Text style={styles.body}>
L&apos;accès s&apos;active quand la souscription est synchronisée dans votre organisation.
</Text>
{requiredPlan === "team" ? (
<Text style={styles.notice}>
Les forfaits équipe se gèrent sur le web. L&apos;offre annuelle reste disponible ici.
</Text>
) : null}
{isLoadingPackages ? (
<View style={styles.loadingRow}>
<ActivityIndicator />
<Text style={styles.loadingText}>Chargement des offres Apple</Text>
</View>
) : null}
{!isLoadingPackages
? packages.map((pkg) => (
<TouchableOpacity
activeOpacity={0.9}
key={pkg.id}
onPress={() => onPurchase(pkg)}
style={styles.cardShell}
>
<LinearGradient
colors={pkg.plan === "annual" ? ["#0f766e", "#115e59"] : ["#1d4ed8", "#1e40af"]}
end={{ x: 1, y: 1 }}
start={{ x: 0, y: 0 }}
style={styles.card}
>
<View style={styles.cardHeader}>
<Text style={styles.cardTitle}>{pkg.title}</Text>
<Text style={styles.cardPrice}>{pkg.price}</Text>
</View>
{pkg.description ? <Text style={styles.cardDescription}>{pkg.description}</Text> : null}
</LinearGradient>
</TouchableOpacity>
))
: null}
{isSyncing ? (
<View style={styles.syncBox}>
<Text style={styles.syncTitle}>Achat reçu</Text>
<Text style={styles.syncText}>
La synchronisation avec votre organisation est en cours. Cela peut prendre quelques
secondes.
</Text>
</View>
) : null}
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
<TouchableOpacity
activeOpacity={0.75}
disabled={isRestoring || isSyncing}
onPress={onRestore}
style={styles.restoreButton}
>
<Text style={styles.restoreText}>
{isRestoring ? "Restauration en cours…" : "Restaurer mes achats"}
</Text>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
body: {
color: "#475569",
fontSize: 14,
lineHeight: 20,
},
card: {
borderRadius: 18,
gap: 10,
paddingHorizontal: 18,
paddingVertical: 18,
},
cardDescription: {
color: "rgba(255,255,255,0.85)",
fontSize: 13,
lineHeight: 18,
},
cardHeader: {
alignItems: "center",
flexDirection: "row",
justifyContent: "space-between",
},
cardPrice: {
color: "#ffffff",
fontSize: 20,
fontWeight: "700",
},
cardShell: {
marginTop: 12,
},
cardTitle: {
color: "#ffffff",
flex: 1,
fontSize: 16,
fontWeight: "700",
marginRight: 12,
},
container: {
gap: 10,
},
errorText: {
color: "#b91c1c",
fontSize: 13,
lineHeight: 18,
},
loadingRow: {
alignItems: "center",
flexDirection: "row",
gap: 10,
paddingVertical: 4,
},
loadingText: {
color: "#475569",
fontSize: 13,
},
notice: {
color: "#92400e",
fontSize: 13,
lineHeight: 18,
},
restoreButton: {
alignItems: "center",
borderColor: "#cbd5e1",
borderRadius: 14,
borderWidth: 1,
marginTop: 6,
paddingHorizontal: 16,
paddingVertical: 14,
},
restoreText: {
color: "#0f172a",
fontSize: 14,
fontWeight: "600",
},
syncBox: {
backgroundColor: "#eff6ff",
borderColor: "#bfdbfe",
borderRadius: 16,
borderWidth: 1,
paddingHorizontal: 14,
paddingVertical: 14,
},
syncText: {
color: "#1d4ed8",
fontSize: 13,
lineHeight: 18,
},
syncTitle: {
color: "#1e3a8a",
fontSize: 14,
fontWeight: "700",
marginBottom: 4,
},
title: {
color: "#0f172a",
fontSize: 18,
fontWeight: "700",
},
});

View file

@ -0,0 +1,88 @@
import * as React from "react";
import renderer from "react-test-renderer";
import {
BillingPaywall,
type BillingPackageOption,
type BillingPaywallProps,
} from "../BillingPaywall";
const basePackages: BillingPackageOption[] = [
{
description: "Parfait pour une personne",
id: "solo_ios_monthly",
package: {} as BillingPackageOption["package"],
plan: "solo",
price: "4,99 €",
title: "Solo mensuel",
},
{
description: "Accès annuel complet",
id: "annual_ios",
package: {} as BillingPackageOption["package"],
plan: "annual",
price: "49,99 €",
title: "Annuel",
},
];
const baseProps: BillingPaywallProps = {
canManageBillingInApp: true,
errorMessage: null,
isLoadingPackages: false,
isRestoring: false,
isSyncing: false,
onPurchase: jest.fn(),
onRestore: jest.fn(),
packages: basePackages,
requiredPlan: "solo",
};
const renderToText = async (props?: Partial<BillingPaywallProps>) => {
let component: renderer.ReactTestRenderer;
await renderer.act(async () => {
component = renderer.create(<BillingPaywall {...baseProps} {...props} />);
});
return JSON.stringify(component!.toJSON());
};
describe("BillingPaywall", () => {
it("renders Apple purchase actions and restore CTA for eligible owners", async () => {
const output = await renderToText();
expect(output).toContain("Débloquer XTablo sur iPhone");
expect(output).toContain("Solo mensuel");
expect(output).toContain("Restaurer mes achats");
});
it("shows a contact-owner message when the user cannot purchase in app", async () => {
const output = await renderToText({
canManageBillingInApp: false,
});
expect(output).toContain("Votre organisation n'a pas encore d'accès actif.");
expect(output).toContain("demandez au responsable");
expect(output).not.toContain("Restaurer mes achats");
});
it("surfaces the pending sync state after a successful store action", async () => {
const output = await renderToText({
isSyncing: true,
});
expect(output).toContain("Achat reçu");
expect(output).toContain("synchronisation");
});
it("keeps team purchases on the web in v1", async () => {
const output = await renderToText({
packages: basePackages.filter((pkg) => pkg.plan === "annual"),
requiredPlan: "team",
});
expect(output).toContain("Les forfaits équipe se gèrent sur le web");
expect(output).toContain("Annuel");
expect(output).not.toContain("Solo mensuel");
});
});

View file

@ -0,0 +1,104 @@
import { api } from "@/lib/api";
import {
fetchOrganizationBillingState,
hasOrganizationBillingAccess,
isOrganizationBillingQueryEnabled,
shouldShowInAppBillingPaywall,
type OrganizationBillingState,
} from "@/hooks/organization";
jest.mock("@/lib/api", () => ({
api: {
get: jest.fn(),
},
}));
jest.mock("@/stores/auth", () => ({
useAuthStore: jest.fn(),
}));
const mockedApi = api as jest.Mocked<typeof api>;
const baseOrganizationState: OrganizationBillingState = {
active_subscription_plan: null,
active_subscription_quantity: 0,
invites_sent: [],
is_billing_owner: true,
is_trial_expired: true,
members: [],
organization: {
id: 1,
logo_url: null,
member_count: 1,
name: "XTablo",
plan: "solo",
tablo_count: 0,
},
required_plan: "solo",
required_team_quantity: 1,
trial_ends_at: "2026-05-16T00:00:00.000Z",
trial_starts_at: "2026-05-02T00:00:00.000Z",
};
describe("organization billing hook helpers", () => {
beforeEach(() => {
mockedApi.get.mockReset();
});
it("fetches the organization billing payload with the current bearer token", async () => {
mockedApi.get.mockResolvedValue({ data: baseOrganizationState });
const result = await fetchOrganizationBillingState("session-token");
expect(mockedApi.get).toHaveBeenCalledWith("/api/v1/users/organization", {
headers: {
Authorization: "Bearer session-token",
},
});
expect(result).toEqual(baseOrganizationState);
});
it("enables the query only when an access token is present", () => {
expect(isOrganizationBillingQueryEnabled(null)).toBe(false);
expect(isOrganizationBillingQueryEnabled(undefined)).toBe(false);
expect(isOrganizationBillingQueryEnabled("token")).toBe(true);
});
it("treats active subscriptions and active trials as organization access", () => {
expect(hasOrganizationBillingAccess(baseOrganizationState)).toBe(false);
expect(
hasOrganizationBillingAccess({
...baseOrganizationState,
active_subscription_plan: "annual",
})
).toBe(true);
expect(
hasOrganizationBillingAccess({
...baseOrganizationState,
is_trial_expired: false,
})
).toBe(true);
});
it("shows the iOS paywall only to unpaid billing owners after the trial expires", () => {
expect(shouldShowInAppBillingPaywall(baseOrganizationState)).toBe(true);
expect(
shouldShowInAppBillingPaywall({
...baseOrganizationState,
is_billing_owner: false,
})
).toBe(false);
expect(
shouldShowInAppBillingPaywall({
...baseOrganizationState,
active_subscription_plan: "solo",
})
).toBe(false);
expect(
shouldShowInAppBillingPaywall({
...baseOrganizationState,
is_trial_expired: false,
})
).toBe(false);
});
});

View file

@ -0,0 +1,95 @@
import * as React from "react";
import renderer from "react-test-renderer";
import { Alert } from "react-native";
import SignUp from "@/app/signup";
const mockSignUp = jest.fn();
const InputMock = (props: Record<string, unknown>) => React.createElement("Input", props);
const ButtonMock = (props: Record<string, unknown>) => React.createElement("Button", props);
jest.mock("@/stores/auth", () => ({
useAuthStore: jest.fn((selector: (state: unknown) => unknown) =>
selector({
loading: false,
signUp: mockSignUp,
})
),
}));
jest.mock("@/hooks/useThemeColor", () => ({
useThemeColor: ({ light }: { light: string }) => light,
}));
jest.mock("@/hooks/useColorScheme", () => ({
useColorScheme: () => "light",
}));
jest.mock("@rn-vui/themed", () => {
return {
Button: ButtonMock,
Input: InputMock,
};
});
jest.mock("expo-router", () => {
const React = require("react");
return {
Link: (props: Record<string, unknown> & { children?: React.ReactNode }) =>
React.createElement("Link", props, props.children),
};
});
jest.mock("lucide-react-native", () => {
const React = require("react");
const Icon = () => React.createElement("Icon");
return {
Building2: Icon,
Lock: Icon,
Mail: Icon,
User: Icon,
};
});
describe("SignUp screen", () => {
beforeEach(() => {
jest.clearAllMocks();
});
it("shows the signup error to the user when registration fails", async () => {
mockSignUp.mockRejectedValueOnce(new Error("Adresse déjà utilisée"));
const alertSpy = jest.spyOn(Alert, "alert").mockImplementation(jest.fn());
let component: renderer.ReactTestRenderer;
await renderer.act(async () => {
component = renderer.create(<SignUp />);
});
const inputs = component!.root.findAllByType(InputMock);
await renderer.act(async () => {
inputs[0].props.onChangeText("Ada");
inputs[1].props.onChangeText("Lovelace");
inputs[2].props.onChangeText("Analytical Engines");
inputs[3].props.onChangeText("ada@example.com");
inputs[4].props.onChangeText("password123");
});
const button = component!.root.findByType(ButtonMock);
await renderer.act(async () => {
await button.props.onPress();
});
expect(mockSignUp).toHaveBeenCalledWith(
"ada@example.com",
"password123",
"Ada",
"Lovelace",
"Analytical Engines"
);
expect(alertSpy).toHaveBeenCalledWith("Erreur", "Adresse déjà utilisée");
});
});

View file

@ -0,0 +1,38 @@
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { useAuthStore } from "@/stores/auth";
import type { OrganizationBillingState } from "@/types/organization.types";
export type { OrganizationBillingState } from "@/types/organization.types";
export const organizationBillingQueryKey = ["organization-billing"] as const;
export const isOrganizationBillingQueryEnabled = (accessToken?: string | null) =>
Boolean(accessToken);
export const fetchOrganizationBillingState = async (accessToken: string) => {
const { data } = await api.get<OrganizationBillingState>("/api/v1/users/organization", {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return data;
};
export const hasOrganizationBillingAccess = (state: OrganizationBillingState) =>
Boolean(state.active_subscription_plan) || !state.is_trial_expired;
export const shouldShowInAppBillingPaywall = (state: OrganizationBillingState) =>
state.is_billing_owner && state.is_trial_expired && !state.active_subscription_plan;
export const useOrganizationBilling = () => {
const accessToken = useAuthStore((state) => state.session?.access_token ?? null);
return useQuery({
queryKey: organizationBillingQueryKey,
queryFn: () => fetchOrganizationBillingState(accessToken as string),
enabled: isOrganizationBillingQueryEnabled(accessToken),
staleTime: 30_000,
});
};

View file

@ -7,13 +7,211 @@ export type Json =
| Json[]
export type Database = {
// Allows to automatically instantiate createClient with right options
// instead of createClient<Database, { PostgrestVersion: 'XX' }>(URL, KEY)
__InternalSupabase: {
PostgrestVersion: "13.0.4"
graphql_public: {
Tables: {
[_ in never]: never
}
Views: {
[_ in never]: never
}
Functions: {
graphql: {
Args: {
extensions?: Json
operationName?: string
query?: string
variables?: Json
}
Returns: Json
}
}
Enums: {
[_ in never]: never
}
CompositeTypes: {
[_ in never]: never
}
}
public: {
Tables: {
admin_audit_log: {
Row: {
action: string
after: Json | null
before: Json | null
created_at: string
id: number
operator_email: string
operator_id: string
role: string
target_id: string
target_type: string
}
Insert: {
action: string
after?: Json | null
before?: Json | null
created_at?: string
id?: number
operator_email: string
operator_id: string
role: string
target_id: string
target_type: string
}
Update: {
action?: string
after?: Json | null
before?: Json | null
created_at?: string
id?: number
operator_email?: string
operator_id?: string
role?: string
target_id?: string
target_type?: string
}
Relationships: []
}
apple_customers: {
Row: {
created_at: string
id: number
last_seen_environment: string | null
original_app_user_id: string | null
revenuecat_app_user_id: string
updated_at: string
user_id: string
}
Insert: {
created_at?: string
id?: number
last_seen_environment?: string | null
original_app_user_id?: string | null
revenuecat_app_user_id: string
updated_at?: string
user_id: string
}
Update: {
created_at?: string
id?: number
last_seen_environment?: string | null
original_app_user_id?: string | null
revenuecat_app_user_id?: string
updated_at?: string
user_id?: string
}
Relationships: [
{
foreignKeyName: "apple_customers_user_id_fkey"
columns: ["user_id"]
isOneToOne: true
referencedRelation: "profiles"
referencedColumns: ["id"]
},
]
}
apple_subscription_events: {
Row: {
environment: string | null
event_id: string
event_type: string
id: number
payload: Json
processed_at: string | null
received_at: string
}
Insert: {
environment?: string | null
event_id: string
event_type: string
id?: number
payload: Json
processed_at?: string | null
received_at?: string
}
Update: {
environment?: string | null
event_id?: string
event_type?: string
id?: number
payload?: Json
processed_at?: string | null
received_at?: string
}
Relationships: []
}
apple_subscriptions: {
Row: {
cancel_at_period_end: boolean
created_at: string
current_period_end: string | null
current_period_start: string | null
environment: string
id: number
last_event_type: string | null
original_transaction_id: string
owner_user_id: string
plan: string
raw_customer_id: string | null
revenuecat_app_user_id: string
revoked_at: string | null
status: string
store: string
store_product_id: string
transaction_id: string | null
updated_at: string
}
Insert: {
cancel_at_period_end?: boolean
created_at?: string
current_period_end?: string | null
current_period_start?: string | null
environment: string
id?: number
last_event_type?: string | null
original_transaction_id: string
owner_user_id: string
plan: string
raw_customer_id?: string | null
revenuecat_app_user_id: string
revoked_at?: string | null
status: string
store?: string
store_product_id: string
transaction_id?: string | null
updated_at?: string
}
Update: {
cancel_at_period_end?: boolean
created_at?: string
current_period_end?: string | null
current_period_start?: string | null
environment?: string
id?: number
last_event_type?: string | null
original_transaction_id?: string
owner_user_id?: string
plan?: string
raw_customer_id?: string | null
revenuecat_app_user_id?: string
revoked_at?: string | null
status?: string
store?: string
store_product_id?: string
transaction_id?: string | null
updated_at?: string
}
Relationships: [
{
foreignKeyName: "apple_subscriptions_owner_user_id_fkey"
columns: ["owner_user_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
]
}
availabilities: {
Row: {
availability_data: Json
@ -84,6 +282,303 @@ export type Database = {
},
]
}
channel_read_state: {
Row: {
channel_id: string
last_read_at: string
user_id: string
}
Insert: {
channel_id: string
last_read_at?: string
user_id: string
}
Update: {
channel_id?: string
last_read_at?: string
user_id?: string
}
Relationships: [
{
foreignKeyName: "channel_read_state_channel_id_fkey"
columns: ["channel_id"]
isOneToOne: false
referencedRelation: "events_and_tablos"
referencedColumns: ["tablo_id"]
},
{
foreignKeyName: "channel_read_state_channel_id_fkey"
columns: ["channel_id"]
isOneToOne: false
referencedRelation: "tablos"
referencedColumns: ["id"]
},
{
foreignKeyName: "channel_read_state_channel_id_fkey"
columns: ["channel_id"]
isOneToOne: false
referencedRelation: "user_tablos"
referencedColumns: ["id"]
},
]
}
client_access: {
Row: {
client_id: string
created_at: string
granted_at: string
granted_by: string
id: number
revoked_at: string | null
tablo_id: string
}
Insert: {
client_id: string
created_at?: string
granted_at?: string
granted_by: string
id?: number
revoked_at?: string | null
tablo_id: string
}
Update: {
client_id?: string
created_at?: string
granted_at?: string
granted_by?: string
id?: number
revoked_at?: string | null
tablo_id?: string
}
Relationships: [
{
foreignKeyName: "client_access_client_id_fkey"
columns: ["client_id"]
isOneToOne: false
referencedRelation: "clients"
referencedColumns: ["id"]
},
{
foreignKeyName: "client_access_granted_by_fkey"
columns: ["granted_by"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
{
foreignKeyName: "client_access_tablo_id_fkey"
columns: ["tablo_id"]
isOneToOne: false
referencedRelation: "events_and_tablos"
referencedColumns: ["tablo_id"]
},
{
foreignKeyName: "client_access_tablo_id_fkey"
columns: ["tablo_id"]
isOneToOne: false
referencedRelation: "tablos"
referencedColumns: ["id"]
},
{
foreignKeyName: "client_access_tablo_id_fkey"
columns: ["tablo_id"]
isOneToOne: false
referencedRelation: "user_tablos"
referencedColumns: ["id"]
},
]
}
client_invites: {
Row: {
cancelled_at: string | null
created_at: string
expires_at: string
id: number
invite_token: string
invite_type: string
invited_by: string
invited_email: string
is_pending: boolean
setup_completed_at: string | null
tablo_id: string
used_at: string | null
}
Insert: {
cancelled_at?: string | null
created_at?: string
expires_at?: string
id?: number
invite_token: string
invite_type?: string
invited_by: string
invited_email: string
is_pending?: boolean
setup_completed_at?: string | null
tablo_id: string
used_at?: string | null
}
Update: {
cancelled_at?: string | null
created_at?: string
expires_at?: string
id?: number
invite_token?: string
invite_type?: string
invited_by?: string
invited_email?: string
is_pending?: boolean
setup_completed_at?: string | null
tablo_id?: string
used_at?: string | null
}
Relationships: [
{
foreignKeyName: "client_invites_invited_by_fkey"
columns: ["invited_by"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
{
foreignKeyName: "client_invites_tablo_id_fkey"
columns: ["tablo_id"]
isOneToOne: false
referencedRelation: "events_and_tablos"
referencedColumns: ["tablo_id"]
},
{
foreignKeyName: "client_invites_tablo_id_fkey"
columns: ["tablo_id"]
isOneToOne: false
referencedRelation: "tablos"
referencedColumns: ["id"]
},
{
foreignKeyName: "client_invites_tablo_id_fkey"
columns: ["tablo_id"]
isOneToOne: false
referencedRelation: "user_tablos"
referencedColumns: ["id"]
},
]
}
client_magic_links: {
Row: {
client_id: string
consumed_at: string | null
created_at: string
created_by: string | null
email: string
expires_at: string
id: number
jti: string | null
purpose: string
redirect_to: string | null
tablo_id: string | null
token_hash: string | null
}
Insert: {
client_id: string
consumed_at?: string | null
created_at?: string
created_by?: string | null
email: string
expires_at: string
id?: number
jti?: string | null
purpose: string
redirect_to?: string | null
tablo_id?: string | null
token_hash?: string | null
}
Update: {
client_id?: string
consumed_at?: string | null
created_at?: string
created_by?: string | null
email?: string
expires_at?: string
id?: number
jti?: string | null
purpose?: string
redirect_to?: string | null
tablo_id?: string | null
token_hash?: string | null
}
Relationships: [
{
foreignKeyName: "client_magic_links_client_id_fkey"
columns: ["client_id"]
isOneToOne: false
referencedRelation: "clients"
referencedColumns: ["id"]
},
{
foreignKeyName: "client_magic_links_created_by_fkey"
columns: ["created_by"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
{
foreignKeyName: "client_magic_links_tablo_id_fkey"
columns: ["tablo_id"]
isOneToOne: false
referencedRelation: "events_and_tablos"
referencedColumns: ["tablo_id"]
},
{
foreignKeyName: "client_magic_links_tablo_id_fkey"
columns: ["tablo_id"]
isOneToOne: false
referencedRelation: "tablos"
referencedColumns: ["id"]
},
{
foreignKeyName: "client_magic_links_tablo_id_fkey"
columns: ["tablo_id"]
isOneToOne: false
referencedRelation: "user_tablos"
referencedColumns: ["id"]
},
]
}
clients: {
Row: {
created_at: string
email: string
first_name: string | null
id: string
last_login_at: string | null
last_name: string | null
normalized_email: string
phone: string | null
updated_at: string
}
Insert: {
created_at?: string
email: string
first_name?: string | null
id?: string
last_login_at?: string | null
last_name?: string | null
normalized_email: string
phone?: string | null
updated_at?: string
}
Update: {
created_at?: string
email?: string
first_name?: string | null
id?: string
last_login_at?: string | null
last_name?: string | null
normalized_email?: string
phone?: string | null
updated_at?: string
}
Relationships: []
}
devis: {
Row: {
client_email: string
@ -256,6 +751,58 @@ export type Database = {
}
Relationships: []
}
messages: {
Row: {
channel_id: string
created_at: string
deleted_at: string | null
id: string
text: string
updated_at: string | null
user_id: string
}
Insert: {
channel_id: string
created_at?: string
deleted_at?: string | null
id?: string
text: string
updated_at?: string | null
user_id: string
}
Update: {
channel_id?: string
created_at?: string
deleted_at?: string | null
id?: string
text?: string
updated_at?: string | null
user_id?: string
}
Relationships: [
{
foreignKeyName: "messages_channel_id_fkey"
columns: ["channel_id"]
isOneToOne: false
referencedRelation: "events_and_tablos"
referencedColumns: ["tablo_id"]
},
{
foreignKeyName: "messages_channel_id_fkey"
columns: ["channel_id"]
isOneToOne: false
referencedRelation: "tablos"
referencedColumns: ["id"]
},
{
foreignKeyName: "messages_channel_id_fkey"
columns: ["channel_id"]
isOneToOne: false
referencedRelation: "user_tablos"
referencedColumns: ["id"]
},
]
}
note_access: {
Row: {
created_at: string | null
@ -384,44 +931,140 @@ export type Database = {
}
Relationships: []
}
organization_invites: {
Row: {
created_at: string
id: number
invited_by: string
invited_email: string
invited_user_id: string | null
organization_id: number
}
Insert: {
created_at?: string
id?: number
invited_by: string
invited_email: string
invited_user_id?: string | null
organization_id: number
}
Update: {
created_at?: string
id?: number
invited_by?: string
invited_email?: string
invited_user_id?: string | null
organization_id?: number
}
Relationships: [
{
foreignKeyName: "organization_invites_invited_by_fkey"
columns: ["invited_by"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
{
foreignKeyName: "organization_invites_invited_user_id_fkey"
columns: ["invited_user_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
{
foreignKeyName: "organization_invites_organization_id_fkey"
columns: ["organization_id"]
isOneToOne: false
referencedRelation: "organizations"
referencedColumns: ["id"]
},
]
}
organizations: {
Row: {
created_at: string
deleted_at: string | null
id: number
internal_uuid: string
logo_url: string | null
name: string
updated_at: string
}
Insert: {
created_at?: string
deleted_at?: string | null
id?: number
internal_uuid?: string
logo_url?: string | null
name: string
updated_at?: string
}
Update: {
created_at?: string
deleted_at?: string | null
id?: number
internal_uuid?: string
logo_url?: string | null
name?: string
updated_at?: string
}
Relationships: []
}
profiles: {
Row: {
avatar_url: string | null
client_onboarded_at: string | null
created_at: string | null
email: string | null
first_name: string | null
id: string
is_client: boolean
last_name: string | null
last_signed_in: string | null
name: string | null
organization_id: number
plan: Database["public"]["Enums"]["subscription_plan"] | null
short_user_id: string
}
Insert: {
avatar_url?: string | null
client_onboarded_at?: string | null
created_at?: string | null
email?: string | null
first_name?: string | null
id: string
is_client?: boolean
last_name?: string | null
last_signed_in?: string | null
name?: string | null
organization_id: number
plan?: Database["public"]["Enums"]["subscription_plan"] | null
short_user_id: string
}
Update: {
avatar_url?: string | null
client_onboarded_at?: string | null
created_at?: string | null
email?: string | null
first_name?: string | null
id?: string
is_client?: boolean
last_name?: string | null
last_signed_in?: string | null
name?: string | null
organization_id?: number
plan?: Database["public"]["Enums"]["subscription_plan"] | null
short_user_id?: string
}
Relationships: []
Relationships: [
{
foreignKeyName: "profiles_organization_id_fkey"
columns: ["organization_id"]
isOneToOne: false
referencedRelation: "organizations"
referencedColumns: ["id"]
},
]
}
shared_notes: {
Row: {
@ -573,7 +1216,9 @@ export type Database = {
deleted_at: string | null
id: string
image: string | null
layout_overview_v1: Json | null
name: string
organization_id: number
owner_id: string
position: number
status: string | null
@ -585,7 +1230,9 @@ export type Database = {
deleted_at?: string | null
id?: string
image?: string | null
layout_overview_v1?: Json | null
name: string
organization_id: number
owner_id: string
position?: number
status?: string | null
@ -597,19 +1244,31 @@ export type Database = {
deleted_at?: string | null
id?: string
image?: string | null
layout_overview_v1?: Json | null
name?: string
organization_id?: number
owner_id?: string
position?: number
status?: string | null
updated_at?: string | null
}
Relationships: []
Relationships: [
{
foreignKeyName: "tablos_organization_id_fkey"
columns: ["organization_id"]
isOneToOne: false
referencedRelation: "organizations"
referencedColumns: ["id"]
},
]
}
tasks: {
Row: {
assignee_id: string | null
created_at: string
deleted_at: string | null
description: string | null
due_date: string | null
id: string
is_parent: boolean
parent_task_id: string | null
@ -622,7 +1281,9 @@ export type Database = {
Insert: {
assignee_id?: string | null
created_at?: string
deleted_at?: string | null
description?: string | null
due_date?: string | null
id?: string
is_parent?: boolean
parent_task_id?: string | null
@ -635,7 +1296,9 @@ export type Database = {
Update: {
assignee_id?: string | null
created_at?: string
deleted_at?: string | null
description?: string | null
due_date?: string | null
id?: string
is_parent?: boolean
parent_task_id?: string | null
@ -728,6 +1391,7 @@ export type Database = {
assignee_name: string | null
created_at: string | null
description: string | null
due_date: string | null
id: string | null
is_parent: boolean | null
parent_task_id: string | null
@ -805,6 +1469,9 @@ export type Database = {
Args: { tablo_id_param: string }
Returns: string
}
create_personal_organization: { Args: never; Returns: number }
current_user_organization_id: { Args: never; Returns: number }
generate_cool_organization_name: { Args: never; Returns: string }
generate_random_string: { Args: { length?: number }; Returns: string }
get_my_active_subscription: {
Args: never
@ -895,11 +1562,20 @@ export type Database = {
subscription_id: string
}[]
}
is_freemium_available: { Args: never; Returns: boolean }
is_paying_user: { Args: { user_uuid: string }; Returns: boolean }
}
Enums: {
devis_status: "draft" | "sent" | "accepted" | "rejected" | "expired"
subscription_plan: "none" | "trial" | "standard" | "beta" | "free"
subscription_plan:
| "none"
| "trial"
| "standard"
| "beta"
| "free"
| "solo"
| "team"
| "annual"
task_status: "todo" | "in_progress" | "in_review" | "done"
}
CompositeTypes: {
@ -1029,11 +1705,24 @@ export type CompositeTypes<
: never
export const Constants = {
graphql_public: {
Enums: {},
},
public: {
Enums: {
devis_status: ["draft", "sent", "accepted", "rejected", "expired"],
subscription_plan: ["none", "trial", "standard", "beta", "free"],
subscription_plan: [
"none",
"trial",
"standard",
"beta",
"free",
"solo",
"team",
"annual",
],
task_status: ["todo", "in_progress", "in_review", "done"],
},
},
} as const

View file

@ -0,0 +1,122 @@
import { Platform } from "react-native";
import Purchases, {
LOG_LEVEL,
PURCHASES_ERROR_CODE,
type CustomerInfo,
type PurchasesError,
type PurchasesOffering,
type PurchasesPackage,
} from "react-native-purchases";
const revenueCatAppleApiKey = process.env.EXPO_PUBLIC_REVENUECAT_APPLE_API_KEY?.trim() ?? "";
let configuredAppUserId: string | null = null;
export type BillingPackagePlan = "solo" | "annual";
export type BillingPackageOption = {
description: string | null;
id: string;
package: PurchasesPackage;
plan: BillingPackagePlan;
price: string;
title: string;
};
const isIosPurchasePlatform = () => Platform.OS === "ios";
const inferBillingPackagePlan = (input: string): BillingPackagePlan | null => {
const normalized = input.toLowerCase();
if (normalized.includes("annual") || normalized.includes("founder")) {
return "annual";
}
if (normalized.includes("solo")) {
return "solo";
}
return null;
};
export const canUseInAppPurchases = () => isIosPurchasePlatform() && revenueCatAppleApiKey.length > 0;
export const ensurePurchasesConfigured = async (appUserID: string) => {
if (!canUseInAppPurchases()) {
return false;
}
if (__DEV__) {
await Purchases.setLogLevel(LOG_LEVEL.DEBUG);
}
const isConfigured = await Purchases.isConfigured();
if (!isConfigured) {
Purchases.configure({
apiKey: revenueCatAppleApiKey,
appUserID,
});
configuredAppUserId = appUserID;
return true;
}
if (configuredAppUserId !== appUserID) {
await Purchases.logIn(appUserID);
configuredAppUserId = appUserID;
}
return true;
};
const toBillingPackageOption = (pkg: PurchasesPackage): BillingPackageOption | null => {
const plan =
inferBillingPackagePlan(pkg.product.identifier) ?? inferBillingPackagePlan(pkg.identifier);
if (!plan) {
return null;
}
return {
description: pkg.product.description ?? null,
id: pkg.product.identifier,
package: pkg,
plan,
price: pkg.product.priceString,
title: pkg.product.title,
};
};
export const getCurrentOffering = async (appUserID: string): Promise<PurchasesOffering | null> => {
const isConfigured = await ensurePurchasesConfigured(appUserID);
if (!isConfigured) {
return null;
}
const offerings = await Purchases.getOfferings();
return offerings.current;
};
export const getBillingPackageOptions = async (appUserID: string) => {
const offering = await getCurrentOffering(appUserID);
if (!offering) {
return [];
}
return offering.availablePackages
.map(toBillingPackageOption)
.filter((pkg): pkg is BillingPackageOption => Boolean(pkg));
};
export const purchaseBillingPackage = async (appUserID: string, pkg: PurchasesPackage) => {
await ensurePurchasesConfigured(appUserID);
return Purchases.purchasePackage(pkg);
};
export const restoreBillingPurchases = async (appUserID: string): Promise<CustomerInfo> => {
await ensurePurchasesConfigured(appUserID);
return Purchases.restorePurchases();
};
export const isPurchaseCancelledError = (error: unknown) =>
(error as PurchasesError | undefined)?.code === PURCHASES_ERROR_CODE.PURCHASE_CANCELLED_ERROR;

View file

@ -47,6 +47,7 @@
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
"react-native-get-random-values": "~1.11.0",
"react-native-purchases": "^10.0.1",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
@ -4412,6 +4413,27 @@
"nanoid": "^3.3.11"
}
},
"node_modules/@revenuecat/purchases-js": {
"version": "1.34.0",
"resolved": "https://registry.npmjs.org/@revenuecat/purchases-js/-/purchases-js-1.34.0.tgz",
"integrity": "sha512-p3hpHHvyllAckqnjpjaCoj+lVK0gNJwqR1F8EwZPw7eMKFummE0UItbGzBCfncJIPxGA1NJHrgZb1dLflEmjhg==",
"license": "MIT"
},
"node_modules/@revenuecat/purchases-js-hybrid-mappings": {
"version": "18.1.0",
"resolved": "https://registry.npmjs.org/@revenuecat/purchases-js-hybrid-mappings/-/purchases-js-hybrid-mappings-18.1.0.tgz",
"integrity": "sha512-P2KtqjPUcdEhTx51nzXAWNFoNTRzQm9lR/g2S90jQ/uzwrB4H7OJNRJ9B1NFNa511Uc84LfwmmIh51oeQO9+qQ==",
"license": "MIT",
"dependencies": {
"@revenuecat/purchases-js": "1.34.0"
}
},
"node_modules/@revenuecat/purchases-typescript-internal": {
"version": "18.1.0",
"resolved": "https://registry.npmjs.org/@revenuecat/purchases-typescript-internal/-/purchases-typescript-internal-18.1.0.tgz",
"integrity": "sha512-aoXmrvDSCVStXAbv+yfUkb1BEIga2hXYiUSLHOoGMX6luXXlSLNMeG7nhvMa7gXTgjnEZXDYOG59en4NNBtJ6A==",
"license": "MIT"
},
"node_modules/@rn-vui/base": {
"version": "5.1.3",
"resolved": "https://registry.npmjs.org/@rn-vui/base/-/base-5.1.3.tgz",
@ -6576,26 +6598,6 @@
"node": ">=12"
}
},
"node_modules/cliui/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/cliui/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
@ -7352,6 +7354,12 @@
"url": "https://github.com/sindresorhus/emittery?sponsor=1"
}
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
@ -13239,7 +13247,6 @@
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
@ -13251,7 +13258,6 @@
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT"
},
"node_modules/proxy-from-env": {
@ -13545,6 +13551,31 @@
"react-native": "*"
}
},
"node_modules/react-native-purchases": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/react-native-purchases/-/react-native-purchases-10.0.1.tgz",
"integrity": "sha512-FyJgOLuGo2TqR/sswzgkUebiYu30FPAsB9N2goyv4q2pr5ixPL0v6QrETrYDpnPI8kz/wg+aMv8/HsAU9b6ajw==",
"license": "MIT",
"workspaces": [
"examples/purchaseTesterTypescript",
"react-native-purchases-ui",
"e2e-tests/MaestroTestApp"
],
"dependencies": {
"@revenuecat/purchases-js-hybrid-mappings": "18.1.0",
"@revenuecat/purchases-typescript-internal": "18.1.0"
},
"peerDependencies": {
"react": ">= 16.6.3",
"react-native": ">= 0.73.0",
"react-native-web": "*"
},
"peerDependenciesMeta": {
"react-native-web": {
"optional": true
}
}
},
"node_modules/react-native-reanimated": {
"version": "4.1.7",
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.7.tgz",
@ -13632,6 +13663,78 @@
"react-native": "*"
}
},
"node_modules/react-native-vector-icons": {
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/react-native-vector-icons/-/react-native-vector-icons-10.3.0.tgz",
"integrity": "sha512-IFQ0RE57819hOUdFvgK4FowM5aMXg7C7XKsuGLevqXkkIJatc3QopN0wYrb2IrzUgmdpfP+QVIbI3S6h7M0btw==",
"deprecated": "react-native-vector-icons package has moved to a new model of per-icon-family packages. See the https://github.com/oblador/react-native-vector-icons/blob/master/MIGRATION.md on how to migrate",
"license": "MIT",
"peer": true,
"dependencies": {
"prop-types": "^15.7.2",
"yargs": "^16.1.1"
},
"bin": {
"fa-upgrade.sh": "bin/fa-upgrade.sh",
"fa5-upgrade": "bin/fa5-upgrade.sh",
"fa6-upgrade": "bin/fa6-upgrade.sh",
"generate-icon": "bin/generate-icon.js"
}
},
"node_modules/react-native-vector-icons/node_modules/cliui": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz",
"integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==",
"license": "ISC",
"peer": true,
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^7.0.0"
}
},
"node_modules/react-native-vector-icons/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/react-native-vector-icons/node_modules/yargs": {
"version": "16.2.0",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz",
"integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==",
"license": "MIT",
"peer": true,
"dependencies": {
"cliui": "^7.0.2",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.0",
"y18n": "^5.0.5",
"yargs-parser": "^20.2.2"
},
"engines": {
"node": ">=10"
}
},
"node_modules/react-native-vector-icons/node_modules/yargs-parser": {
"version": "20.2.9",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz",
"integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=10"
}
},
"node_modules/react-native-web": {
"version": "0.21.2",
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz",
@ -14936,6 +15039,32 @@
"node": ">=8"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/string-width/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/string.prototype.matchall": {
"version": "4.0.12",
"resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz",
@ -15566,7 +15695,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@ -16317,26 +16446,6 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrap-ansi/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/wrap-ansi/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wrap-ansi/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
@ -16502,38 +16611,6 @@
"node": ">=12"
}
},
"node_modules/yargs/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/yargs/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View file

@ -54,6 +54,7 @@
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
"react-native-get-random-values": "~1.11.0",
"react-native-purchases": "^10.0.1",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",

View file

@ -0,0 +1,124 @@
jest.mock("@/lib/supabase", () => ({
supabase: {
auth: {
getSession: jest.fn(),
onAuthStateChange: jest.fn(),
setSession: jest.fn(),
signInWithIdToken: jest.fn(),
signInWithOAuth: jest.fn(),
signInWithPassword: jest.fn(),
signOut: jest.fn(),
signUp: jest.fn(),
},
},
}));
jest.mock("@/lib/api", () => ({
api: {
get: jest.fn(),
},
}));
jest.mock("@/lib/purchases", () => ({
ensurePurchasesConfigured: jest.fn(),
}));
jest.mock("expo-web-browser", () => ({
maybeCompleteAuthSession: jest.fn(),
openAuthSessionAsync: jest.fn(),
}));
jest.mock("expo-auth-session", () => ({
makeRedirectUri: jest.fn(() => "xtablo://redirect"),
}));
jest.mock("expo-auth-session/build/QueryParams", () => ({
getQueryParams: jest.fn(() => ({ params: {}, errorCode: null })),
}));
jest.mock("react-native", () => ({
Linking: {
addEventListener: jest.fn(),
getInitialURL: jest.fn(),
},
}));
jest.mock("expo-apple-authentication", () => ({
AppleAuthenticationScope: {
EMAIL: "EMAIL",
FULL_NAME: "FULL_NAME",
},
signInAsync: jest.fn(),
}));
import { useAuthStore } from "@/stores/auth";
import { supabase } from "@/lib/supabase";
const mockedSupabase = supabase as jest.Mocked<typeof supabase>;
const mockedSignUp = mockedSupabase.auth.signUp as jest.Mock;
describe("auth store signUp", () => {
beforeEach(() => {
jest.clearAllMocks();
useAuthStore.setState({
initialized: false,
loading: false,
session: null,
user: null,
});
});
it("sends snake_case signup metadata and a full name", async () => {
mockedSignUp.mockResolvedValue({
data: {
session: {
user: { id: "user-id" },
},
},
error: null,
} as never);
await useAuthStore.getState().signUp(
"owner@example.com",
"password123",
"Ada",
"Lovelace",
"Analytical Engines"
);
expect(mockedSupabase.auth.signUp).toHaveBeenCalledWith({
email: "owner@example.com",
password: "password123",
options: {
data: {
company_name: "Analytical Engines",
first_name: "Ada",
last_name: "Lovelace",
name: "Ada Lovelace",
},
},
});
});
it("throws a helpful error when signup does not create a session", async () => {
mockedSignUp.mockResolvedValue({
data: {
session: null,
user: { id: "user-id" },
},
error: null,
} as never);
await expect(
useAuthStore.getState().signUp(
"owner@example.com",
"password123",
"Ada",
"Lovelace",
"Analytical Engines"
)
).rejects.toThrow(
"Impossible d'ouvrir votre session après l'inscription. Si cette adresse existe déjà, essayez de vous connecter."
);
});
});

View file

@ -9,6 +9,8 @@ import { QueryClient } from "@tanstack/react-query";
import { User } from "@/types/user.types";
import { api } from "@/lib/api";
import * as AppleAuthentication from "expo-apple-authentication";
import { ensurePurchasesConfigured } from "@/lib/purchases";
import { organizationBillingQueryKey } from "@/hooks/organization";
interface AuthState {
session: Session | null;
@ -29,7 +31,7 @@ interface AuthState {
signInWithApple: () => Promise<void>;
signOut: () => Promise<void>;
createSessionFromUrl: (url: string) => Promise<void>;
fetchAndSetUser: (session: Session | null) => Promise<void>;
fetchAndSetUser: (session: Session | null, queryClient?: QueryClient) => Promise<void>;
}
WebBrowser.maybeCompleteAuthSession();
@ -41,8 +43,13 @@ export const useAuthStore = create<AuthState>((set, get) => ({
loading: true,
initialized: false,
setSession: (session: Session | null) => set({ session }),
fetchAndSetUser: async (session: Session | null) => {
if (!session) return;
fetchAndSetUser: async (session: Session | null, queryClient?: QueryClient) => {
if (!session) {
set({ user: null });
queryClient?.removeQueries({ queryKey: organizationBillingQueryKey });
return;
}
try {
const { data } = await api.get<User>("/api/v1/users/me", {
headers: {
@ -50,6 +57,8 @@ export const useAuthStore = create<AuthState>((set, get) => ({
},
});
set({ user: data });
await ensurePurchasesConfigured(data.id);
await queryClient?.invalidateQueries({ queryKey: organizationBillingQueryKey });
} catch (error) {
console.error("Error fetching user:", error);
}
@ -63,12 +72,13 @@ export const useAuthStore = create<AuthState>((set, get) => ({
set({
session,
});
await get().fetchAndSetUser(session, queryClient);
supabase.auth.onAuthStateChange(async (event, session) => {
set({
session,
});
await get().fetchAndSetUser(session);
await get().fetchAndSetUser(session, queryClient);
});
const initialUrl = await Linking.getInitialURL();
@ -102,18 +112,34 @@ export const useAuthStore = create<AuthState>((set, get) => ({
lastName: string,
companyName: string
) => {
await supabase.auth.signUp({
email,
password,
options: {
data: {
firstName,
lastName,
companyName,
set({ loading: true });
try {
const { data, error } = await supabase.auth.signUp({
email,
password,
options: {
data: {
company_name: companyName,
first_name: firstName,
last_name: lastName,
name: [firstName, lastName].filter(Boolean).join(" ").trim(),
},
},
},
});
set({ loading: false });
});
if (error) {
throw error;
}
if (!data.session) {
throw new Error(
"Impossible d'ouvrir votre session après l'inscription. Si cette adresse existe déjà, essayez de vous connecter."
);
}
} finally {
set({ loading: false });
}
},
performOAuth: async (provider: Provider) => {
const { data, error } = await supabase.auth.signInWithOAuth({

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,51 @@
export type OrganizationPlan = "solo" | "team" | "annual";
export type RequiredOrganizationPlan = "solo" | "team";
export type OrganizationSummary = {
id: number;
logo_url: string | null;
member_count: number;
name: string;
plan: OrganizationPlan;
tablo_count: number;
};
export type OrganizationMember = {
avatar_url: string | null;
created_at: string | null;
email: string | null;
first_name: string | null;
id: string;
last_name: string | null;
name: string | null;
plan: OrganizationPlan;
};
export type OrganizationInvite = {
created_at: string;
id: number;
invited_email: string;
invited_member: {
avatar_url: string | null;
email: string | null;
first_name: string | null;
id: string;
last_name: string | null;
name: string | null;
} | null;
invited_user_id: string | null;
};
export type OrganizationBillingState = {
active_subscription_plan: OrganizationPlan | null;
active_subscription_quantity: number;
invites_sent: OrganizationInvite[];
is_billing_owner: boolean;
is_trial_expired: boolean;
members: OrganizationMember[];
organization: OrganizationSummary;
required_plan: RequiredOrganizationPlan;
required_team_quantity: number;
trial_ends_at: string;
trial_starts_at: string;
};