Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add xero sync #56

Merged
merged 9 commits into from
Apr 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"@usevenice/connector-toggl": "workspace:*",
"@usevenice/connector-venmo": "workspace:*",
"@usevenice/connector-wise": "workspace:*",
"@usevenice/connector-xero": "workspace:*",
"@usevenice/connector-yodlee": "workspace:*",
"@usevenice/engine-backend": "workspace:*",
"@usevenice/env": "workspace:*",
Expand Down
37 changes: 24 additions & 13 deletions connectors/connector-xero/def.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,32 @@
import type {
ConnectorDef,
ConnectorSchemas,
EntityPayloadWithRaw,
} from '@usevenice/cdk'
import {connHelpers, oauthBaseSchema} from '@usevenice/cdk'
import {z, zCast} from '@usevenice/util'
import type {components} from '@opensdks/sdk-xero/xero_accounting.oas.types'
import type {ConnectorDef, ConnectorSchemas} from '@usevenice/cdk'
import {connHelpers, oauthBaseSchema, zEntityPayload} from '@usevenice/cdk'
import {R, z} from '@usevenice/util'

export const zConfig = oauthBaseSchema.connectorConfig

const oReso = oauthBaseSchema.resourceSettings
export const zSettings = oReso.extend({
oauth: oReso.shape.oauth,
})

export type XERO = components['schemas']

export const XERO_ENTITY_NAME = {
Account: 'Account',
BankTransaction: 'BankTransaction',
} as const

export const xeroSchemas = {
name: z.literal('xero'),
resourceSettings: z.object({
access_token: z.string(),
}),
destinationInputEntity: zCast<EntityPayloadWithRaw>(),
connectorConfig: oauthBaseSchema.connectorConfig,
connectorConfig: zConfig,
resourceSettings: zSettings,
connectOutput: oauthBaseSchema.connectOutput,
sourceOutputEntity: zEntityPayload,
sourceOutputEntities: R.mapValues(XERO_ENTITY_NAME, () => z.unknown()),
} satisfies ConnectorSchemas

export const helpers = connHelpers(xeroSchemas)
export const xeroHelpers = connHelpers(xeroSchemas)

