Repository: appwrite/console
Pull request #1760: Feat: validate live payment
Author: login: lohanidamodar
Created: 2025-03-23T06:07:31Z | Merged: 2025-03-27T09:07:32Z

<!--
Thank you for sending the PR! We appreciate you spending the time to work on these changes.

Help us understand your motivation by explaining why you decided to make this change.

You can learn more about contributing to appwrite here: https://github.com/appwrite/appwrite/blob/master/CONTRIBUTING.md

Happy contributing!

-->

## What does this PR do?

- Validate payment after live retry for failed invoice
- Previously relied in weebhook to update the invoice status
- Will immediately update the invoice status to reflect the payment status

## Test Plan

(Write your test plan here. If you changed any code, please provide us with clear instructions on how you verified your changes work.)

## Related PRs and Issues

(If this PR is related to any other PR or resolves any issue or related to any issue link all related PR and issues here.)

### Have you read the [Contributing Guidelines on issues](https://github.com/appwrite/appwrite/blob/master/CONTRIBUTING.md)?

YES


Diff:
diff --git a/src/lib/components/heading.svelte b/src/lib/components/heading.svelte
index a8ce049483..d018314610 100644
--- a/src/lib/components/heading.svelte
+++ b/src/lib/components/heading.svelte
@@ -14,6 +14,7 @@
 
 <svelte:element
     this={tag}
+    class:target={!!id}
     class={`heading-level-${size} u-min-width-0 ${classes}`}
     class:u-trim-1={trimmed}
     class:u-trim-2={trimmedSecondLine}
@@ -23,7 +24,7 @@
 </svelte:element>
 
 <style>
-    :target {
+    .target {
         padding-top: 120px;
         margin-top: -120px;
     }
diff --git a/src/lib/sdk/billing.ts b/src/lib/sdk/billing.ts
index 68db777fbb..e18ef961af 100644
--- a/src/lib/sdk/billing.ts
+++ b/src/lib/sdk/billing.ts
@@ -739,6 +739,14 @@ export class Billing {
         );
     }
 
+    async updateInvoiceStatus(organizationId: string, invoiceId: string): Promise<Invoice> {
+        const path = `/organizations/${organizationId}/invoices/${invoiceId}/status`;
+        const uri = new URL(this.client.config.endpoint + path);
+        return await this.client.call('PATCH', uri, {
+            'content-type': 'application/json'
+        });
+    }
+
     async retryPayment(
         organizationId: string,
         invoiceId: string,
diff --git a/src/lib/stores/billing.ts b/src/lib/stores/billing.ts
index 3923ce2500..0cfd78499b 100644
--- a/src/lib/stores/billing.ts
+++ b/src/lib/stores/billing.ts
@@ -33,6 +33,7 @@ import { canSeeBilling } from './roles';
 import { sdk } from './sdk';
 import { user } from './user';
 import BudgetLimitAlert from '$routes/(console)/organization-[organization]/budgetLimitAlert.svelte';
+import TeamReadonlyAlert from '$routes/(console)/organization-[organization]/teamReadonlyAlert.svelte';
 
 export type Tier = 'tier-0' | 'tier-1' | 'tier-2' | 'auto-1' | 'cont-1' | 'ent-1';
 
@@ -59,6 +60,9 @@ export const roles = [
     }
 ];
 
+export const teamStatusReadonly = 'readonly';
+export const billingLimitOutstandingInvoice = 'outstanding_invoice';
+
 export const paymentMethods = derived(page, ($page) => $page.data.paymentMethods as PaymentList);
 export const addressList = derived(page, ($page) => $page.data.addressList as AddressesList);
 export const plansInfo = derived(page, ($page) => $page.data.plansInfo as PlansMap);
