Skip to content

Commit

Permalink
feat: billing (#240)
Browse files Browse the repository at this point in the history
I've built so that `billing/lib` can be extracted to w3up, published
separately and used here.

<img width="938" alt="Screenshot 2023-11-01 at 10 52 52"
src="https://github.com/web3-storage/w3infra/assets/152863/7be365b4-cb80-4acd-88dd-3375434eccbf">

TODO:

* [x] Env vars for queue URLs etc.
* [x] Add test for UCAN invocation handler
* [x] DLQs
* [x] Add code to invoke Stripe

Post merge:

* [ ] Add space size snapshots for each space in the system
* [x] Ensure `Customer` table get populated when user selects plan
  • Loading branch information
Alan Shaw authored Nov 1, 2023
1 parent 4a79281 commit 72a628f
Show file tree
Hide file tree
Showing 69 changed files with 14,053 additions and 537 deletions.
3 changes: 2 additions & 1 deletion .eslintignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
dist/
node_modules/
node_modules/
coverage/
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,7 @@ cdk.context.json

# local env files
.env*.local
.test-env.json
.test-env.json

# testing
coverage
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ The server-side implementation of the `store/*` and `upload/*` capabilities defi
The repo contains the infra deployment code and the api implementation.

```
├── billing - usage accounting and reporting to the payment system
├── carpark - lambda for announce new CARs in the carpark bucket
├── replicator - lambda to replicate buckets to R2
├── satnav - lambda to listen for new CARs and write CAR indexes in satnav
Expand Down Expand Up @@ -230,6 +231,10 @@ The HTTP Basic auth token for the UCAN Invocation entrypoint, where UCAN invocat

_Example:_ `MgCZG7EvaA...1pX9as=`

#### `STRIPE_SECRET_KEY`

Stripe API key for reporting per customer usage.

## HTTP API

A UCAN based [RPC] API over HTTP.
Expand Down
95 changes: 95 additions & 0 deletions billing/data/consumer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import * as Link from 'multiformats/link'
import { DecodeFailure, EncodeFailure, Schema } from './lib.js'

/**
* @typedef {import('../lib/api').Consumer} Consumer
* @typedef {import('../types').InferStoreRecord<Consumer>} ConsumerStoreRecord
* @typedef {import('../types').StoreRecord} StoreRecord
* @typedef {import('../lib/api').ConsumerKey} ConsumerKey
* @typedef {import('../types').InferStoreRecord<ConsumerKey>} ConsumerKeyStoreRecord
* @typedef {import('../lib/api').ConsumerListKey} ConsumerListKey
* @typedef {import('../types').InferStoreRecord<ConsumerListKey>} ConsumerListKeyStoreRecord
* @typedef {Pick<Consumer, 'consumer'|'provider'|'subscription'>} ConsumerList
*/

const schema = Schema.struct({
consumer: Schema.did(),
provider: Schema.did({ method: 'web' }),
subscription: Schema.text(),
cause: Schema.link({ version: 1 }),
insertedAt: Schema.date(),
updatedAt: Schema.date()
})

/** @type {import('../lib/api').Validator<Consumer>} */
export const validate = input => schema.read(input)

/** @type {import('../lib/api').Encoder<Consumer, ConsumerStoreRecord>} */
export const encode = input => {
try {
return {
ok: {
consumer: input.consumer,
provider: input.provider,
subscription: input.subscription,
cause: input.cause.toString(),
insertedAt: input.insertedAt.toISOString(),
updatedAt: input.updatedAt.toISOString()
}
}
} catch (/** @type {any} */ err) {
return {
error: new EncodeFailure(`encoding consumer record: ${err.message}`)
}
}
}

/** @type {import('../lib/api').Decoder<StoreRecord, Consumer>} */
export const decode = input => {
try {
return {
ok: {
consumer: Schema.did().from(input.consumer),
provider: Schema.did({ method: 'web' }).from(input.provider),
subscription: /** @type {string} */ (input.subscription),
cause: Link.parse(/** @type {string} */ (input.cause)),
insertedAt: new Date(input.insertedAt),
updatedAt: new Date(input.updatedAt)
}
}
} catch (/** @type {any} */ err) {
return {
error: new DecodeFailure(`decoding consumer record: ${err.message}`)
}
}
}

/** @type {import('../lib/api').Encoder<ConsumerKey, ConsumerKeyStoreRecord>} */
export const encodeKey = input => ({
ok: {
subscription: input.subscription,
provider: input.provider
}
})

/** Encoders/decoders for listings. */
export const lister = {
/** @type {import('../lib/api').Encoder<ConsumerListKey, ConsumerListKeyStoreRecord>} */
encodeKey: input => ({ ok: { consumer: input.consumer } }),
/** @type {import('../lib/api').Decoder<StoreRecord, ConsumerList>} */
decode: input => {
try {
return {
ok: {
consumer: Schema.did().from(input.consumer),
provider: Schema.did({ method: 'web' }).from(input.provider),
subscription: String(input.subscription)
}
}
} catch (/** @type {any} */ err) {
return {
error: new DecodeFailure(`decoding consumer list record: ${err.message}`)
}
}
}
}
51 changes: 51 additions & 0 deletions billing/data/customer-billing-instruction.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import * as dagJSON from '@ipld/dag-json'
import { EncodeFailure, DecodeFailure, Schema } from './lib.js'

/**
* @typedef {import('../lib/api').CustomerBillingInstruction} CustomerBillingInstruction
*/

export const schema = Schema.struct({
customer: Schema.did({ method: 'mailto' }),
account: Schema.uri({ protocol: 'stripe:' }),
product: Schema.text(),
from: Schema.date(),
to: Schema.date()
})

/** @type {import('../lib/api').Validator<CustomerBillingInstruction>} */
export const validate = input => schema.read(input)

/** @type {import('../lib/api').Encoder<CustomerBillingInstruction, string>} */
export const encode = message => {
try {
const data = {
...message,
from: message.from.toISOString(),
to: message.to.toISOString()
}
return { ok: dagJSON.stringify(data) }
} catch (/** @type {any} */ err) {
return {
error: new EncodeFailure(`encoding billing instruction message: ${err.message}`)
}
}
}

/** @type {import('../lib/api').Decoder<string, CustomerBillingInstruction>} */
export const decode = str => {
try {
const data = dagJSON.parse(str)
return {
ok: {
...data,
from: new Date(data.from),
to: new Date(data.to)
}
}
} catch (/** @type {any} */ err) {
return {
error: new DecodeFailure(`decoding billing instruction message: ${err.message}`)
}
}
}
65 changes: 65 additions & 0 deletions billing/data/customer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import * as Link from 'multiformats/link'
import { EncodeFailure, DecodeFailure, Schema } from './lib.js'

/**
* @typedef {import('../lib/api').Customer} Customer
* @typedef {import('../types').InferStoreRecord<Customer>} CustomerStoreRecord
* @typedef {import('../types').StoreRecord} StoreRecord
* @typedef {import('../lib/api').CustomerKey} CustomerKey
* @typedef {import('../types').InferStoreRecord<CustomerKey>} CustomerKeyStoreRecord
*/

const schema = Schema.struct({
customer: Schema.did({ method: 'mailto' }),
account: Schema.uri({ protocol: 'stripe:' }),
product: Schema.text(),
cause: Schema.link({ version: 1 }),
insertedAt: Schema.date(),
updatedAt: Schema.date()
})

/** @type {import('../lib/api').Validator<Customer>} */
export const validate = input => schema.read(input)

/** @type {import('../lib/api').Encoder<Customer, CustomerStoreRecord>} */
export const encode = input => {
try {
return {
ok: {
cause: input.cause.toString(),
customer: input.customer,
account: input.account,
product: input.product,
insertedAt: input.insertedAt.toISOString(),
updatedAt: input.updatedAt.toISOString()
}
}
} catch (/** @type {any} */ err) {
return {
error: new EncodeFailure(`encoding customer record: ${err.message}`)
}
}
}

/** @type {import('../lib/api').Encoder<CustomerKey, CustomerKeyStoreRecord>} */
export const encodeKey = input => ({ ok: { customer: input.customer } })

/** @type {import('../lib/api').Decoder<StoreRecord, Customer>} */
export const decode = input => {
try {
return {
ok: {
cause: Link.parse(String(input.cause)),
customer: Schema.did({ method: 'mailto' }).from(input.customer),
account: Schema.uri({ protocol: 'stripe:' }).from(input.account),
product: /** @type {string} */ (input.product),
insertedAt: new Date(input.insertedAt),
updatedAt: new Date(input.updatedAt)
}
}
} catch (/** @type {any} */ err) {
return {
error: new DecodeFailure(`decoding customer record: ${err.message}`)
}
}
}
102 changes: 102 additions & 0 deletions billing/data/lib.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { Failure } from '@ucanto/server'
import * as Validator from '@ucanto/validator'

export class DecodeFailure extends Failure {
/** @param {string} [message] Context for the message. */
constructor (message) {
super()
this.name = /** @type {const} */ ('DecodeFailure')
this.detail = message
}

describe () {
const detail = this.detail ? `: ${this.detail}` : ''
return `decode failed${detail}`
}
}

export class EncodeFailure extends Failure {
/** @param {string} [message] Context for the message. */
constructor (message) {
super()
this.name = /** @type {const} */ ('EncodeFailure')
this.detail = message
}

describe () {
const detail = this.detail ? `: ${this.detail}` : ''
return `encode failed${detail}`
}
}

/**
* @template [I=unknown]
* @extends {Validator.Schema.API<bigint, I>}
*/
class BigIntSchema extends Validator.Schema.API {
/**
* @param {I} input
*/
readWith (input) {
return typeof input === 'bigint'
? { ok: input }
: Validator.typeError({ expect: 'bigint', actual: input })
}

toString () {
return 'bigint'
}

/**
* @param {bigint} n
*/
greaterThanEqualTo (n) {
return this.refine(new GreaterThanEqualTo(n))
}
}

/**
* @template {bigint} T
* @extends {Validator.Schema.API<T, T, bigint>}
*/
class GreaterThanEqualTo extends Validator.Schema.API {
/**
* @param {T} input
* @param {bigint} number
* @returns {Validator.Schema.ReadResult<T>}
*/
readWith (input, number) {
return input >= number
? { ok: input }
: Validator.Schema.error(`Expected ${input} >= ${number}`)
}

toString() {
return `greaterThan(${this.settings})`
}
}

/**
* @template [I=unknown]
* @extends {Validator.Schema.API<Date, I>}
*/
class DateSchema extends Validator.Schema.API {
/**
* @param {I} input
*/
readWith (input) {
return input instanceof Date
? { ok: input }
: Validator.typeError({ expect: 'Date', actual: input })
}

toString () {
return 'Date'
}
}

export const Schema = {
...Validator.Schema,
bigint: () => new BigIntSchema(),
date: () => new DateSchema()
}
Loading

0 comments on commit 72a628f

Please sign in to comment.