export const xeroDef = {
metadata: {
Expand Down
6 changes: 6 additions & 0 deletions connectors/connector-xero/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import type {initXeroSDK} from '@opensdks/sdk-xero'

// codegen:start {preset: barrel, include: "./{*.{ts,tsx},*/index.{ts,tsx}}", exclude: "./**/*.{d,spec,test,fixture,gen,node}.{ts,tsx}"}
export * from './def'
export * from './server'
// codegen:end

export * from '@opensdks/sdk-xero'

export type XeroSDK = ReturnType<typeof initXeroSDK>
3 changes: 2 additions & 1 deletion connectors/connector-xero/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
"sideEffects": [],
"module": "./index.ts",
"dependencies": {
"@opensdks/sdk-xero": "^0.0.2",
"@opensdks/runtime": "^0.0.19",
"@opensdks/sdk-xero": "^0.0.7",
"@opensdks/util-zod": "^0.0.15",
"@usevenice/cdk": "workspace:*",
"@usevenice/util": "workspace:*"
Expand Down
79 changes: 73 additions & 6 deletions connectors/connector-xero/server.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,83 @@
import {initXeroSDK} from '@opensdks/sdk-xero'
import type {ConnectorServer} from '@usevenice/cdk'
import type {xeroSchemas} from './def'
import {nangoProxyLink} from '@usevenice/cdk'
import {Rx, rxjs} from '@usevenice/util'
import {XERO_ENTITY_NAME, xeroHelpers, type xeroSchemas} from './def'

export const xeroServer = {
newInstance: ({settings}) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
// Would be good if this was async...
newInstance: ({settings, fetchLinks}) => {
const xero = initXeroSDK({
headers: {authorization: `Bearer ${settings.access_token}`},
headers: {
authorization: `Bearer ${settings.oauth.credentials.access_token}`,
},
links: (defaultLinks) => [
(req, next) => {
req.headers.set(
nangoProxyLink.kBaseUrlOverride,
'https://api.xero.com',
)
// nango's proxy endpoint is pretty annoying... Will only proxy
// if it is prefixed with nango-proxy. Might as well not proxy like this...
const tenantId = req.headers.get('xero-tenant-id')
if (tenantId) {
req.headers.delete('xero-tenant-id')
req.headers.set('nango-proxy-xero-tenant-id', tenantId)
}
return next(req)
},
...fetchLinks,
...defaultLinks,
],
})
// TODO(@jatin): Add logic here to handle sync.
return xero
},
} satisfies ConnectorServer<typeof xeroSchemas>
sourceSync: ({instance: xero, streams}) => {
console.log('[xero] Starting sync')
async function* iterateEntities() {
// TODO: Should handle more than one tenant Id
const tenantId = await xero.identity
.GET('/Connections')
.then((r) => r.data?.[0]?.tenantId)
if (!tenantId) {
throw new Error(
'Missing access to any tenants. Check xero token permission',
)
}
for (const type of Object.values(XERO_ENTITY_NAME)) {
if (!streams[type]) {
continue
}

const singular = type as 'BankTransaction'
const plural = `${singular}s` as const
const kId = `${singular}ID` as const
let page = 1
while (true) {
const result = await xero.accounting.GET(`/${plural}`, {
params: {header: {'xero-tenant-id': tenantId}, query: {page}},
})
if (result.data[plural]?.length) {
yield result.data[plural]?.map((a) =>
xeroHelpers._opData(singular, a[kId]!, a),
)
// Account does not support pagination, all or nothing...
if (type !== 'Account') {
page++
continue
}
}
break
}
}
}

return rxjs
.from(iterateEntities())
.pipe(
Rx.mergeMap((ops) => rxjs.from([...ops, xeroHelpers._op('commit')])),
)
},
} satisfies ConnectorServer<typeof xeroSchemas, ReturnType<typeof initXeroSDK>>

export default xeroServer
3 changes: 2 additions & 1 deletion docs/samples/banking-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import {createVeniceClient} from '@usevenice/sdk'
const venice = createVeniceClient({
apiKey: process.env['_VENICE_API_KEY'],
apiHost: process.env['_VENICE_API_HOST'],
resourceId: process.env['_QBO_RESOURCE_ID'],
// resourceId: process.env['_QBO_RESOURCE_ID'],
resourceId: process.env['_XERO_RESOURCE_ID'],
})

void venice.GET('/verticals/banking/category').then((r) => {
Expand Down
21 changes: 10 additions & 11 deletions packages/engine-backend/router/_base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,19 +45,18 @@ export const trpc = initTRPC

export const publicProcedure = trpc.procedure
// Enable me for logs
// .use(
// ({next, ctx, input, rawInput, meta, path}) => {
// console.log('[trpc]', {
// input,
// rawInput,
// meta,
// path,
// })
// return next({ctx})
// },
// )
// .use(({next, ctx, input, rawInput, meta, path}) => {
// console.log('[trpc]', {
// input,
// rawInput,
// meta,
// path,
// })
// return next({ctx})
// })

export const protectedProcedure = publicProcedure.use(({next, ctx}) => {
console.log('DEBUG', ctx.viewer)
if (!hasRole(ctx.viewer, ['end_user', 'user', 'org', 'system'])) {
throw new TRPCError({
code: ctx.viewer.role === 'anon' ? 'UNAUTHORIZED' : 'FORBIDDEN',
Expand Down
2 changes: 2 additions & 0 deletions packages/engine-backend/router/endUserRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,8 @@ export const endUserRouter = trpc.router({
const int = await ctx.asOrgIfNeeded.getConnectorConfigOrFail(ccfgId)
console.log('didConnect start', int.connector.name, input, connCtxInput)

// TODO: we should make it possible for oauth connectors to
// ALSO handle custom postConnect... This would be very handy for xero for instance
const resoUpdate = await (async () => {
if (
!int.connector.postConnect &&
Expand Down
3 changes: 2 additions & 1 deletion packages/engine-backend/router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
outreachAdapter,
qboAdapter,
salesloftAdapter,
xeroAdapter,
} from '@usevenice/cdk/verticals'
import {remoteProcedure, trpc} from './_base'
import {adminRouter} from './adminRouter'
Expand All @@ -26,7 +27,7 @@ import {systemRouter} from './systemRouter'
const bankingRouter = createBankingRouter({
trpc,
remoteProcedure,
adapterByName: {qbo: qboAdapter},
adapterByName: {qbo: qboAdapter, xero: xeroAdapter},
})
const accountingRouter = createAccountingRouter({
trpc,
Expand Down
1 change: 0 additions & 1 deletion packages/engine-frontend/VeniceConnect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,6 @@ export function _VeniceConnect({
}) {
const nangoPublicKey =
_trpcReact.getPublicEnv.useQuery().data?.NEXT_PUBLIC_NANGO_PUBLIC_KEY

const nangoFrontend = React.useMemo(
() =>
nangoPublicKey &&
Expand Down
48 changes: 44 additions & 4 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

46 changes: 46 additions & 0 deletions verticals/vertical-banking/adapters/xero-adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type {Oas_accounting, XeroSDK} from 'connectors/connector-xero'
import type {StrictObj} from '@usevenice/vdk'
import {mapper, z, zCast} from '@usevenice/vdk'
import type {VerticalBanking} from '../banking'
import {zBanking} from '../banking'

type Xero = Oas_accounting['components']['schemas']

const mappers = {
category: mapper(
zCast<StrictObj<Xero['Account']>>(),
zBanking.category.extend({_raw: z.unknown().optional()}),
{
id: 'AccountID',
name: 'Name',
_raw: (a) => a,
},
),
}

export const xeroAdapter = {
listCategories: async ({instance}) => {
// TODO: Abstract this away please...
const tenantId = await instance.identity
.GET('/Connections')
.then((r) => r.data?.[0]?.tenantId)
if (!tenantId) {
throw new Error(
'Missing access to any tenants. Check xero token permission',
)
}

const res = await instance.accounting.GET('/Accounts', {
params: {
header: {'xero-tenant-id': tenantId},
query: {
where: 'Class=="REVENUE"||Class=="EXPENSE"',
},
},
})
return {
hasNextPage: false,
items: (res.data.Accounts ?? []).map(mappers.category),
}
},
} satisfies VerticalBanking<{instance: XeroSDK}>
Loading
Loading