@@ -270,27 +274,35 @@ export function calculateTrialDay(org: Organization) {
 }
 
 export async function checkForUsageLimit(org: Organization) {
-    if (!org?.billingLimits) {
+    if (org?.status === teamStatusReadonly && org?.remarks === billingLimitOutstandingInvoice) {
+        headerAlert.add({
+            id: 'teamReadOnlyFailedInvoices',
+            component: TeamReadonlyAlert,
+            show: true,
+            importance: 11
+        });
+        readOnly.set(true);
+        return;
+    }
+    if (!org?.billingLimits && org?.status !== teamStatusReadonly) {
         readOnly.set(false);
         return;
     }
     if (org?.billingPlan !== BillingPlan.FREE) {
         const { budgetLimit } = org?.billingLimits ?? {};
 
-        if (!budgetLimit || budgetLimit < 100) {
+        if (budgetLimit && budgetLimit >= 100) {
             readOnly.set(false);
+            headerAlert.add({
+                id: 'budgetLimit',
+                component: BudgetLimitAlert,
+                show: true,
+                importance: 10
+            });
+
+            readOnly.set(true);
             return;
         }
-
-        headerAlert.add({
-            id: 'budgetLimit',
-            component: BudgetLimitAlert,
-            show: true,
-            importance: 10
-        });
-
-        readOnly.set(true);
-        return;
     }
     const { bandwidth, documents, executions, storage, users } = org?.billingLimits ?? {};
     const resources = [
diff --git a/src/lib/stores/organization.ts b/src/lib/stores/organization.ts
index 3f02fe50a0..471c25d218 100644
--- a/src/lib/stores/organization.ts
+++ b/src/lib/stores/organization.ts
@@ -32,6 +32,8 @@ export type Organization = Models.Team<Record<string, unknown>> & {
     billingPlanDowngrade?: Tier;
     billingAggregationId: string;
     billingInvoiceId: string;
+    status: string;
+    remarks: string;
 };
 
 export type OrganizationList = {
diff --git a/src/routes/(console)/organization-[organization]/billing/+page.svelte b/src/routes/(console)/organization-[organization]/billing/+page.svelte
index 2d3772358b..1e73af0c61 100644
--- a/src/routes/(console)/organization-[organization]/billing/+page.svelte
+++ b/src/routes/(console)/organization-[organization]/billing/+page.svelte
@@ -19,7 +19,9 @@
     import RetryPaymentModal from './retryPaymentModal.svelte';
     import { selectedInvoice, showRetryModal } from './store';
     import { Button } from '$lib/elements/forms';
-    import { goto } from '$app/navigation';
+    import { goto, invalidate } from '$app/navigation';
+    import { Dependencies } from '$lib/constants';
+    import { base } from '$app/paths';
 
     export let data;
 
@@ -50,10 +52,21 @@
                 await confirmPayment(
                     $organization.$id,
                     invoice.clientSecret,
-                    $organization.paymentMethodId
+                    $organization.paymentMethodId,
+                    `${base}/organization-${$organization.$id}/billing?type=validate-invoice&invoice=${invoice.$id}`
                 );
             }
 
+            if (
+                $page.url.searchParams.has('type') &&
+                $page.url.searchParams.get('type') === 'validate-invoice'
+            ) {
+                const invoiceId = $page.url.searchParams.get('invoice');
+                await sdk.forConsole.billing.updateInvoiceStatus($organization.$id, invoiceId);
+                invalidate(Dependencies.INVOICES);
+                invalidate(Dependencies.ORGANIZATION);
+            }
+
             if (
                 $page.url.searchParams.has('invoice') &&
                 $page.url.searchParams.get('type') === 'retry'
diff --git a/src/routes/(console)/organization-[organization]/billing/paymentHistory.svelte b/src/routes/(console)/organization-[organization]/billing/paymentHistory.svelte
index 67a5fbc582..1272dc4663 100644
--- a/src/routes/(console)/organization-[organization]/billing/paymentHistory.svelte
+++ b/src/routes/(console)/organization-[organization]/billing/paymentHistory.svelte
@@ -51,6 +51,11 @@
         ]);
     }
 
+    $: if ($page.url.searchParams.get('type') === 'validate-invoice') {
+        window.history.replaceState({}, '', $page.url.pathname);
+        request();
+    }
+
     function retryPayment(invoice: Invoice) {
         $selectedInvoice = invoice;
         $showRetryModal = true;
@@ -62,7 +67,7 @@
 </script>
 
 <CardGrid>
-    <Heading tag="h2" size="6">Payment history</Heading>
+    <Heading id="payment-history" tag="h2" size="6">Payment history</Heading>
 
     <p class="text">
         Transaction history for this organization. Download invoices for more details about your
diff --git a/src/routes/(console)/organization-[organization]/billing/retryPaymentModal.svelte b/src/routes/(console)/organization-[organization]/billing/retryPaymentModal.svelte
index 7606cbf98d..94fe73d44c 100644
--- a/src/routes/(console)/organization-[organization]/billing/retryPaymentModal.svelte
+++ b/src/routes/(console)/organization-[organization]/billing/retryPaymentModal.svelte
@@ -20,6 +20,7 @@
     import { onMount } from 'svelte';
     import { getApiEndpoint, sdk } from '$lib/stores/sdk';
     import { formatCurrency } from '$lib/helpers/numbers';
+    import { base } from '$app/paths';
 
     export let show = false;
     export let invoice: Invoice;
@@ -79,8 +80,12 @@
             await confirmPayment(
                 $organization.$id,
                 clientSecret,
-                paymentMethodId ? paymentMethodId : $organization.paymentMethodId
+                paymentMethodId ? paymentMethodId : $organization.paymentMethodId,
+                `${base}/organization-${$organization.$id}/billing?type=validate-invoice&invoice=${invoice.$id}`
             );
+
+            await sdk.forConsole.billing.updateInvoiceStatus($organization.$id, invoice.$id);
+
             invalidate(Dependencies.ORGANIZATION);
             invalidate(Dependencies.INVOICES);
 
diff --git a/src/routes/(console)/organization-[organization]/teamReadonlyAlert.svelte b/src/routes/(console)/organization-[organization]/teamReadonlyAlert.svelte
new file mode 100644
index 0000000000..19fc2bf371
--- /dev/null
+++ b/src/routes/(console)/organization-[organization]/teamReadonlyAlert.svelte
@@ -0,0 +1,29 @@
+<script lang="ts">
+    import { page } from '$app/stores';
+    import { Button } from '$lib/elements/forms';
+    import { organization } from '$lib/stores/organization';
+    import { HeaderAlert } from '$lib/layout';
+    import {
+        billingLimitOutstandingInvoice,
+        hideBillingHeaderRoutes,
+        readOnly,
+        teamStatusReadonly
+    } from '$lib/stores/billing';
+    import { base } from '$app/paths';
+
+    $: redirectUrl = `${base}/organization-${$organization?.$id}/billing#payment-history`;
+</script>
+
+{#if $organization?.$id && $organization?.status === teamStatusReadonly && $organization?.remarks === billingLimitOutstandingInvoice && $readOnly && !hideBillingHeaderRoutes.includes($page.url.pathname)}
+    <HeaderAlert type="error" title="Access restricted">
+        <svelte:fragment>
+            Your organization’s access to resources has been restricted due to unpaid invoices.
+            Payment is required to restore access.
+        </svelte:fragment>
+        <svelte:fragment slot="buttons">
+            <Button secondary fullWidthMobile href={redirectUrl}>
+                <span class="text">View invoices</span>
+            </Button>
+        </svelte:fragment>
+    </HeaderAlert>
+{/if}


Reviews:
user: login: ItzNotABug
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-24T06:07:15Z
user: login: lohanidamodar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-24T06:10:35Z
user: login: lohanidamodar
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-24T06:12:14Z
user: login: ItzNotABug
body: LGTM.
**Note: The copy needs to be confirmed so this shouldn't be merged before that.**
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-24T06:13:17Z
user: login: ItzNotABug
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-24T09:06:11Z
user: login: christyjacob4
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T09:07:26Z

Review comments:
diff_hunk: @@ -739,6 +739,14 @@ export class Billing {
         );
     }
 
+    async validateInvoice(organizationId: string, invoiceId: string): Promise<Invoice> {
+        const path = `/organizations/${organizationId}/invoices/${invoiceId}/validate`;
+        const uri = new URL(this.client.config.endpoint + path);
+        return await this.client.call('PATCH', uri, {
path: src/lib/sdk/billing.ts
user: login: ItzNotABug
body: Why is this a `PATCH`? Is this also updating the invoice on backend?
Should be a `GET` if we are just fetching the details of an invoice, right?
created_at: 2025-03-24T06:04:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,29 @@
+<script lang="ts">
+    import { page } from '$app/stores';
+    import { Button } from '$lib/elements/forms';
+    import { organization } from '$lib/stores/organization';
+    import { HeaderAlert } from '$lib/layout';
+    import {
+        billingLimitOutstandingInvoice,
+        hideBillingHeaderRoutes,
+        readOnly,
+        teamStatusReadonly
+    } from '$lib/stores/billing';
+    import { base } from '$app/paths';
+
+    $: redirectUrl = `${base}/organization-${$organization.$id}/billing#payment-history`;
+</script>
+
+{#if $organization?.$id && $organization?.status === teamStatusReadonly && $organization?.remarks === billingLimitOutstandingInvoice && $readOnly && !hideBillingHeaderRoutes.includes($page.url.pathname)}
+    <HeaderAlert type="error" title="Access restricted">
+        <svelte:fragment>
+            Access to your organization resources has been restricted as you have unpaid invoices.
+            Please pay the invoice to restore access.
path: src/routes/(console)/organization-[organization]/teamReadonlyAlert.svelte
user: login: ItzNotABug
body: is this copy cleared by design?
created_at: 2025-03-24T06:07:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,29 @@
+<script lang="ts">
+    import { page } from '$app/stores';
+    import { Button } from '$lib/elements/forms';
+    import { organization } from '$lib/stores/organization';
+    import { HeaderAlert } from '$lib/layout';
+    import {
+        billingLimitOutstandingInvoice,
+        hideBillingHeaderRoutes,
+        readOnly,
+        teamStatusReadonly
+    } from '$lib/stores/billing';
+    import { base } from '$app/paths';
+
+    $: redirectUrl = `${base}/organization-${$organization.$id}/billing#payment-history`;
+</script>
+
+{#if $organization?.$id && $organization?.status === teamStatusReadonly && $organization?.remarks === billingLimitOutstandingInvoice && $readOnly && !hideBillingHeaderRoutes.includes($page.url.pathname)}
+    <HeaderAlert type="error" title="Access restricted">
+        <svelte:fragment>
+            Access to your organization resources has been restricted as you have unpaid invoices.
+            Please pay the invoice to restore access.
path: src/routes/(console)/organization-[organization]/teamReadonlyAlert.svelte
user: login: lohanidamodar
body: not yet, I'll have a setup for design to review.
created_at: 2025-03-24T06:10:32Z
author_association: MEMBER
diff_hunk: @@ -739,6 +739,14 @@ export class Billing {
         );
     }
 
+    async validateInvoice(organizationId: string, invoiceId: string): Promise<Invoice> {
+        const path = `/organizations/${organizationId}/invoices/${invoiceId}/validate`;
+        const uri = new URL(this.client.config.endpoint + path);
+        return await this.client.call('PATCH', uri, {
path: src/lib/sdk/billing.ts
user: login: lohanidamodar
body: Yes, it will check the state of payment and updates the invoice if required so it's a PATCH
created_at: 2025-03-24T06:12:13Z
author_association: MEMBER

PR comments: