diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 297e7fd3d..8904b795d 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -32,11 +32,16 @@ jobs: - name: Install dependencies run: pnpm install + - name: Typecheck run: pnpm run typecheck + - name: Lint run: pnpm run lint + - name: Production Build (test env) + run: pnpm --filter web build:test + test: name: ⚫️ Test timeout-minutes: 8 diff --git a/apps/e2e/tests/team-accounts/team-accounts.po.ts b/apps/e2e/tests/team-accounts/team-accounts.po.ts index 4e51d33bc..eeefe25de 100644 --- a/apps/e2e/tests/team-accounts/team-accounts.po.ts +++ b/apps/e2e/tests/team-accounts/team-accounts.po.ts @@ -37,6 +37,12 @@ export class TeamAccountsPageObject { }).click(); } + async goToBilling() { + await this.page.locator('a', { + hasText: 'Billing', + }).click(); + } + async openAccountsSelector() { await this.page.click('[data-test="account-selector-trigger"]'); } diff --git a/apps/e2e/tests/team-billing/team-billing.po.ts b/apps/e2e/tests/team-billing/team-billing.po.ts new file mode 100644 index 000000000..f27cc3aae --- /dev/null +++ b/apps/e2e/tests/team-billing/team-billing.po.ts @@ -0,0 +1,18 @@ +import { Page } from '@playwright/test'; +import { StripePageObject } from '../utils/stripe.po'; +import { TeamAccountsPageObject } from '../team-accounts/team-accounts.po'; + +export class TeamBillingPageObject { + private readonly teamAccounts: TeamAccountsPageObject; + public readonly stripe: StripePageObject; + + constructor(page: Page) { + this.teamAccounts = new TeamAccountsPageObject(page); + this.stripe = new StripePageObject(page); + } + + async setup() { + await this.teamAccounts.setup(); + await this.teamAccounts.goToBilling(); + } +} \ No newline at end of file diff --git a/apps/e2e/tests/team-billing/team-billing.spec.ts b/apps/e2e/tests/team-billing/team-billing.spec.ts new file mode 100644 index 000000000..dd46465be --- /dev/null +++ b/apps/e2e/tests/team-billing/team-billing.spec.ts @@ -0,0 +1,24 @@ +import { expect, Page, test } from '@playwright/test'; +import { TeamBillingPageObject } from './team-billing.po'; + +test.describe('Team Billing', () => { + let page: Page; + let billing: TeamBillingPageObject; + + test.beforeAll(async ({ browser }) => { + page = await browser.newPage(); + billing = new TeamBillingPageObject(page); + + await billing.setup(); + }); + + test('a team can subscribe to a plan', async () => { + await billing.stripe.selectPlan(0); + await billing.stripe.proceedToCheckout(); + + await billing.stripe.fillForm(); + await billing.stripe.submitForm(); + + await expect(billing.stripe.successStatus()).toBeVisible(); + }); +}); \ No newline at end of file diff --git a/apps/e2e/tests/user-billing/user-billing.po.ts b/apps/e2e/tests/user-billing/user-billing.po.ts new file mode 100644 index 000000000..0468cb2c2 --- /dev/null +++ b/apps/e2e/tests/user-billing/user-billing.po.ts @@ -0,0 +1,17 @@ +import { Page } from '@playwright/test'; +import { AuthPageObject } from '../authentication/auth.po'; +import { StripePageObject } from '../utils/stripe.po'; + +export class UserBillingPageObject { + private readonly auth: AuthPageObject; + public readonly stripe: StripePageObject; + + constructor(page: Page) { + this.auth = new AuthPageObject(page); + this.stripe = new StripePageObject(page); + } + + async setup() { + await this.auth.signUpFlow('/home/billing'); + } +} \ No newline at end of file diff --git a/apps/e2e/tests/user-billing/user-billing.spec.ts b/apps/e2e/tests/user-billing/user-billing.spec.ts new file mode 100644 index 000000000..9ef729356 --- /dev/null +++ b/apps/e2e/tests/user-billing/user-billing.spec.ts @@ -0,0 +1,24 @@ +import { expect, Page, test } from '@playwright/test'; +import { UserBillingPageObject } from './user-billing.po'; + +test.describe('User Billing', () => { + let page: Page; + let billing: UserBillingPageObject; + + test.beforeAll(async ({ browser }) => { + page = await browser.newPage(); + billing = new UserBillingPageObject(page); + + await billing.setup(); + }); + + test('user can subscribe to a plan', async () => { + await billing.stripe.selectPlan(0); + await billing.stripe.proceedToCheckout(); + + await billing.stripe.fillForm(); + await billing.stripe.submitForm(); + + await expect(billing.stripe.successStatus()).toBeVisible(); + }); +}); \ No newline at end of file diff --git a/apps/e2e/tests/utils/stripe.po.ts b/apps/e2e/tests/utils/stripe.po.ts new file mode 100644 index 000000000..ecbee69ee --- /dev/null +++ b/apps/e2e/tests/utils/stripe.po.ts @@ -0,0 +1,87 @@ +import { Page, expect } from '@playwright/test'; + +export class StripePageObject { + private page: Page; + + constructor(page: Page) { + this.page = page; + } + + plans() { + return this.page.locator('[data-test-plan]'); + } + + getStripeCheckoutIframe() { + return this.page.frameLocator('[name="embedded-checkout"]'); + } + + async fillForm(params: { + billingName?: string; + cardNumber?: string; + expiry?: string; + cvc?: string; + billingCountry?: string; + } = {}) { + expect(() => { + return this.getStripeCheckoutIframe().locator('form').isVisible(); + }); + + const billingName = this.billingName(); + const cardNumber = this.cardNumber(); + const expiry = this.expiry(); + const cvc = this.cvc(); + const billingCountry = this.billingCountry(); + + await billingName.fill(params.billingName ?? 'Mr Makerkit'); + await cardNumber.fill(params.cardNumber ?? '4242424242424242'); + await expiry.fill(params.expiry ?? '1228'); + await cvc.fill(params.cvc ?? '123'); + await billingCountry.selectOption(params.billingCountry ?? 'IT'); + } + + submitForm() { + return this.getStripeCheckoutIframe().locator('form button').click(); + } + + cardNumber() { + return this.getStripeCheckoutIframe().locator('#cardNumber'); + } + + cvc() { + return this.getStripeCheckoutIframe().locator('#cardCvc'); + } + + expiry() { + return this.getStripeCheckoutIframe().locator('#cardExpiry'); + } + + billingName() { + return this.getStripeCheckoutIframe().locator('#billingName'); + } + + cardForm() { + return this.getStripeCheckoutIframe().locator('form'); + } + + billingCountry() { + return this.getStripeCheckoutIframe().locator('#billingCountry'); + } + + selectPlan(index: number = 0) { + const plans = this.plans(); + + return plans.nth(index).click(); + } + + manageBillingButton() { + return this.page.locator('manage-billing-redirect-button'); + } + + successStatus() { + return this.page.locator('[data-test="payment-return-success"]'); + } + + proceedToCheckout() { + return this.page.click('[data-test="checkout-submit-button"]'); + } +} \ No newline at end of file diff --git a/apps/web/app/(marketing)/_components/site-page-header.tsx b/apps/web/app/(marketing)/_components/site-page-header.tsx index 5fded2637..b767f9434 100644 --- a/apps/web/app/(marketing)/_components/site-page-header.tsx +++ b/apps/web/app/(marketing)/_components/site-page-header.tsx @@ -10,12 +10,8 @@ export function SitePageHeader(props: {

{props.title}

-

- {props.subtitle} +

+ {props.subtitle}

diff --git a/apps/web/app/(marketing)/blog/_components/post-header.tsx b/apps/web/app/(marketing)/blog/_components/post-header.tsx index 7a0559063..88d57405f 100644 --- a/apps/web/app/(marketing)/blog/_components/post-header.tsx +++ b/apps/web/app/(marketing)/blog/_components/post-header.tsx @@ -1,11 +1,5 @@ -import Link from 'next/link'; - -import { ArrowLeft } from 'lucide-react'; - import { Cms } from '@kit/cms'; -import { Button } from '@kit/ui/button'; import { If } from '@kit/ui/if'; -import { Trans } from '@kit/ui/trans'; import { cn } from '@kit/ui/utils'; import { CoverImage } from '~/(marketing)/blog/_components/cover-image'; @@ -17,25 +11,27 @@ export const PostHeader: React.FC<{ const { title, publishedAt, description, image } = post; return ( -
+
-
+

{title}

-

- -

+
+ + + +
- +

{(imageUrl) => ( -
+
-
+
diff --git a/apps/web/app/api/billing/webhook/route.ts b/apps/web/app/api/billing/webhook/route.ts index 0b6c97207..1a206c502 100644 --- a/apps/web/app/api/billing/webhook/route.ts +++ b/apps/web/app/api/billing/webhook/route.ts @@ -39,11 +39,13 @@ export async function POST(request: Request) { ); return new Response('OK', { status: 200 }); - } catch (e) { + } catch (error) { + console.error(error); + logger.error( { name: 'billing', - error: e, + error: JSON.stringify(error), }, `Failed to process billing webhook`, ); diff --git a/apps/web/config/billing.sample.config.ts b/apps/web/config/billing.sample.config.ts index 295406d34..0b613fcdc 100644 --- a/apps/web/config/billing.sample.config.ts +++ b/apps/web/config/billing.sample.config.ts @@ -31,7 +31,7 @@ export default createBillingSchema({ interval: 'month', lineItems: [ { - id: '324646', + id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe', name: 'Addon 2', cost: 9.99, type: 'flat', @@ -45,7 +45,7 @@ export default createBillingSchema({ interval: 'year', lineItems: [ { - id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe1', + id: 'starter-yearly', name: 'Base', cost: 99.99, type: 'flat', @@ -70,7 +70,7 @@ export default createBillingSchema({ interval: 'month', lineItems: [ { - id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe2', + id: 'price_pro', name: 'Base', cost: 19.99, type: 'flat', @@ -84,7 +84,7 @@ export default createBillingSchema({ interval: 'year', lineItems: [ { - id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe3', + id: 'price_pro_yearly', name: 'Base', cost: 199.99, type: 'flat', @@ -113,7 +113,7 @@ export default createBillingSchema({ interval: 'month', lineItems: [ { - id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe4', + id: 'price_enterprise-monthly', name: 'Base', cost: 29.99, type: 'flat', @@ -127,7 +127,7 @@ export default createBillingSchema({ interval: 'year', lineItems: [ { - id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe5', + id: 'price_enterprise_yearly', name: 'Base', cost: 299.99, type: 'flat', diff --git a/packages/billing/core/src/create-billing-schema.ts b/packages/billing/core/src/create-billing-schema.ts index 496fc1fd1..c85d4305d 100644 --- a/packages/billing/core/src/create-billing-schema.ts +++ b/packages/billing/core/src/create-billing-schema.ts @@ -356,7 +356,7 @@ export function getLineItemTypeById( for (const product of config.products) { for (const plan of product.plans) { for (const lineItem of plan.lineItems) { - if (lineItem.type === id) { + if (lineItem.id === id) { return lineItem.type; } } diff --git a/packages/billing/gateway/src/components/plan-picker.tsx b/packages/billing/gateway/src/components/plan-picker.tsx index 58841c222..79a91c38b 100644 --- a/packages/billing/gateway/src/components/plan-picker.tsx +++ b/packages/billing/gateway/src/components/plan-picker.tsx @@ -236,6 +236,7 @@ export function PlanPicker( key={primaryLineItem.id} >
-