feat: add apple billing flow for expo app
This commit is contained in:
parent
5fba42f678
commit
66fa698e48
32 changed files with 6284 additions and 1638 deletions
|
|
@ -14,7 +14,7 @@ steps:
|
||||||
- '--region'
|
- '--region'
|
||||||
- 'europe-west1'
|
- 'europe-west1'
|
||||||
- '--set-env-vars'
|
- '--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:
|
images:
|
||||||
- 'europe-west1-docker.pkg.dev/$_AR_PROJECT_ID/$_AR_REPOSITORY/xtablo-source/$_SERVICE_NAME:$COMMIT_SHA'
|
- 'europe-west1-docker.pkg.dev/$_AR_PROJECT_ID/$_AR_REPOSITORY/xtablo-source/$_SERVICE_NAME:$COMMIT_SHA'
|
||||||
|
|
||||||
|
|
|
||||||
79
apps/api/src/__tests__/config/revenuecat-config.test.ts
Normal file
79
apps/api/src/__tests__/config/revenuecat-config.test.ts
Normal 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"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
51
apps/api/src/__tests__/helpers/appleBilling.test.ts
Normal file
51
apps/api/src/__tests__/helpers/appleBilling.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -4,6 +4,7 @@ import {
|
||||||
getOrganizationOwner,
|
getOrganizationOwner,
|
||||||
getTrialWindow,
|
getTrialWindow,
|
||||||
parseTrialRolloutDate,
|
parseTrialRolloutDate,
|
||||||
|
selectBestBillingCandidate,
|
||||||
} from "../../helpers/billing.js";
|
} from "../../helpers/billing.js";
|
||||||
|
|
||||||
describe("billing helpers", () => {
|
describe("billing helpers", () => {
|
||||||
|
|
@ -91,4 +92,54 @@ describe("billing helpers", () => {
|
||||||
expect(result.trialEndDate.toISOString()).toBe("2026-03-26T00:00:00.000Z");
|
expect(result.trialEndDate.toISOString()).toBe("2026-03-26T00:00:00.000Z");
|
||||||
expect(result.isTrialExpired).toBe(true);
|
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 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
108
apps/api/src/__tests__/helpers/organizationMembers.test.ts
Normal file
108
apps/api/src/__tests__/helpers/organizationMembers.test.ts
Normal 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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
145
apps/api/src/__tests__/routes/revenuecat.test.ts
Normal file
145
apps/api/src/__tests__/routes/revenuecat.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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
|
// DELETE /me must run last — it hard-deletes the auth user, making ownerUser unusable for subsequent tests
|
||||||
describe("DELETE /me - Delete Account", () => {
|
describe("DELETE /me - Delete Account", () => {
|
||||||
it("should return 401 when unauthenticated", async () => {
|
it("should return 401 when unauthenticated", async () => {
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,9 @@ export interface AppConfig {
|
||||||
STRIPE_SOLO_PRICE_ID: string;
|
STRIPE_SOLO_PRICE_ID: string;
|
||||||
STRIPE_TEAM_PRICE_ID: string;
|
STRIPE_TEAM_PRICE_ID: string;
|
||||||
STRIPE_FOUNDER_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_USER: string;
|
||||||
EMAIL_CLIENT_ID: string;
|
EMAIL_CLIENT_ID: string;
|
||||||
EMAIL_CLIENT_SECRET: string;
|
EMAIL_CLIENT_SECRET: string;
|
||||||
|
|
@ -133,6 +136,18 @@ export function createConfig(secrets?: Secrets): AppConfig {
|
||||||
"STRIPE_FOUNDER_PRICE_ID",
|
"STRIPE_FOUNDER_PRICE_ID",
|
||||||
process.env.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_USER: validateEnvVar("EMAIL_USER", process.env.EMAIL_USER),
|
||||||
EMAIL_CLIENT_ID: validateEnvVar("EMAIL_CLIENT_ID", process.env.EMAIL_CLIENT_ID),
|
EMAIL_CLIENT_ID: validateEnvVar("EMAIL_CLIENT_ID", process.env.EMAIL_CLIENT_ID),
|
||||||
EMAIL_CLIENT_SECRET: isTestMode
|
EMAIL_CLIENT_SECRET: isTestMode
|
||||||
|
|
|
||||||
105
apps/api/src/helpers/appleBilling.ts
Normal file
105
apps/api/src/helpers/appleBilling.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||||
|
import { toAppleBillingCandidate, type AppleBillingCandidate } from "./appleBilling.js";
|
||||||
|
|
||||||
export type BillingPlan = "solo" | "team" | "annual";
|
export type BillingPlan = "solo" | "team" | "annual";
|
||||||
export type RequiredBillingPlan = "solo" | "team";
|
export type RequiredBillingPlan = "solo" | "team";
|
||||||
|
|
@ -41,6 +42,19 @@ type StripeProductRow = {
|
||||||
metadata: Record<string, string | null> | null;
|
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 = {
|
export type OrganizationBillingState = {
|
||||||
owner_user_id: string;
|
owner_user_id: string;
|
||||||
member_count: number;
|
member_count: number;
|
||||||
|
|
@ -56,6 +70,7 @@ export type OrganizationBillingState = {
|
||||||
const ACTIVE_BILLING_STATUSES = ["active", "trialing", "past_due"];
|
const ACTIVE_BILLING_STATUSES = ["active", "trialing", "past_due"];
|
||||||
const DEFAULT_BILLING_TRIAL_DAYS = 14;
|
const DEFAULT_BILLING_TRIAL_DAYS = 14;
|
||||||
const DEFAULT_BILLING_TRIAL_ROLLOUT_AT = "2026-03-08T00:00:00.000Z";
|
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 parseTrialDays = () => {
|
||||||
const parsed = Number.parseInt(process.env.BILLING_TRIAL_DAYS ?? "", 10);
|
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 === "active") return 3;
|
||||||
if (status === "past_due") return 2;
|
if (status === "past_due") return 2;
|
||||||
if (status === "trialing") return 1;
|
if (status === "trialing") return 1;
|
||||||
|
if (status === "canceled") return 1;
|
||||||
return 0;
|
return 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -147,6 +163,20 @@ const inferBillingPlan = (planHint: string | null | undefined): BillingPlan | nu
|
||||||
return null;
|
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 normalizeProfilePlan = (plan: string | null | undefined): BillingPlan => {
|
||||||
const normalized = (plan ?? "").toLowerCase();
|
const normalized = (plan ?? "").toLowerCase();
|
||||||
|
|
||||||
|
|
@ -195,157 +225,202 @@ const getOrganizationProfiles = async (supabase: SupabaseClient, organizationId:
|
||||||
return { error: null, profiles };
|
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 (
|
const resolveActiveSubscription = async (
|
||||||
supabase: SupabaseClient,
|
supabase: SupabaseClient,
|
||||||
ownerUserId: string,
|
ownerUserId: string,
|
||||||
ownerProfilePlan: string | null
|
ownerProfilePlan: string | null
|
||||||
): Promise<{ plan: BillingPlan | null; quantity: number }> => {
|
): 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 ownerFallbackPlan = normalizeProfilePlan(ownerProfilePlan);
|
||||||
|
let stripeCandidates: BillingCandidate[] = [];
|
||||||
const subscriptionIds = normalizedSubscriptions.map((subscription) => subscription.id);
|
try {
|
||||||
const { data: subscriptionItems, error: subscriptionItemsError } = await supabase
|
const { data: customers, error: customersError } = 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")
|
.schema("stripe")
|
||||||
.from("prices")
|
.from("customers")
|
||||||
.select("id, lookup_key, metadata, product")
|
.select("id, metadata")
|
||||||
.in("id", priceIds);
|
.limit(1000);
|
||||||
|
|
||||||
if (pricesError) {
|
if (customersError) {
|
||||||
throw new Error(pricesError.message);
|
throw new Error(customersError.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedPrices = (prices ?? []) as StripePriceRow[];
|
const customer = (customers as StripeCustomerRow[] | null)?.find(
|
||||||
pricesById = new Map(normalizedPrices.map((price) => [price.id, price]));
|
(candidate) => candidate.metadata?.user_id === ownerUserId
|
||||||
|
|
||||||
const productIds = Array.from(
|
|
||||||
new Set(
|
|
||||||
normalizedPrices
|
|
||||||
.map((price) => price.product)
|
|
||||||
.filter((product): product is string => Boolean(product))
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (productIds.length > 0) {
|
if (customer) {
|
||||||
const { data: products, error: productsError } = await supabase
|
const { data: subscriptions, error: subscriptionsError } = await supabase
|
||||||
.schema("stripe")
|
.schema("stripe")
|
||||||
.from("products")
|
.from("subscriptions")
|
||||||
.select("id, name, metadata")
|
.select("id, status, created, current_period_end")
|
||||||
.in("id", productIds);
|
.eq("customer", customer.id)
|
||||||
|
.in("status", ACTIVE_BILLING_STATUSES);
|
||||||
|
|
||||||
if (productsError) {
|
if (subscriptionsError) {
|
||||||
throw new Error(productsError.message);
|
throw new Error(subscriptionsError.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedProducts = (products ?? []) as StripeProductRow[];
|
const normalizedSubscriptions = (subscriptions ?? []) as StripeSubscriptionRow[];
|
||||||
productsById = new Map(normalizedProducts.map((product) => [product.id, product]));
|
|
||||||
|
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 { data: appleSubscriptions, error: appleSubscriptionsError } = await supabase
|
||||||
const relatedItems = normalizedItems.filter((item) => item.subscription === subscription.id);
|
.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 [
|
return [
|
||||||
{
|
{
|
||||||
subscription,
|
currentPeriodEnd: candidate.currentPeriodEnd,
|
||||||
plan: null as BillingPlan | null,
|
plan: candidate.plan,
|
||||||
quantity: 1,
|
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 selectBestBillingCandidate([...stripeCandidates, ...appleCandidates], ownerFallbackPlan);
|
||||||
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,
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getOrganizationBillingState = async (
|
export const getOrganizationBillingState = async (
|
||||||
|
|
|
||||||
56
apps/api/src/helpers/organizationMembers.ts
Normal file
56
apps/api/src/helpers/organizationMembers.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -9,6 +9,7 @@ import { getPublicClientInvitesRouter } from "./clientInvites.js";
|
||||||
import { getClientPortalRouter } from "./clientPortal.js";
|
import { getClientPortalRouter } from "./clientPortal.js";
|
||||||
import { getMaybeAuthenticatedRouter } from "./maybeAuthRouter.js";
|
import { getMaybeAuthenticatedRouter } from "./maybeAuthRouter.js";
|
||||||
import { getPublicRouter } from "./public.js";
|
import { getPublicRouter } from "./public.js";
|
||||||
|
import { getRevenueCatWebhookRouter } from "./revenuecat.js";
|
||||||
import { getStripeWebhookRouter } from "./stripe.js";
|
import { getStripeWebhookRouter } from "./stripe.js";
|
||||||
import { getTaskRouter } from "./tasks.js";
|
import { getTaskRouter } from "./tasks.js";
|
||||||
|
|
||||||
|
|
@ -33,6 +34,7 @@ export const getMainRouter = (config: AppConfig) => {
|
||||||
mainRouter.route("/tasks", getTaskRouter());
|
mainRouter.route("/tasks", getTaskRouter());
|
||||||
|
|
||||||
// webhooks
|
// webhooks
|
||||||
|
mainRouter.route("/revenuecat-webhook", getRevenueCatWebhookRouter(config));
|
||||||
mainRouter.route("/stripe-webhook", getStripeWebhookRouter());
|
mainRouter.route("/stripe-webhook", getStripeWebhookRouter());
|
||||||
|
|
||||||
// admin routes
|
// admin routes
|
||||||
|
|
|
||||||
223
apps/api/src/routers/revenuecat.ts
Normal file
223
apps/api/src/routers/revenuecat.ts
Normal 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;
|
||||||
|
};
|
||||||
|
|
@ -4,6 +4,7 @@ import { Hono } from "hono";
|
||||||
import { createFactory } from "hono/factory";
|
import { createFactory } from "hono/factory";
|
||||||
import { getOrganizationBillingState } from "../helpers/billing.js";
|
import { getOrganizationBillingState } from "../helpers/billing.js";
|
||||||
import { createInvitedUser, getOrganizationPlan, MAX_TABLO_LIMIT } from "../helpers/helpers.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 { deleteOrgIcons, uploadOrgIcons } from "../helpers/orgIcons.js";
|
||||||
import type { AuthEnv } from "../types/app.types.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);
|
return c.json({ error: "Organization not found" }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: members, error: membersError } = await supabase
|
const { data: members, error: membersError } = await loadOrganizationMembers(supabase, {
|
||||||
.from("profiles")
|
organizationId,
|
||||||
.select("id, email, name, first_name, last_name, avatar_url, created_at, plan")
|
userId: user.id,
|
||||||
.eq("organization_id", organizationId)
|
});
|
||||||
.order("created_at", { ascending: true });
|
|
||||||
|
|
||||||
if (membersError) {
|
if (membersError) {
|
||||||
return c.json({ error: "Failed to load organization members" }, 500);
|
return c.json({ error: "Failed to load organization members" }, 500);
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||||
|
);
|
||||||
101
supabase/tests/database/13_apple_billing.test.sql
Normal file
101
supabase/tests/database/13_apple_billing.test.sql
Normal 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;
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
Switch,
|
Switch,
|
||||||
Alert,
|
Alert,
|
||||||
Linking,
|
Linking,
|
||||||
|
ActivityIndicator,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { LinearGradient } from "expo-linear-gradient";
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
|
@ -38,11 +39,26 @@ import {
|
||||||
} from "@/constants/colors";
|
} from "@/constants/colors";
|
||||||
import { useThemeColor } from "@/hooks/useThemeColor";
|
import { useThemeColor } from "@/hooks/useThemeColor";
|
||||||
import { useColorScheme } from "@/hooks/useColorScheme";
|
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() {
|
export default function SettingsScreen() {
|
||||||
const signOut = useAuthStore((state) => state.signOut);
|
const signOut = useAuthStore((state) => state.signOut);
|
||||||
const user = useUser();
|
const user = useUser();
|
||||||
const colorScheme = useColorScheme();
|
const colorScheme = useColorScheme();
|
||||||
|
const organizationBillingQuery = useOrganizationBilling();
|
||||||
|
|
||||||
// Theme-aware colors
|
// Theme-aware colors
|
||||||
const backgroundColor = useThemeColor({ light: "#f8fafc", dark: "#111827" }, "background");
|
const backgroundColor = useThemeColor({ light: "#f8fafc", dark: "#111827" }, "background");
|
||||||
|
|
@ -61,8 +77,72 @@ export default function SettingsScreen() {
|
||||||
const [pushNotifications, setPushNotifications] = useState(true);
|
const [pushNotifications, setPushNotifications] = useState(true);
|
||||||
const [emailNotifications, setEmailNotifications] = useState(true);
|
const [emailNotifications, setEmailNotifications] = useState(true);
|
||||||
const [biometricAuth, setBiometricAuth] = useState(false);
|
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 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 = () => {
|
const handleSignOut = () => {
|
||||||
Alert.alert("Déconnexion", "Êtes-vous sûr de vouloir vous déconnecter ?", [
|
Alert.alert("Déconnexion", "Êtes-vous sûr de vouloir vous déconnecter ?", [
|
||||||
{
|
{
|
||||||
|
|
@ -197,6 +277,162 @@ export default function SettingsScreen() {
|
||||||
false
|
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'abonnement…
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (organizationBillingQuery.error || !organizationBilling) {
|
||||||
|
return (
|
||||||
|
<View style={styles.billingBox}>
|
||||||
|
<Text style={styles.billingErrorText}>
|
||||||
|
Impossible de charger la facturation de l'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 (
|
return (
|
||||||
<View style={[styles.container, { backgroundColor }]}>
|
<View style={[styles.container, { backgroundColor }]}>
|
||||||
<StatusBar
|
<StatusBar
|
||||||
|
|
@ -239,6 +475,8 @@ export default function SettingsScreen() {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{renderSettingsSection("Abonnement", renderBillingContent())}
|
||||||
|
|
||||||
{/* {renderSettingsSection(
|
{/* {renderSettingsSection(
|
||||||
"Notifications",
|
"Notifications",
|
||||||
<>
|
<>
|
||||||
|
|
@ -531,4 +769,18 @@ const styles = StyleSheet.create({
|
||||||
bottomSpacing: {
|
bottomSpacing: {
|
||||||
height: 100,
|
height: 100,
|
||||||
},
|
},
|
||||||
|
billingBox: {
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingVertical: 20,
|
||||||
|
},
|
||||||
|
billingErrorText: {
|
||||||
|
color: "#b91c1c",
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
billingHelperText: {
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 20,
|
||||||
|
marginTop: 10,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useState } from "react";
|
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 { Button, Input } from "@rn-vui/themed";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import { Link } from "expo-router";
|
import { Link } from "expo-router";
|
||||||
|
|
@ -30,6 +30,19 @@ export default function SignUp() {
|
||||||
? require("@/assets/images/logo_white.png")
|
? require("@/assets/images/logo_white.png")
|
||||||
: require("@/assets/images/logo.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 (
|
return (
|
||||||
<View style={[styles.container, { backgroundColor }]}>
|
<View style={[styles.container, { backgroundColor }]}>
|
||||||
<Image source={logo} style={styles.logo} />
|
<Image source={logo} style={styles.logo} />
|
||||||
|
|
@ -91,7 +104,7 @@ export default function SignUp() {
|
||||||
<Button
|
<Button
|
||||||
title="S'inscrire"
|
title="S'inscrire"
|
||||||
disabled={authLoading}
|
disabled={authLoading}
|
||||||
onPress={() => signUp(email, password, firstName, lastName, companyName)}
|
onPress={handleSignUp}
|
||||||
buttonStyle={styles.button}
|
buttonStyle={styles.button}
|
||||||
titleStyle={styles.buttonTitle}
|
titleStyle={styles.buttonTitle}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
218
xtablo-expo/components/BillingPaywall.tsx
Normal file
218
xtablo-expo/components/BillingPaywall.tsx
Normal 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'a pas encore d'accès actif.</Text>
|
||||||
|
<Text style={styles.body}>
|
||||||
|
Pour activer l'abonnement sur mobile, demandez au responsable de facturation de
|
||||||
|
gérer l'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'accès s'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'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",
|
||||||
|
},
|
||||||
|
});
|
||||||
88
xtablo-expo/components/__tests__/BillingPaywall.test.tsx
Normal file
88
xtablo-expo/components/__tests__/BillingPaywall.test.tsx
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
104
xtablo-expo/components/__tests__/organization-hook.test.ts
Normal file
104
xtablo-expo/components/__tests__/organization-hook.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
95
xtablo-expo/components/__tests__/signup-screen.test.tsx
Normal file
95
xtablo-expo/components/__tests__/signup-screen.test.tsx
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
38
xtablo-expo/hooks/organization.ts
Normal file
38
xtablo-expo/hooks/organization.ts
Normal 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,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -7,13 +7,211 @@ export type Json =
|
||||||
| Json[]
|
| Json[]
|
||||||
|
|
||||||
export type Database = {
|
export type Database = {
|
||||||
// Allows to automatically instantiate createClient with right options
|
graphql_public: {
|
||||||
// instead of createClient<Database, { PostgrestVersion: 'XX' }>(URL, KEY)
|
Tables: {
|
||||||
__InternalSupabase: {
|
[_ in never]: never
|
||||||
PostgrestVersion: "13.0.4"
|
}
|
||||||
|
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: {
|
public: {
|
||||||
Tables: {
|
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: {
|
availabilities: {
|
||||||
Row: {
|
Row: {
|
||||||
availability_data: Json
|
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: {
|
devis: {
|
||||||
Row: {
|
Row: {
|
||||||
client_email: string
|
client_email: string
|
||||||
|
|
@ -256,6 +751,58 @@ export type Database = {
|
||||||
}
|
}
|
||||||
Relationships: []
|
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: {
|
note_access: {
|
||||||
Row: {
|
Row: {
|
||||||
created_at: string | null
|
created_at: string | null
|
||||||
|
|
@ -384,44 +931,140 @@ export type Database = {
|
||||||
}
|
}
|
||||||
Relationships: []
|
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: {
|
profiles: {
|
||||||
Row: {
|
Row: {
|
||||||
avatar_url: string | null
|
avatar_url: string | null
|
||||||
|
client_onboarded_at: string | null
|
||||||
created_at: string | null
|
created_at: string | null
|
||||||
email: string | null
|
email: string | null
|
||||||
first_name: string | null
|
first_name: string | null
|
||||||
id: string
|
id: string
|
||||||
|
is_client: boolean
|
||||||
last_name: string | null
|
last_name: string | null
|
||||||
last_signed_in: string | null
|
last_signed_in: string | null
|
||||||
name: string | null
|
name: string | null
|
||||||
|
organization_id: number
|
||||||
plan: Database["public"]["Enums"]["subscription_plan"] | null
|
plan: Database["public"]["Enums"]["subscription_plan"] | null
|
||||||
short_user_id: string
|
short_user_id: string
|
||||||
}
|
}
|
||||||
Insert: {
|
Insert: {
|
||||||
avatar_url?: string | null
|
avatar_url?: string | null
|
||||||
|
client_onboarded_at?: string | null
|
||||||
created_at?: string | null
|
created_at?: string | null
|
||||||
email?: string | null
|
email?: string | null
|
||||||
first_name?: string | null
|
first_name?: string | null
|
||||||
id: string
|
id: string
|
||||||
|
is_client?: boolean
|
||||||
last_name?: string | null
|
last_name?: string | null
|
||||||
last_signed_in?: string | null
|
last_signed_in?: string | null
|
||||||
name?: string | null
|
name?: string | null
|
||||||
|
organization_id: number
|
||||||
plan?: Database["public"]["Enums"]["subscription_plan"] | null
|
plan?: Database["public"]["Enums"]["subscription_plan"] | null
|
||||||
short_user_id: string
|
short_user_id: string
|
||||||
}
|
}
|
||||||
Update: {
|
Update: {
|
||||||
avatar_url?: string | null
|
avatar_url?: string | null
|
||||||
|
client_onboarded_at?: string | null
|
||||||
created_at?: string | null
|
created_at?: string | null
|
||||||
email?: string | null
|
email?: string | null
|
||||||
first_name?: string | null
|
first_name?: string | null
|
||||||
id?: string
|
id?: string
|
||||||
|
is_client?: boolean
|
||||||
last_name?: string | null
|
last_name?: string | null
|
||||||
last_signed_in?: string | null
|
last_signed_in?: string | null
|
||||||
name?: string | null
|
name?: string | null
|
||||||
|
organization_id?: number
|
||||||
plan?: Database["public"]["Enums"]["subscription_plan"] | null
|
plan?: Database["public"]["Enums"]["subscription_plan"] | null
|
||||||
short_user_id?: string
|
short_user_id?: string
|
||||||
}
|
}
|
||||||
Relationships: []
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "profiles_organization_id_fkey"
|
||||||
|
columns: ["organization_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "organizations"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
}
|
}
|
||||||
shared_notes: {
|
shared_notes: {
|
||||||
Row: {
|
Row: {
|
||||||
|
|
@ -573,7 +1216,9 @@ export type Database = {
|
||||||
deleted_at: string | null
|
deleted_at: string | null
|
||||||
id: string
|
id: string
|
||||||
image: string | null
|
image: string | null
|
||||||
|
layout_overview_v1: Json | null
|
||||||
name: string
|
name: string
|
||||||
|
organization_id: number
|
||||||
owner_id: string
|
owner_id: string
|
||||||
position: number
|
position: number
|
||||||
status: string | null
|
status: string | null
|
||||||
|
|
@ -585,7 +1230,9 @@ export type Database = {
|
||||||
deleted_at?: string | null
|
deleted_at?: string | null
|
||||||
id?: string
|
id?: string
|
||||||
image?: string | null
|
image?: string | null
|
||||||
|
layout_overview_v1?: Json | null
|
||||||
name: string
|
name: string
|
||||||
|
organization_id: number
|
||||||
owner_id: string
|
owner_id: string
|
||||||
position?: number
|
position?: number
|
||||||
status?: string | null
|
status?: string | null
|
||||||
|
|
@ -597,19 +1244,31 @@ export type Database = {
|
||||||
deleted_at?: string | null
|
deleted_at?: string | null
|
||||||
id?: string
|
id?: string
|
||||||
image?: string | null
|
image?: string | null
|
||||||
|
layout_overview_v1?: Json | null
|
||||||
name?: string
|
name?: string
|
||||||
|
organization_id?: number
|
||||||
owner_id?: string
|
owner_id?: string
|
||||||
position?: number
|
position?: number
|
||||||
status?: string | null
|
status?: string | null
|
||||||
updated_at?: string | null
|
updated_at?: string | null
|
||||||
}
|
}
|
||||||
Relationships: []
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "tablos_organization_id_fkey"
|
||||||
|
columns: ["organization_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "organizations"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
}
|
}
|
||||||
tasks: {
|
tasks: {
|
||||||
Row: {
|
Row: {
|
||||||
assignee_id: string | null
|
assignee_id: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
|
deleted_at: string | null
|
||||||
description: string | null
|
description: string | null
|
||||||
|
due_date: string | null
|
||||||
id: string
|
id: string
|
||||||
is_parent: boolean
|
is_parent: boolean
|
||||||
parent_task_id: string | null
|
parent_task_id: string | null
|
||||||
|
|
@ -622,7 +1281,9 @@ export type Database = {
|
||||||
Insert: {
|
Insert: {
|
||||||
assignee_id?: string | null
|
assignee_id?: string | null
|
||||||
created_at?: string
|
created_at?: string
|
||||||
|
deleted_at?: string | null
|
||||||
description?: string | null
|
description?: string | null
|
||||||
|
due_date?: string | null
|
||||||
id?: string
|
id?: string
|
||||||
is_parent?: boolean
|
is_parent?: boolean
|
||||||
parent_task_id?: string | null
|
parent_task_id?: string | null
|
||||||
|
|
@ -635,7 +1296,9 @@ export type Database = {
|
||||||
Update: {
|
Update: {
|
||||||
assignee_id?: string | null
|
assignee_id?: string | null
|
||||||
created_at?: string
|
created_at?: string
|
||||||
|
deleted_at?: string | null
|
||||||
description?: string | null
|
description?: string | null
|
||||||
|
due_date?: string | null
|
||||||
id?: string
|
id?: string
|
||||||
is_parent?: boolean
|
is_parent?: boolean
|
||||||
parent_task_id?: string | null
|
parent_task_id?: string | null
|
||||||
|
|
@ -728,6 +1391,7 @@ export type Database = {
|
||||||
assignee_name: string | null
|
assignee_name: string | null
|
||||||
created_at: string | null
|
created_at: string | null
|
||||||
description: string | null
|
description: string | null
|
||||||
|
due_date: string | null
|
||||||
id: string | null
|
id: string | null
|
||||||
is_parent: boolean | null
|
is_parent: boolean | null
|
||||||
parent_task_id: string | null
|
parent_task_id: string | null
|
||||||
|
|
@ -805,6 +1469,9 @@ export type Database = {
|
||||||
Args: { tablo_id_param: string }
|
Args: { tablo_id_param: string }
|
||||||
Returns: 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 }
|
generate_random_string: { Args: { length?: number }; Returns: string }
|
||||||
get_my_active_subscription: {
|
get_my_active_subscription: {
|
||||||
Args: never
|
Args: never
|
||||||
|
|
@ -895,11 +1562,20 @@ export type Database = {
|
||||||
subscription_id: string
|
subscription_id: string
|
||||||
}[]
|
}[]
|
||||||
}
|
}
|
||||||
|
is_freemium_available: { Args: never; Returns: boolean }
|
||||||
is_paying_user: { Args: { user_uuid: string }; Returns: boolean }
|
is_paying_user: { Args: { user_uuid: string }; Returns: boolean }
|
||||||
}
|
}
|
||||||
Enums: {
|
Enums: {
|
||||||
devis_status: "draft" | "sent" | "accepted" | "rejected" | "expired"
|
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"
|
task_status: "todo" | "in_progress" | "in_review" | "done"
|
||||||
}
|
}
|
||||||
CompositeTypes: {
|
CompositeTypes: {
|
||||||
|
|
@ -1029,11 +1705,24 @@ export type CompositeTypes<
|
||||||
: never
|
: never
|
||||||
|
|
||||||
export const Constants = {
|
export const Constants = {
|
||||||
|
graphql_public: {
|
||||||
|
Enums: {},
|
||||||
|
},
|
||||||
public: {
|
public: {
|
||||||
Enums: {
|
Enums: {
|
||||||
devis_status: ["draft", "sent", "accepted", "rejected", "expired"],
|
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"],
|
task_status: ["todo", "in_progress", "in_review", "done"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
|
|
||||||
122
xtablo-expo/lib/purchases.ts
Normal file
122
xtablo-expo/lib/purchases.ts
Normal 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;
|
||||||
227
xtablo-expo/package-lock.json
generated
227
xtablo-expo/package-lock.json
generated
|
|
@ -47,6 +47,7 @@
|
||||||
"react-native": "0.81.5",
|
"react-native": "0.81.5",
|
||||||
"react-native-gesture-handler": "~2.28.0",
|
"react-native-gesture-handler": "~2.28.0",
|
||||||
"react-native-get-random-values": "~1.11.0",
|
"react-native-get-random-values": "~1.11.0",
|
||||||
|
"react-native-purchases": "^10.0.1",
|
||||||
"react-native-reanimated": "~4.1.1",
|
"react-native-reanimated": "~4.1.1",
|
||||||
"react-native-safe-area-context": "~5.6.0",
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
"react-native-screens": "~4.16.0",
|
"react-native-screens": "~4.16.0",
|
||||||
|
|
@ -4412,6 +4413,27 @@
|
||||||
"nanoid": "^3.3.11"
|
"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": {
|
"node_modules/@rn-vui/base": {
|
||||||
"version": "5.1.3",
|
"version": "5.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rn-vui/base/-/base-5.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rn-vui/base/-/base-5.1.3.tgz",
|
||||||
|
|
@ -6576,26 +6598,6 @@
|
||||||
"node": ">=12"
|
"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": {
|
"node_modules/cliui/node_modules/strip-ansi": {
|
||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
"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"
|
"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": {
|
"node_modules/encodeurl": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
|
||||||
|
|
@ -13239,7 +13247,6 @@
|
||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.4.0",
|
"loose-envify": "^1.4.0",
|
||||||
|
|
@ -13251,7 +13258,6 @@
|
||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/proxy-from-env": {
|
"node_modules/proxy-from-env": {
|
||||||
|
|
@ -13545,6 +13551,31 @@
|
||||||
"react-native": "*"
|
"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": {
|
"node_modules/react-native-reanimated": {
|
||||||
"version": "4.1.7",
|
"version": "4.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.7.tgz",
|
||||||
|
|
@ -13632,6 +13663,78 @@
|
||||||
"react-native": "*"
|
"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": {
|
"node_modules/react-native-web": {
|
||||||
"version": "0.21.2",
|
"version": "0.21.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz",
|
||||||
|
|
@ -14936,6 +15039,32 @@
|
||||||
"node": ">=8"
|
"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": {
|
"node_modules/string.prototype.matchall": {
|
||||||
"version": "4.0.12",
|
"version": "4.0.12",
|
||||||
"resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz",
|
"resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz",
|
||||||
|
|
@ -15566,7 +15695,7 @@
|
||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
|
|
@ -16317,26 +16446,6 @@
|
||||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
"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": {
|
"node_modules/wrap-ansi/node_modules/strip-ansi": {
|
||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
|
|
@ -16502,38 +16611,6 @@
|
||||||
"node": ">=12"
|
"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": {
|
"node_modules/yocto-queue": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@
|
||||||
"react-native": "0.81.5",
|
"react-native": "0.81.5",
|
||||||
"react-native-gesture-handler": "~2.28.0",
|
"react-native-gesture-handler": "~2.28.0",
|
||||||
"react-native-get-random-values": "~1.11.0",
|
"react-native-get-random-values": "~1.11.0",
|
||||||
|
"react-native-purchases": "^10.0.1",
|
||||||
"react-native-reanimated": "~4.1.1",
|
"react-native-reanimated": "~4.1.1",
|
||||||
"react-native-safe-area-context": "~5.6.0",
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
"react-native-screens": "~4.16.0",
|
"react-native-screens": "~4.16.0",
|
||||||
|
|
|
||||||
124
xtablo-expo/stores/__tests__/auth.test.ts
Normal file
124
xtablo-expo/stores/__tests__/auth.test.ts
Normal 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."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -9,6 +9,8 @@ import { QueryClient } from "@tanstack/react-query";
|
||||||
import { User } from "@/types/user.types";
|
import { User } from "@/types/user.types";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import * as AppleAuthentication from "expo-apple-authentication";
|
import * as AppleAuthentication from "expo-apple-authentication";
|
||||||
|
import { ensurePurchasesConfigured } from "@/lib/purchases";
|
||||||
|
import { organizationBillingQueryKey } from "@/hooks/organization";
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
session: Session | null;
|
session: Session | null;
|
||||||
|
|
@ -29,7 +31,7 @@ interface AuthState {
|
||||||
signInWithApple: () => Promise<void>;
|
signInWithApple: () => Promise<void>;
|
||||||
signOut: () => Promise<void>;
|
signOut: () => Promise<void>;
|
||||||
createSessionFromUrl: (url: string) => Promise<void>;
|
createSessionFromUrl: (url: string) => Promise<void>;
|
||||||
fetchAndSetUser: (session: Session | null) => Promise<void>;
|
fetchAndSetUser: (session: Session | null, queryClient?: QueryClient) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
WebBrowser.maybeCompleteAuthSession();
|
WebBrowser.maybeCompleteAuthSession();
|
||||||
|
|
@ -41,8 +43,13 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||||
loading: true,
|
loading: true,
|
||||||
initialized: false,
|
initialized: false,
|
||||||
setSession: (session: Session | null) => set({ session }),
|
setSession: (session: Session | null) => set({ session }),
|
||||||
fetchAndSetUser: async (session: Session | null) => {
|
fetchAndSetUser: async (session: Session | null, queryClient?: QueryClient) => {
|
||||||
if (!session) return;
|
if (!session) {
|
||||||
|
set({ user: null });
|
||||||
|
queryClient?.removeQueries({ queryKey: organizationBillingQueryKey });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data } = await api.get<User>("/api/v1/users/me", {
|
const { data } = await api.get<User>("/api/v1/users/me", {
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -50,6 +57,8 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
set({ user: data });
|
set({ user: data });
|
||||||
|
await ensurePurchasesConfigured(data.id);
|
||||||
|
await queryClient?.invalidateQueries({ queryKey: organizationBillingQueryKey });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching user:", error);
|
console.error("Error fetching user:", error);
|
||||||
}
|
}
|
||||||
|
|
@ -63,12 +72,13 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||||
set({
|
set({
|
||||||
session,
|
session,
|
||||||
});
|
});
|
||||||
|
await get().fetchAndSetUser(session, queryClient);
|
||||||
|
|
||||||
supabase.auth.onAuthStateChange(async (event, session) => {
|
supabase.auth.onAuthStateChange(async (event, session) => {
|
||||||
set({
|
set({
|
||||||
session,
|
session,
|
||||||
});
|
});
|
||||||
await get().fetchAndSetUser(session);
|
await get().fetchAndSetUser(session, queryClient);
|
||||||
});
|
});
|
||||||
|
|
||||||
const initialUrl = await Linking.getInitialURL();
|
const initialUrl = await Linking.getInitialURL();
|
||||||
|
|
@ -102,18 +112,34 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||||
lastName: string,
|
lastName: string,
|
||||||
companyName: string
|
companyName: string
|
||||||
) => {
|
) => {
|
||||||
await supabase.auth.signUp({
|
set({ loading: true });
|
||||||
email,
|
|
||||||
password,
|
try {
|
||||||
options: {
|
const { data, error } = await supabase.auth.signUp({
|
||||||
data: {
|
email,
|
||||||
firstName,
|
password,
|
||||||
lastName,
|
options: {
|
||||||
companyName,
|
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) => {
|
performOAuth: async (provider: Provider) => {
|
||||||
const { data, error } = await supabase.auth.signInWithOAuth({
|
const { data, error } = await supabase.auth.signInWithOAuth({
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
51
xtablo-expo/types/organization.types.ts
Normal file
51
xtablo-expo/types/organization.types.ts
Normal 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;
|
||||||
|
};
|
||||||
Loading…
Reference in a new issue