diff --git a/components/ee/payment-endpoint/src/accounting/account-service.spec.db.ts b/components/ee/payment-endpoint/src/accounting/account-service.spec.db.ts index 37bee7904aced9..b6424df6372afd 100644 --- a/components/ee/payment-endpoint/src/accounting/account-service.spec.db.ts +++ b/components/ee/payment-endpoint/src/accounting/account-service.spec.db.ts @@ -4,21 +4,21 @@ * See License.enterprise.txt in the project root folder. */ -import { testContainer } from '@gitpod/gitpod-db/lib/test-container'; -import { hoursLater, rightBefore, rightAfter, oneMonthLater } from '@gitpod/gitpod-protocol/lib/util/timeutil'; -import { DBWorkspace, DBWorkspaceInstance, WorkspaceDB, UserDB, DBUser, DBIdentity } from '@gitpod/gitpod-db/lib'; -import { DBAccountEntry } from '@gitpod/gitpod-db/lib/typeorm/entity/db-account-entry'; -import { TypeORM } from '@gitpod/gitpod-db/lib/typeorm/typeorm'; -import { AccountEntry, Subscription, AccountStatement } from '@gitpod/gitpod-protocol/lib/accounting-protocol'; -import { Plans, ABSOLUTE_MAX_USAGE, Plan } from '@gitpod/gitpod-protocol/lib/plans'; -import * as chai from 'chai'; -import { suite, test, timeout } from 'mocha-typescript'; -import { AccountService } from './account-service'; -import { AccountServiceImpl } from './account-service-impl'; -import { DBSubscription } from '@gitpod/gitpod-db/lib/typeorm/entity/db-subscription'; -import { AccountingDB } from '@gitpod/gitpod-db/lib/accounting-db'; -import { AccountingServer } from './accounting-server'; -import { SubscriptionService } from './subscription-service'; +import { testContainer } from "@gitpod/gitpod-db/lib/test-container"; +import { hoursLater, rightBefore, rightAfter, oneMonthLater } from "@gitpod/gitpod-protocol/lib/util/timeutil"; +import { DBWorkspace, DBWorkspaceInstance, WorkspaceDB, UserDB, DBUser, DBIdentity } from "@gitpod/gitpod-db/lib"; +import { DBAccountEntry } from "@gitpod/gitpod-db/lib/typeorm/entity/db-account-entry"; +import { TypeORM } from "@gitpod/gitpod-db/lib/typeorm/typeorm"; +import { AccountEntry, Subscription, AccountStatement } from "@gitpod/gitpod-protocol/lib/accounting-protocol"; +import { Plans, ABSOLUTE_MAX_USAGE, Plan } from "@gitpod/gitpod-protocol/lib/plans"; +import * as chai from "chai"; +import { suite, test, timeout } from "mocha-typescript"; +import { AccountService } from "./account-service"; +import { AccountServiceImpl } from "./account-service-impl"; +import { DBSubscription } from "@gitpod/gitpod-db/lib/typeorm/entity/db-subscription"; +import { AccountingDB } from "@gitpod/gitpod-db/lib/accounting-db"; +import { AccountingServer } from "./accounting-server"; +import { SubscriptionService } from "./subscription-service"; const expect = chai.expect; @@ -28,14 +28,15 @@ const secondMonth = new Date(Date.UTC(2000, 1, 1)).toISOString(); const end = new Date(Date.UTC(2000, 2, 1)).toISOString(); @timeout(10000) -@suite class AccountServiceSpec { +@suite +class AccountServiceSpec { typeORM = localTestContainer.get(TypeORM); accountService = localTestContainer.get(AccountService); accountingDb = localTestContainer.get(AccountingDB); workspaceDb = localTestContainer.get(WorkspaceDB); userDb = localTestContainer.get(UserDB); - subscription: Subscription + subscription: Subscription; @timeout(10000) async before() { @@ -43,57 +44,60 @@ const end = new Date(Date.UTC(2000, 2, 1)).toISOString(); await this.setupUserAndWs(); this.subscription = await this.accountingDb.newSubscription({ - userId: 'Sven', + userId: "Sven", startDate: start, amount: 100, - planId: 'test' + planId: "test", }); } protected async setupPurgeDB() { const manager = (await this.typeORM.getConnection()).manager; - await manager.clear(DBWorkspaceInstance) - await manager.clear(DBAccountEntry) - await manager.clear(DBSubscription) - await manager.clear(DBWorkspace) - manager.query('SET FOREIGN_KEY_CHECKS = 0;'); + await manager.clear(DBWorkspaceInstance); + await manager.clear(DBAccountEntry); + await manager.clear(DBSubscription); + await manager.clear(DBWorkspace); + manager.query("SET FOREIGN_KEY_CHECKS = 0;"); await manager.clear(DBIdentity); await manager.clear(DBUser); - manager.query('SET FOREIGN_KEY_CHECKS = 1;'); + manager.query("SET FOREIGN_KEY_CHECKS = 1;"); } protected async setupUserAndWs() { await this.userDb.storeUser({ - id: 'Sven', + id: "Sven", creationDate: start, - fullName: 'Sven', - identities: [{ - authProviderId: 'github.com', - authId: 'Sven', - authName: 'Sven', - tokens: [] - }], + fullName: "Sven", + identities: [ + { + authProviderId: "github.com", + authId: "Sven", + authName: "Sven", + tokens: [], + }, + ], additionalData: { emailNotificationSettings: { allowsChangelogMail: true, - allowsDevXMail: true + allowsDevXMail: true, }, - } + }, }); await this.workspaceDb.store({ - id: '1', - ownerId: 'Sven', - contextURL: '', + id: "1", + ownerId: "Sven", + contextURL: "", creationTime: start, - config: { ports: [], tasks: [], image: '' }, - context: { title: '' }, - description: 'test ws', - type: 'regular' + config: { ports: [], tasks: [], image: "" }, + context: { title: "" }, + description: "test ws", + type: "regular", }); } @timeout(4000) - @test async testIssue4045Minimal() { + @test + async testIssue4045Minimal() { // We want a clean state await this.setupPurgeDB(); await this.setupUserAndWs(); @@ -103,12 +107,12 @@ const end = new Date(Date.UTC(2000, 2, 1)).toISOString(); // they would run out of hours, regardless of having an unlimited plan. const subscription = async (plan: Plan, startDate: string, endDate?: string) => { const s = await this.accountingDb.newSubscription({ - userId: 'Sven', + userId: "Sven", startDate, amount: Plans.getHoursPerMonth(plan), - paymentReference: 'plan', + paymentReference: "plan", endDate, - planId: plan.chargebeeId + planId: plan.chargebeeId, }); await this.accountingDb.storeSubscription(s); }; @@ -134,18 +138,18 @@ const end = new Date(Date.UTC(2000, 2, 1)).toISOString(); } @test - async testRemainingHoursUnlimited() { + async testRemainingHoursUnlimited() { // We want a clean state await this.setupPurgeDB(); await this.setupUserAndWs(); const basic = Plans.BASIC_EUR; this.subscription = await this.accountingDb.newSubscription({ - userId: 'Sven', + userId: "Sven", startDate: start, amount: Plans.getHoursPerMonth(basic), - paymentReference: 'mine-basic', - planId: basic.chargebeeId + paymentReference: "mine-basic", + planId: basic.chargebeeId, }); await this.accountingDb.storeSubscription(this.subscription); @@ -155,228 +159,247 @@ const end = new Date(Date.UTC(2000, 2, 1)).toISOString(); const p2 = Plans.PROFESSIONAL_EUR; const proSusbcription = await this.accountingDb.newSubscription({ - userId: 'Sven', + userId: "Sven", startDate: subscriptionSwitchDate, amount: Plans.getHoursPerMonth(p2), - paymentReference: 'mine-pro', - planId: p2.chargebeeId + paymentReference: "mine-pro", + planId: p2.chargebeeId, }); await this.accountingDb.storeSubscription(proSusbcription); const statementDate = hoursLater(start, 2); - const statement = await this.accountService.getAccountStatement('Sven', statementDate); + const statement = await this.accountService.getAccountStatement("Sven", statementDate); expect(statement!.remainingHours).to.be.equal("unlimited"); const remainingUsageHours = this.accountService.getRemainingUsageHours(statement, 1); expect(remainingUsageHours).to.be.equal(ABSOLUTE_MAX_USAGE); } @test async testRemainingHours() { - const subscriptionSwitchDate = hoursLater(start, 10 * 24); // 10 days + const subscriptionSwitchDate = hoursLater(start, 10 * 24); // 10 days Subscription.cancelSubscription(this.subscription, subscriptionSwitchDate); await this.accountingDb.storeSubscription(this.subscription); const insertCancelledSubscription = async (startDate: string) => { - await this.accountingDb.storeSubscription(await this.accountingDb.newSubscription({ - userId: 'Sven', - startDate: startDate, - cancellationDate: hoursLater(startDate, 1), - endDate: oneMonthLater(startDate), - amount: 200, - planId: 'test' - })); + await this.accountingDb.storeSubscription( + await this.accountingDb.newSubscription({ + userId: "Sven", + startDate: startDate, + cancellationDate: hoursLater(startDate, 1), + endDate: oneMonthLater(startDate), + amount: 200, + planId: "test", + }), + ); }; await insertCancelledSubscription(hoursLater(subscriptionSwitchDate, 1)); await insertCancelledSubscription(hoursLater(subscriptionSwitchDate, 2)); await insertCancelledSubscription(hoursLater(subscriptionSwitchDate, 3)); await insertCancelledSubscription(hoursLater(subscriptionSwitchDate, 4)); - const statement = await this.accountService.getAccountStatement('Sven', hoursLater(subscriptionSwitchDate, 5)); + const statement = await this.accountService.getAccountStatement("Sven", hoursLater(subscriptionSwitchDate, 5)); expect(statement!.remainingHours).to.be.equal(800); } @test async noSessions() { - expect(await this.invoice(start)).to.be.equal('') - expect(await this.invoice(rightAfter(start))).to.be.equal('2000-01-01T00:00:00.000Z 100 credit 100') - expect(await this.invoice(rightBefore(secondMonth))).to.be.equal('2000-01-01T00:00:00.000Z 100 credit 100'); + expect(await this.invoice(start)).to.be.equal(""); + expect(await this.invoice(rightAfter(start))).to.be.equal("2000-01-01T00:00:00.000Z 100 credit 100"); + expect(await this.invoice(rightBefore(secondMonth))).to.be.equal("2000-01-01T00:00:00.000Z 100 credit 100"); expect(await this.invoice(secondMonth)).to.be.equal( -`2000-01-01T00:00:00.000Z 100 credit 0 -2000-01-31T23:59:59.999Z -100 expiry`) + `2000-01-01T00:00:00.000Z 100 credit 0 +2000-01-31T23:59:59.999Z -100 expiry`, + ); expect(await this.invoice(rightAfter(secondMonth))).to.be.equal( -`2000-01-01T00:00:00.000Z 100 credit 0 + `2000-01-01T00:00:00.000Z 100 credit 0 2000-01-31T23:59:59.999Z -100 expiry -2000-02-01T00:00:00.000Z 100 credit 100`) +2000-02-01T00:00:00.000Z 100 credit 100`, + ); } @test async singleSession() { await this.createSession(start, 30); expect(await this.invoice(hoursLater(start, 1))).to.be.equal( -`2000-01-01T00:00:00.000Z 100 credit 99 -2000-01-01T00:59:59.999Z -1 session`); + `2000-01-01T00:00:00.000Z 100 credit 99 +2000-01-01T00:59:59.999Z -1 session`, + ); expect(await this.invoice(hoursLater(start, 31))).to.be.equal( -`2000-01-01T00:00:00.000Z 100 credit 70 -2000-01-02T06:00:00.000Z -30 session`) + `2000-01-01T00:00:00.000Z 100 credit 70 +2000-01-02T06:00:00.000Z -30 session`, + ); expect(await this.invoice(rightBefore(secondMonth))).to.be.equal( -`2000-01-01T00:00:00.000Z 100 credit 70 -2000-01-02T06:00:00.000Z -30 session`); + `2000-01-01T00:00:00.000Z 100 credit 70 +2000-01-02T06:00:00.000Z -30 session`, + ); expect(await this.invoice(secondMonth)).to.be.equal( -`2000-01-01T00:00:00.000Z 100 credit 0 + `2000-01-01T00:00:00.000Z 100 credit 0 2000-01-02T06:00:00.000Z -30 session -2000-01-31T23:59:59.999Z -70 expiry`) +2000-01-31T23:59:59.999Z -70 expiry`, + ); expect(await this.invoice(rightAfter(secondMonth))).to.be.equal( -`2000-01-01T00:00:00.000Z 100 credit 0 + `2000-01-01T00:00:00.000Z 100 credit 0 2000-01-02T06:00:00.000Z -30 session 2000-01-31T23:59:59.999Z -70 expiry -2000-02-01T00:00:00.000Z 100 credit 100`) +2000-02-01T00:00:00.000Z 100 credit 100`, + ); } @test async twoOverlappingSessions() { await this.createSession(start, 30); await this.createSession(secondDay, 20); expect(await this.invoice(hoursLater(secondDay, 1))).to.be.equal( -`2000-01-01T00:00:00.000Z 100 credit 74 + `2000-01-01T00:00:00.000Z 100 credit 74 2000-01-02T00:59:59.999Z -25 session -2000-01-02T00:59:59.999Z -1 session`) +2000-01-02T00:59:59.999Z -1 session`, + ); expect(await this.invoice(hoursLater(secondDay, 21))).to.be.equal( -`2000-01-01T00:00:00.000Z 100 credit 50 + `2000-01-01T00:00:00.000Z 100 credit 50 2000-01-02T06:00:00.000Z -30 session -2000-01-02T20:00:00.000Z -20 session`) +2000-01-02T20:00:00.000Z -20 session`, + ); } @test async rightBeforeEndOfMonth() { await this.createSession(rightBefore(secondMonth), -10); expect(await this.invoice(secondMonth)).to.be.equal( -`2000-01-01T00:00:00.000Z 100 credit 0 + `2000-01-01T00:00:00.000Z 100 credit 0 2000-01-31T23:59:59.999Z -10 session -2000-01-31T23:59:59.999Z -90 expiry`); +2000-01-31T23:59:59.999Z -90 expiry`, + ); expect(await this.invoice(end)).to.be.equal( -`2000-01-01T00:00:00.000Z 100 credit 0 + `2000-01-01T00:00:00.000Z 100 credit 0 2000-01-31T23:59:59.999Z -10 session 2000-01-31T23:59:59.999Z -90 expiry 2000-02-01T00:00:00.000Z 100 credit 0 -2000-02-29T23:59:59.999Z -100 expiry`); +2000-02-29T23:59:59.999Z -100 expiry`, + ); } @test async overbooking() { await this.createSession(start, 120); expect(await this.invoice(end)).to.be.equal( -`2000-01-01T00:00:00.000Z 100 credit 0 + `2000-01-01T00:00:00.000Z 100 credit 0 2000-01-06T00:00:00.000Z -100 session 2000-01-06T00:00:00.000Z 20 loss 2000-02-01T00:00:00.000Z 100 credit 0 -2000-02-29T23:59:59.999Z -100 expiry`); +2000-02-29T23:59:59.999Z -100 expiry`, + ); } @test async multiPeriodSession() { await this.createSession(hoursLater(secondMonth, -15), 20); expect(await this.invoice(rightBefore(end))).to.be.equal( -`2000-01-01T00:00:00.000Z 100 credit 0 + `2000-01-01T00:00:00.000Z 100 credit 0 2000-01-31T23:59:59.999Z -15 session 2000-01-31T23:59:59.999Z -85 expiry 2000-02-01T00:00:00.000Z 100 credit 95 -2000-02-01T05:00:00.000Z -5 session`); +2000-02-01T05:00:00.000Z -5 session`, + ); } @test async multiCredit() { await this.accountingDb.newAccountEntry({ - userId: 'Sven', + userId: "Sven", amount: 10, date: hoursLater(start, 48), - kind: 'credit', - expiryDate: hoursLater(secondMonth, 48) - }) + kind: "credit", + expiryDate: hoursLater(secondMonth, 48), + }); await this.accountingDb.newAccountEntry({ - userId: 'Sven', + userId: "Sven", amount: 20, date: hoursLater(start, 24), - kind: 'credit', - expiryDate: hoursLater(start, 72) - }) + kind: "credit", + expiryDate: hoursLater(start, 72), + }); expect(await this.invoice(end)).to.be.equal( -`2000-01-01T00:00:00.000Z 100 credit 0 + `2000-01-01T00:00:00.000Z 100 credit 0 2000-01-02T00:00:00.000Z 20 open 0 2000-01-03T00:00:00.000Z 10 open 0 2000-01-03T23:59:59.999Z -20 expiry 2000-01-31T23:59:59.999Z -100 expiry 2000-02-01T00:00:00.000Z 100 credit 0 2000-02-02T23:59:59.999Z -10 expiry -2000-02-29T23:59:59.999Z -100 expiry`); - } - -@test async multiCreditSessionBefore() { - await this.accountingDb.newAccountEntry({ - userId: 'Sven', - amount: 20, - date: hoursLater(start, 24), - kind: 'credit', - expiryDate: hoursLater(secondMonth, 24) - }) - await this.createSession(start, 10); - expect(await this.invoice(end)).to.be.equal( -`2000-01-01T00:00:00.000Z 100 credit 0 +2000-02-29T23:59:59.999Z -100 expiry`, + ); + } + + @test async multiCreditSessionBefore() { + await this.accountingDb.newAccountEntry({ + userId: "Sven", + amount: 20, + date: hoursLater(start, 24), + kind: "credit", + expiryDate: hoursLater(secondMonth, 24), + }); + await this.createSession(start, 10); + expect(await this.invoice(end)).to.be.equal( + `2000-01-01T00:00:00.000Z 100 credit 0 2000-01-01T10:00:00.000Z -10 session 2000-01-02T00:00:00.000Z 20 open 0 2000-01-31T23:59:59.999Z -90 expiry 2000-02-01T00:00:00.000Z 100 credit 0 2000-02-01T23:59:59.999Z -20 expiry -2000-02-29T23:59:59.999Z -100 expiry`); +2000-02-29T23:59:59.999Z -100 expiry`, + ); } @test async multiCreditSessionAfter() { await this.accountingDb.newAccountEntry({ - userId: 'Sven', + userId: "Sven", amount: 20, date: hoursLater(start, 24), - kind: 'credit', - expiryDate: hoursLater(start, 48) - }) + kind: "credit", + expiryDate: hoursLater(start, 48), + }); await this.createSession(hoursLater(start, 48), 10); expect(await this.invoice(end)).to.be.equal( -`2000-01-01T00:00:00.000Z 100 credit 0 + `2000-01-01T00:00:00.000Z 100 credit 0 2000-01-02T00:00:00.000Z 20 open 0 2000-01-02T23:59:59.999Z -20 expiry 2000-01-03T10:00:00.000Z -10 session 2000-01-31T23:59:59.999Z -90 expiry 2000-02-01T00:00:00.000Z 100 credit 0 -2000-02-29T23:59:59.999Z -100 expiry`); - } +2000-02-29T23:59:59.999Z -100 expiry`, + ); + } @test async multiCreditSessionOverlap() { await this.accountingDb.newAccountEntry({ - userId: 'Sven', + userId: "Sven", amount: 20, date: hoursLater(start, 24), - kind: 'credit', - expiryDate: hoursLater(secondMonth, 24) - }) + kind: "credit", + expiryDate: hoursLater(secondMonth, 24), + }); await this.createSession(start, 48); expect(await this.invoice(end)).to.be.equal( -`2000-01-01T00:00:00.000Z 100 credit 0 + `2000-01-01T00:00:00.000Z 100 credit 0 2000-01-02T00:00:00.000Z 20 open 0 2000-01-03T00:00:00.000Z -48 session 2000-01-31T23:59:59.999Z -52 expiry 2000-02-01T00:00:00.000Z 100 credit 0 2000-02-01T23:59:59.999Z -20 expiry -2000-02-29T23:59:59.999Z -100 expiry`); +2000-02-29T23:59:59.999Z -100 expiry`, + ); } @test async multiCreditSessionOverlap_1() { await this.accountingDb.newAccountEntry({ - userId: 'Sven', + userId: "Sven", amount: 20, date: hoursLater(start, 24), - kind: 'credit', - expiryDate: hoursLater(start, 72) - }) + kind: "credit", + expiryDate: hoursLater(start, 72), + }); await this.accountingDb.newAccountEntry({ - userId: 'Sven', + userId: "Sven", amount: 10, date: hoursLater(start, 48), - kind: 'credit', - expiryDate: hoursLater(secondMonth, 48) - }) + kind: "credit", + expiryDate: hoursLater(secondMonth, 48), + }); await this.createSession(hoursLater(start, 12), 40); expect(await this.invoice(end)).to.be.equal( -`2000-01-01T00:00:00.000Z 100 credit 0 + `2000-01-01T00:00:00.000Z 100 credit 0 2000-01-01T23:59:59.999Z -12 session 2000-01-02T00:00:00.000Z 20 open 0 2000-01-03T00:00:00.000Z 10 open 0 @@ -385,98 +408,127 @@ const end = new Date(Date.UTC(2000, 2, 1)).toISOString(); 2000-01-31T23:59:59.999Z -80 expiry 2000-02-01T00:00:00.000Z 100 credit 0 2000-02-02T23:59:59.999Z -10 expiry -2000-02-29T23:59:59.999Z -100 expiry`); +2000-02-29T23:59:59.999Z -100 expiry`, + ); } @test async multiSubscription() { const subscriptionSwitchDate = hoursLater(start, 10 * 24); // 10 days - Subscription.cancelSubscription(this.subscription, subscriptionSwitchDate, oneMonthLater(this.subscription.startDate)); + Subscription.cancelSubscription( + this.subscription, + subscriptionSwitchDate, + oneMonthLater(this.subscription.startDate), + ); await this.accountingDb.storeSubscription(this.subscription); - await this.accountingDb.storeSubscription(await this.accountingDb.newSubscription({ - userId: 'Sven', - startDate: subscriptionSwitchDate, - amount: 200, - planId: 'test' - })); + await this.accountingDb.storeSubscription( + await this.accountingDb.newSubscription({ + userId: "Sven", + startDate: subscriptionSwitchDate, + amount: 200, + planId: "test", + }), + ); expect(await this.invoice(end)).to.be.equal( -`2000-01-01T00:00:00.000Z 100 credit 0 + `2000-01-01T00:00:00.000Z 100 credit 0 2000-01-11T00:00:00.000Z 200 credit 0 2000-01-31T23:59:59.999Z -100 expiry 2000-02-10T23:59:59.999Z -200 expiry -2000-02-11T00:00:00.000Z 200 credit 200`); +2000-02-11T00:00:00.000Z 200 credit 200`, + ); } @test async multiSubscriptionOverlappingSession() { const subscriptionSwitchDate = hoursLater(start, 240); - Subscription.cancelSubscription(this.subscription, subscriptionSwitchDate, oneMonthLater(this.subscription.startDate)); - await this.accountingDb.storeSubscription(this.subscription) - await this.accountingDb.storeSubscription(await this.accountingDb.newSubscription({ - userId: 'Sven', - startDate: subscriptionSwitchDate, - amount: 200, - planId: 'test' - })); - await this.createSession(hoursLater(start, 230), 20) + Subscription.cancelSubscription( + this.subscription, + subscriptionSwitchDate, + oneMonthLater(this.subscription.startDate), + ); + await this.accountingDb.storeSubscription(this.subscription); + await this.accountingDb.storeSubscription( + await this.accountingDb.newSubscription({ + userId: "Sven", + startDate: subscriptionSwitchDate, + amount: 200, + planId: "test", + }), + ); + await this.createSession(hoursLater(start, 230), 20); expect(await this.invoice(end)).to.be.equal( -`2000-01-01T00:00:00.000Z 100 credit 0 + `2000-01-01T00:00:00.000Z 100 credit 0 2000-01-11T00:00:00.000Z 200 credit 0 2000-01-11T10:00:00.000Z -20 session 2000-01-31T23:59:59.999Z -80 expiry 2000-02-10T23:59:59.999Z -200 expiry -2000-02-11T00:00:00.000Z 200 credit 200`); +2000-02-11T00:00:00.000Z 200 credit 200`, + ); } @test async multiSubscriptionOverlappingSessions() { const subscriptionSwitchDate = hoursLater(start, 240); - Subscription.cancelSubscription(this.subscription, subscriptionSwitchDate, oneMonthLater(this.subscription.startDate)); - await this.accountingDb.storeSubscription(this.subscription) - await this.accountingDb.storeSubscription(await this.accountingDb.newSubscription({ - userId: 'Sven', - startDate: subscriptionSwitchDate, - amount: 200, - planId: 'test' - })); - await this.createSession(hoursLater(start, 230), 20) - await this.createSession(hoursLater(start, 220), 30) + Subscription.cancelSubscription( + this.subscription, + subscriptionSwitchDate, + oneMonthLater(this.subscription.startDate), + ); + await this.accountingDb.storeSubscription(this.subscription); + await this.accountingDb.storeSubscription( + await this.accountingDb.newSubscription({ + userId: "Sven", + startDate: subscriptionSwitchDate, + amount: 200, + planId: "test", + }), + ); + await this.createSession(hoursLater(start, 230), 20); + await this.createSession(hoursLater(start, 220), 30); expect(await this.invoice(end)).to.be.equal( -`2000-01-01T00:00:00.000Z 100 credit 0 + `2000-01-01T00:00:00.000Z 100 credit 0 2000-01-11T00:00:00.000Z 200 credit 0 2000-01-11T10:00:00.000Z -30 session 2000-01-11T10:00:00.000Z -20 session 2000-01-31T23:59:59.999Z -50 expiry 2000-02-10T23:59:59.999Z -200 expiry -2000-02-11T00:00:00.000Z 200 credit 200`); +2000-02-11T00:00:00.000Z 200 credit 200`, + ); } @test async multiSubscriptionOverlappingSession_2() { const subscriptionSwitchDate1 = hoursLater(start, 240); const subscriptionSwitchDate2 = hoursLater(start, 245); - Subscription.cancelSubscription(this.subscription, subscriptionSwitchDate1, oneMonthLater(this.subscription.startDate)); - await this.accountingDb.storeSubscription(this.subscription) + Subscription.cancelSubscription( + this.subscription, + subscriptionSwitchDate1, + oneMonthLater(this.subscription.startDate), + ); + await this.accountingDb.storeSubscription(this.subscription); const subscription2 = await this.accountingDb.newSubscription({ - userId: 'Sven', + userId: "Sven", startDate: subscriptionSwitchDate1, amount: 200, - planId: 'test' + planId: "test", }); Subscription.cancelSubscription(subscription2, subscriptionSwitchDate2, oneMonthLater(subscription2.startDate)); await this.accountingDb.storeSubscription(subscription2); - await this.accountingDb.storeSubscription(await this.accountingDb.newSubscription({ - userId: 'Sven', - startDate: subscriptionSwitchDate2, - amount: 300, - planId: 'test' - })); - await this.createSession(hoursLater(start, 230), 20) + await this.accountingDb.storeSubscription( + await this.accountingDb.newSubscription({ + userId: "Sven", + startDate: subscriptionSwitchDate2, + amount: 300, + planId: "test", + }), + ); + await this.createSession(hoursLater(start, 230), 20); expect(await this.invoice(end)).to.be.equal( -`2000-01-01T00:00:00.000Z 100 credit 0 + `2000-01-01T00:00:00.000Z 100 credit 0 2000-01-11T00:00:00.000Z 200 credit 0 2000-01-11T05:00:00.000Z 300 credit 0 2000-01-11T10:00:00.000Z -20 session 2000-01-31T23:59:59.999Z -80 expiry 2000-02-10T23:59:59.999Z -200 expiry 2000-02-11T04:59:59.999Z -300 expiry -2000-02-11T05:00:00.000Z 300 credit 300`); +2000-02-11T05:00:00.000Z 300 credit 300`, + ); } @test async remainingHoursNoSession() { @@ -502,13 +554,15 @@ const end = new Date(Date.UTC(2000, 2, 1)).toISOString(); @test async remainingHoursTwoSubscriptions() { Subscription.cancelSubscription(this.subscription, secondMonth); - await this.accountingDb.storeSubscription(this.subscription) - await this.accountingDb.storeSubscription(await this.accountingDb.newSubscription({ - userId: 'Sven', - startDate: secondMonth, - amount: 200, - planId: 'test' - })); + await this.accountingDb.storeSubscription(this.subscription); + await this.accountingDb.storeSubscription( + await this.accountingDb.newSubscription({ + userId: "Sven", + startDate: secondMonth, + amount: 200, + planId: "test", + }), + ); expect(await this.remainingHours(rightAfter(start))).to.be.equal(100); expect(await this.remainingHours(rightAfter(secondMonth))).to.be.equal(200); } @@ -518,10 +572,10 @@ const end = new Date(Date.UTC(2000, 2, 1)).toISOString(); await this.createSession(hoursLater(start, 12), 110); // add a credit afterwards await this.accountingDb.newAccountEntry({ - userId: 'Sven', + userId: "Sven", amount: 10, date: hoursLater(start, 200), - kind: 'credit' + kind: "credit", }); // before the credit the balance should be 0 (i.e. +10 free) expect(await this.remainingHours(hoursLater(start, 199))).to.be.equal(0); @@ -532,18 +586,18 @@ const end = new Date(Date.UTC(2000, 2, 1)).toISOString(); @test async creditsAreBookedAgainstSessionsByAge() { const subscriptionWith40Hours = { ...this.subscription, - amount: 40 + amount: 40, }; await this.accountingDb.storeSubscription(await this.accountingDb.newSubscription(subscriptionWith40Hours)); - await this.createSession(hoursLater(start, 30), 30) - await this.createSession(hoursLater(start, 60), 60) - await this.createSession(hoursLater(start, 20), 20) - await this.createSession(hoursLater(start, 50), 50) - await this.createSession(hoursLater(start, 10), 10) - await this.createSession(hoursLater(start, 40), 40) + await this.createSession(hoursLater(start, 30), 30); + await this.createSession(hoursLater(start, 60), 60); + await this.createSession(hoursLater(start, 20), 20); + await this.createSession(hoursLater(start, 50), 50); + await this.createSession(hoursLater(start, 10), 10); + await this.createSession(hoursLater(start, 40), 40); const expectation = await this.invoice(end); expect(expectation).to.be.equal( -`2000-01-01T00:00:00.000Z 40 credit 0 + `2000-01-01T00:00:00.000Z 40 credit 0 2000-01-01T20:00:00.000Z -10 session 2000-01-02T16:00:00.000Z -20 session 2000-01-03T12:00:00.000Z -10 session @@ -552,34 +606,43 @@ const end = new Date(Date.UTC(2000, 2, 1)).toISOString(); 2000-01-05T04:00:00.000Z 50 loss 2000-01-06T00:00:00.000Z 60 loss 2000-02-01T00:00:00.000Z 40 credit 0 -2000-02-29T23:59:59.999Z -40 expiry`); +2000-02-29T23:59:59.999Z -40 expiry`, + ); } // Test for https://github.com/TypeFox/gitpod/pull/3797#issuecomment-588170598 @test async testPaidPlanWhileProOpenSource() { - Subscription.cancelSubscription(this.subscription, hoursLater(start, 1), oneMonthLater(this.subscription.startDate)); - await this.accountingDb.storeSubscription(this.subscription) - await this.accountingDb.storeSubscription(await this.accountingDb.newSubscription({ - userId: 'Sven', - planId: 'free-open-source', - amount: 11904, - startDate: hoursLater(start, 2), - endDate: hoursLater(start, 2 + 365.25 * 24), // one year later - })); - await this.accountingDb.storeSubscription(await this.accountingDb.newSubscription({ - userId: 'Sven', - planId: 'professional-new-eur', - amount: 11904, - startDate: hoursLater(start, 3), - cancellationDate: hoursLater(start, 4), // one hour later - endDate: hoursLater(start, 4), - })); - let statement = await this.accountService.getAccountStatement('Sven', hoursLater(start, 5)); - const redactedCredits = statement!.credits.map(c => { - (c.description as any).subscriptionId = '[...]'; - c.uid = '[...]'; + Subscription.cancelSubscription( + this.subscription, + hoursLater(start, 1), + oneMonthLater(this.subscription.startDate), + ); + await this.accountingDb.storeSubscription(this.subscription); + await this.accountingDb.storeSubscription( + await this.accountingDb.newSubscription({ + userId: "Sven", + planId: "free-open-source", + amount: 11904, + startDate: hoursLater(start, 2), + endDate: hoursLater(start, 2 + 365.25 * 24), // one year later + }), + ); + await this.accountingDb.storeSubscription( + await this.accountingDb.newSubscription({ + userId: "Sven", + planId: "professional-new-eur", + amount: 11904, + startDate: hoursLater(start, 3), + cancellationDate: hoursLater(start, 4), // one hour later + endDate: hoursLater(start, 4), + }), + ); + let statement = await this.accountService.getAccountStatement("Sven", hoursLater(start, 5)); + const redactedCredits = statement!.credits.map((c) => { + (c.description as any).subscriptionId = "[...]"; + c.uid = "[...]"; return c; - }) + }); expect(JSON.stringify(redactedCredits, null, 4)).to.be.equal(`[ { "userId": "Sven", @@ -621,12 +684,12 @@ const end = new Date(Date.UTC(2000, 2, 1)).toISOString(); "uid": "[...]" } ]`); - const redactedDebits = statement!.debits.map(d => { - (d.description as any).subscriptionId = '[...]'; - d.creditId = '[...]'; - d.uid = '[...]'; + const redactedDebits = statement!.debits.map((d) => { + (d.description as any).subscriptionId = "[...]"; + d.creditId = "[...]"; + d.uid = "[...]"; return d; - }) + }); expect(JSON.stringify(redactedDebits, null, 4)).to.be.equal(`[ { "userId": "Sven", @@ -646,53 +709,53 @@ const end = new Date(Date.UTC(2000, 2, 1)).toISOString(); @test async testInvalidSessionDates() { await this.workspaceDb.storeInstance({ creationTime: start, - startedTime: '', - stoppedTime: '', - id: '' + (++this.id), - workspaceId: '1', - ideUrl: '', - region: '', - workspaceImage: '', - status: { phase: 'running', conditions: {} } + startedTime: "", + stoppedTime: "", + id: "" + ++this.id, + workspaceId: "1", + ideUrl: "", + region: "", + workspaceImage: "", + status: { version: 1, phase: "running", conditions: {} }, }); - let statement = await this.accountService.getAccountStatement('Sven', hoursLater(start, 1)); + let statement = await this.accountService.getAccountStatement("Sven", hoursLater(start, 1)); expect(statement!.remainingHours).to.be.equal(100); await this.workspaceDb.storeInstance({ creationTime: hoursLater(start, 10), startedTime: undefined, stoppedTime: undefined, - id: '' + (++this.id), - workspaceId: '1', - ideUrl: '', - region: '', - workspaceImage: '', - status: { phase: 'running', conditions: {} } + id: "" + ++this.id, + workspaceId: "1", + ideUrl: "", + region: "", + workspaceImage: "", + status: { version: 1, phase: "running", conditions: {} }, }); - statement = await this.accountService.getAccountStatement('Sven', hoursLater(start, 12)); + statement = await this.accountService.getAccountStatement("Sven", hoursLater(start, 12)); expect(statement!.remainingHours).to.be.equal(100); } - id = 0 + id = 0; private async createSession(reference: string, hours: number) { - const start = (hours < 0) ? hoursLater(reference, hours) : reference - const stop = (hours < 0) ? reference : hoursLater(reference, hours) + const start = hours < 0 ? hoursLater(reference, hours) : reference; + const stop = hours < 0 ? reference : hoursLater(reference, hours); await this.workspaceDb.storeInstance({ creationTime: start, startedTime: start, stoppedTime: stop, - id: '' + (++this.id), - workspaceId: '1', - ideUrl: '', - region: '', - workspaceImage: '', - status: { phase: 'running', conditions: {} } + id: "" + ++this.id, + workspaceId: "1", + ideUrl: "", + region: "", + workspaceImage: "", + status: { version: 1, phase: "running", conditions: {} }, }); } private async invoice(date: string): Promise { - const statement = await this.accountService.getAccountStatement('Sven', date); + const statement = await this.accountService.getAccountStatement("Sven", date); const result = this.stringifyStatement(statement); console.log(result); return result; @@ -701,38 +764,39 @@ const end = new Date(Date.UTC(2000, 2, 1)).toISOString(); private stringifyStatement(statement: AccountStatement): string { const result = [...statement.credits, ...statement.debits] .sort((e0, e1) => { - const timeDiff = e0.date.localeCompare(e1.date) - return timeDiff === 0 - ? this.rank(e0) - this.rank(e1) - : timeDiff + const timeDiff = e0.date.localeCompare(e1.date); + return timeDiff === 0 ? this.rank(e0) - this.rank(e1) : timeDiff; }) - .map(e => `${e.date} ${e.amount} ${e.kind}${e.remainingAmount !== undefined ? ' ' + e.remainingAmount : ''}`) - .join('\n'); + .map( + (e) => + `${e.date} ${e.amount} ${e.kind}${e.remainingAmount !== undefined ? " " + e.remainingAmount : ""}`, + ) + .join("\n"); return result; } private rank(t: AccountEntry): number { switch (t.kind) { - case 'carry': + case "carry": return 1; - case 'credit': + case "credit": return 2; - case 'session': + case "session": return 3; - case 'loss': + case "loss": return 4; - case 'expiry': + case "expiry": return 5; - case 'open': + case "open": return 6; } } private async remainingHours(date: string, numInstances = 1, includeNext = false) { - const statement = await this.accountService.getAccountStatement('Sven', date); + const statement = await this.accountService.getAccountStatement("Sven", date); const statementString = this.stringifyStatement(statement); console.log(statementString); - const result = this.accountService.getRemainingUsageHours(statement, numInstances, includeNext) + const result = this.accountService.getRemainingUsageHours(statement, numInstances, includeNext); console.log(result); return result; } @@ -744,4 +808,4 @@ localTestContainer.bind(AccountServiceImpl).toSelf().inSingletonScope(); localTestContainer.bind(AccountService).toService(AccountServiceImpl); localTestContainer.bind(AccountingServer).toSelf().inSingletonScope(); localTestContainer.bind(SubscriptionService).toSelf().inSingletonScope(); -module.exports = new AccountServiceSpec() +module.exports = new AccountServiceSpec(); diff --git a/components/gitpod-db/src/user-db.spec.db.ts b/components/gitpod-db/src/user-db.spec.db.ts index 9f16d935c42f91..68075af33b0e2b 100644 --- a/components/gitpod-db/src/user-db.spec.db.ts +++ b/components/gitpod-db/src/user-db.spec.db.ts @@ -218,6 +218,7 @@ namespace TestData { deployedTime: undefined, stoppedTime: undefined, status: { + version: 1, phase: "preparing", conditions: {}, }, diff --git a/components/gitpod-db/src/workspace-db.spec.db.ts b/components/gitpod-db/src/workspace-db.spec.db.ts index 7f655f49035dae..9d4b38b9b1319d 100644 --- a/components/gitpod-db/src/workspace-db.spec.db.ts +++ b/components/gitpod-db/src/workspace-db.spec.db.ts @@ -58,6 +58,7 @@ class WorkspaceDBSpec { stoppingTime: undefined, stoppedTime: undefined, status: { + version: 1, phase: "preparing", conditions: {}, }, @@ -81,6 +82,7 @@ class WorkspaceDBSpec { stoppingTime: undefined, stoppedTime: undefined, status: { + version: 1, phase: "running", conditions: {}, }, @@ -119,6 +121,7 @@ class WorkspaceDBSpec { stoppingTime: undefined, stoppedTime: undefined, status: { + version: 1, phase: "preparing", conditions: {}, }, @@ -157,6 +160,7 @@ class WorkspaceDBSpec { stoppingTime: undefined, stoppedTime: undefined, status: { + version: 1, phase: "preparing", conditions: {}, }, diff --git a/components/gitpod-protocol/src/workspace-instance.ts b/components/gitpod-protocol/src/workspace-instance.ts index c5370b521a3b06..d45f0081790f3f 100644 --- a/components/gitpod-protocol/src/workspace-instance.ts +++ b/components/gitpod-protocol/src/workspace-instance.ts @@ -72,6 +72,11 @@ export interface WorkspaceInstance { // WorkspaceInstanceStatus describes the current state of a workspace instance export interface WorkspaceInstanceStatus { + // version is the current version of the workspace instance status + // Note: consider this value opague. The only guarantee given is that it imposes + // a partial order on status updates, i.e. a.version > b.version -> a newer than b. + version?: number; + // phase describes a high-level state of the workspace instance phase: WorkspaceInstancePhase; diff --git a/components/server/src/auth/resource-access.spec.ts b/components/server/src/auth/resource-access.spec.ts index 11349afd5c093a..aa35915a001ae5 100644 --- a/components/server/src/auth/resource-access.spec.ts +++ b/components/server/src/auth/resource-access.spec.ts @@ -585,6 +585,7 @@ class TestResourceAccess { creationTime: new Date(2000, 1, 2).toISOString(), region: "local", status: { + version: 1, conditions: {}, phase: "running", }, diff --git a/components/server/src/workspace/workspace-starter.ts b/components/server/src/workspace/workspace-starter.ts index 0a3a0a1993efc6..4fc01085011e1d 100644 --- a/components/server/src/workspace/workspace-starter.ts +++ b/components/server/src/workspace/workspace-starter.ts @@ -780,6 +780,7 @@ export class WorkspaceStarter { region: this.config.installationShortname, // Shortname set to bridge can cleanup workspaces stuck preparing workspaceImage: "", // Initially empty, filled during starting process status: { + version: 0, conditions: {}, phase: "preparing", }, diff --git a/components/ws-manager-bridge/src/bridge.ts b/components/ws-manager-bridge/src/bridge.ts index 3c6c71ea576cef..db992f254d94d8 100644 --- a/components/ws-manager-bridge/src/bridge.ts +++ b/components/ws-manager-bridge/src/bridge.ts @@ -227,6 +227,7 @@ export class WorkspaceManagerBridge implements Disposable { const span = TraceContext.startSpan("handleStatusUpdate", ctx); span.setTag("status", JSON.stringify(filterStatus(status))); span.setTag("writeToDB", writeToDB); + span.setTag("statusVersion", status.statusVersion); try { // Beware of the ID mapping here: What's a workspace to the ws-manager is a workspace instance to the rest of the system. // The workspace ID of ws-manager is the workspace instance ID in the database. @@ -252,6 +253,14 @@ export class WorkspaceManagerBridge implements Disposable { return; } + const currentStatusVersion = instance.status.version || 0; + if (currentStatusVersion > 0 && currentStatusVersion >= status.statusVersion) { + // We've gotten an event which is older than one we've already processed. We shouldn't process the stale one. + span.setTag("statusUpdate.staleEvent", true); + this.prometheusExporter.recordStaleStatusUpdate(); + log.debug(ctx, "Stale status update received, skipping."); + } + if (!!status.spec.exposedPortsList) { instance.status.exposedPorts = status.spec.exposedPortsList.map((p) => { return { @@ -269,6 +278,7 @@ export class WorkspaceManagerBridge implements Disposable { } instance.ideUrl = status.spec.url!; + instance.status.version = status.statusVersion; instance.status.timeout = status.spec.timeout; if (!!instance.status.conditions.failed && !status.conditions.failed) { // We already have a "failed" condition, and received an empty one: This is a bug, "failed" conditions are terminal per definition. diff --git a/components/ws-manager-bridge/src/prometheus-metrics-exporter.ts b/components/ws-manager-bridge/src/prometheus-metrics-exporter.ts index 9f1a9d71c439da..9ac85d63c4629c 100644 --- a/components/ws-manager-bridge/src/prometheus-metrics-exporter.ts +++ b/components/ws-manager-bridge/src/prometheus-metrics-exporter.ts @@ -17,6 +17,7 @@ export class PrometheusMetricsExporter { protected readonly clusterScore: prom.Gauge; protected readonly clusterCordoned: prom.Gauge; protected readonly statusUpdatesTotal: prom.Counter; + protected readonly staleStatusUpdatesTotal: prom.Counter; protected readonly stalePrebuildEventsTotal: prom.Counter; protected readonly prebuildsCompletedTotal: prom.Counter; @@ -53,6 +54,10 @@ export class PrometheusMetricsExporter { help: "Total workspace status updates received", labelNames: ["workspace_cluster", "known_instance"], }); + this.staleStatusUpdatesTotal = new prom.Counter({ + name: "gitpod_ws_manager_bridge_stale_status_updates_total", + help: "Total count of stale status updates received by workspace manager bridge", + }); this.stalePrebuildEventsTotal = new prom.Counter({ name: "gitpod_ws_manager_bridge_stale_prebuild_events_total", help: "Total count of stale prebuild events received by workspace manager bridge", @@ -129,6 +134,10 @@ export class PrometheusMetricsExporter { this.statusUpdatesTotal.labels(installation, knownInstance ? "true" : "false").inc(); } + recordStaleStatusUpdate(): void { + this.staleStatusUpdatesTotal.inc(); + } + recordStalePrebuildEvent(): void { this.stalePrebuildEventsTotal.inc(); }