Site, sudo, API and docs: tax, card fees and free workspace limits
Prices say they exclude tax, the card fee is shown before paying, Billing asks for a missing address and shows tax and card fees as their own lines, New workspace, People and Access say Start the plan to invite people, sudo shows Tax collected and takes an enterprise's billing address. API and MCP descriptions, the billing guide, BILLING_OPERATIONS.md and PLAN.md.
26 files+807−920/26 viewed
| 1020 | 1020 | "Who the access token acts as, and the workspaces it can work in. `kind` is `user` for a person's token, `workspace` for a token that belongs to a workspace, and `agent` for the token a g1t agent works with." | |
| 1021 | 1021 | } | |
| 1022 | 1022 | Op::CreateWorkspace => { | |
| 1023 | − | "Create a workspace. A workspace owns repositories and is the first part of their address: g1t.sh/{workspace}/{repo}. The whoami tool lists the ones you already belong to." | |
| 1023 | + | "Create a workspace. A workspace owns repositories and is the first part of their address: g1t.sh/{workspace}/{repo}. The whoami tool lists the ones you already belong to. A new workspace is free, and each person can own one free workspace: if you already own one (or several, from before), this is refused with `payment_required` (402) until each workspace you own is on the g1t plan or deleted. Workspaces with the plan, an enterprise's terms or a full discount do not count." | |
| 1024 | 1024 | } | |
| 1025 | 1025 | Op::ListEmails => { | |
| 1026 | 1026 | "Your email addresses: each one's `email`, whether it is `verified` (confirmed), `primary` or the `backup`, and when it was added and confirmed. Also whether you keep your address private (`private_email`), your `noreply` address, and `commit_email`, the address on commits g1t makes for you. People only: an agent's or a workspace's token cannot read or change addresses." | |
| 1047 | 1047 | "The invites made for a workspace, newest first, with each pending one's `code`. Owners only." | |
| 1048 | 1048 | } | |
| 1049 | 1049 | Op::InviteMember => { | |
| 1050 | − | "Invite an email address into a workspace. It always makes an invite bound to that address and emails it the link, so the answer never says whether the address has a g1t account. Without one, accepting makes the account and joins the workspace in one step, and uses one of the workspace's granted invites, or else one of yours. With one, it costs nothing, and they join when they accept. To add someone by username at once, use the workspace's People page. Owners only." | |
| 1050 | + | "Invite an email address into a workspace. It always makes an invite bound to that address and emails it the link, so the answer never says whether the address has a g1t account. Without one, accepting makes the account and joins the workspace in one step, and uses one of the workspace's granted invites, or else one of yours. With one, it costs nothing, and they join when they accept. To add someone by username at once, use the workspace's People page. Owners only. A free workspace cannot invite anyone: this is refused with `payment_required` (402) until it starts the g1t plan, and an invite sent before cannot be accepted until then." | |
| 1051 | 1051 | } | |
| 1052 | 1052 | Op::RevokeWorkspaceInvite => "Revoke a workspace's pending invite. Owners only.", | |
| 1053 | 1053 | Op::DeleteWorkspace => { | |
| 1334 | 1334 | "Who has access to a repository: the workspace's `base_permission`, and `people`, everyone with a role on it other than through it being public. Each person has their effective `role` (read, triage, write, maintain or admin), its `source` (`owner` of the workspace, the workspace's `base` permission, or a `direct` role on this repository), their `direct` role if they have one, and their `workspace_role` (`owner`, `member`, or null for an outside collaborator). Pending `invitations` are listed for those with the Admin role, and empty for anyone else. `viewer_role` is your own role, and `can_manage` whether you may change who has access. Needs the Write role or higher. People only." | |
| 1335 | 1335 | } | |
| 1336 | 1336 | Op::AddCollaborator => { | |
| 1337 | − | "Give someone a role on a repository, by username or email address. A member of its workspace gets the role at once (`result` is `granted`, with the `collaborator`). Anyone else becomes an outside collaborator once they accept an invitation, which is emailed to them and waits 7 days (`result` is `invited`, with the `invitation`); an address with no g1t account is sent an invite that makes the account and accepts in one step. The role is read, triage, write, maintain or admin. Needs the Admin role on the repository, signed in as a person with a confirmed email address; agents' and workspaces' tokens are refused." | |
| 1337 | + | "Give someone a role on a repository, by username or email address. A member of its workspace gets the role at once (`result` is `granted`, with the `collaborator`). Anyone else becomes an outside collaborator once they accept an invitation, which is emailed to them and waits 7 days (`result` is `invited`, with the `invitation`); an address with no g1t account is sent an invite that makes the account and accepts in one step. The role is read, triage, write, maintain or admin. Needs the Admin role on the repository, signed in as a person with a confirmed email address; agents' and workspaces' tokens are refused. A free workspace can give its members a role, but cannot invite anyone from outside it: that is refused with `payment_required` (402) until the workspace starts the g1t plan." | |
| 1338 | 1338 | } | |
| 1339 | 1339 | Op::UpdateCollaborator => { | |
| 1340 | 1340 | "Change the role someone was given on a repository directly, or the role of their pending invitation. A role from ownership or the workspace's base permission is not changed here: an owner always has Admin, and a member never has less than the base permission. Needs the Admin role. People only." | |
| 1355 | 1355 | "The invitations to repositories waiting for you to answer, sent to your username or to one of your confirmed email addresses, newest first. Accept or decline each by its `id`. People only; an agent's or a workspace's token gets an empty list." | |
| 1356 | 1356 | } | |
| 1357 | 1357 | Op::AcceptRepoInvitation => { | |
| 1358 | − | "Accept an invitation to a repository sent to you. You get its role on that repository at once, as an outside collaborator unless you belong to its workspace. Refused when the workspace asks something of everyone with access that your account does not meet, such as two-factor authentication. People only." | |
| 1358 | + | "Accept an invitation to a repository sent to you. You get its role on that repository at once, as an outside collaborator unless you belong to its workspace. Refused when the workspace asks something of everyone with access that your account does not meet, such as two-factor authentication, and with `payment_required` (402) while the workspace is free: it can add no one until it starts the g1t plan, and the invitation waits until then. People only." | |
| 1359 | 1359 | } | |
| 1360 | 1360 | Op::DeclineRepoInvitation => { | |
| 1361 | 1361 | "Decline an invitation to a repository sent to you. Whoever sent it can invite you again. People only." | |
| 1484 | 1484 | "Start buying AI credit. Returns `url`, a payment page to open in a browser and pay by card; it comes back to the workspace's billing page. `amount_cents` is the credit, in whole dollars from $10 (1000) to $1,000 (100000); any card fee is added on top. The credit is added once the payment goes through. Owners only, as a person: signed in or with a personal access token. A workspace's own token and g1t's agents never buy credit." | |
| 1485 | 1485 | } | |
| 1486 | 1486 | Op::ListInvoices => { | |
| 1487 | − | "A workspace's invoices, newest first. `invoices` is every invoice billed to it (the plan, activations, AI credit and usage), each with its `status`, `total_cents`, `currency` and links to view it and its PDF. `usage_invoices` are g1t's itemised invoices for usage, one when each month closes and one each time the card is charged near the limit, with their `lines` in millionths of a dollar. `upcoming` is what the next invoice comes to so far. `unavailable` says why `invoices` could not be read just now, when it could not. Members of the workspace only." | |
| 1487 | + | "A workspace's invoices, newest first. `invoices` is every invoice billed to it (the plan, activations, AI credit and usage), each with its `status`, `total_cents`, `currency` and links to view it and its PDF. `usage_invoices` are g1t's itemised invoices for usage, one when each month closes and one each time the card is charged near the limit, with their `lines` in millionths of a dollar; `amount_micros` is the usage, and the card processing fee (`fee_micros`) and tax (`tax_micros`) are on top. Prices exclude tax: Stripe adds it where it applies. `upcoming` is what the next invoice comes to so far. `unavailable` says why `invoices` could not be read just now, when it could not. Members of the workspace only." | |
| 1488 | 1488 | } | |
| 1489 | 1489 | Op::GetBillingDetails => { | |
| 1490 | − | "Who a workspace's invoices are made out to: the billing `email`, `name`, `address`, tax ID (`tax_id_type`, `tax_id`), `po_number` and the invoices' `language`, with the default `payment_method` as far as it is safe to show (its kind, brand, last four digits and expiry). `customer` is false until the workspace has been set up to pay. Members of the workspace only." | |
| 1490 | + | "Who a workspace's invoices are made out to: the billing `email`, `name`, `address`, tax ID (`tax_id_type`, `tax_id`), `po_number` and the invoices' `language`, with the default `payment_method` as far as it is safe to show (its kind, brand, last four digits and expiry). `customer` is false until the workspace has been set up to pay. Tax is worked out from the address: `tax_location` says whether it is enough for that (a country, and in the US a ZIP code), `tax_address_needed_at` is set while g1t is holding a charge for want of one, `tax_id_status` is Stripe's check of the tax ID (`pending`, `verified`, `unverified` or `unavailable`), and `tax_exempt` is `none`, `exempt` or `reverse`. Members of the workspace only." | |
| 1491 | 1491 | } | |
| 1492 | 1492 | Op::ListUserTeams => { | |
| 1493 | 1493 | "The teams someone is in within a workspace, as list_teams describes them, leaving out secret teams you cannot see. Members of the workspace only." |
| 68 | 68 | "member_count": 1, | |
| 69 | 69 | "base_permission": "write" | |
| 70 | 70 | }, | |
| 71 | − | "notes": "`base_permission` is what every member gets on each of its repositories: `write` until an owner changes it with `PATCH /workspaces/{workspace}` or `PUT /workspaces/{workspace}/base_permission`. See [Access and roles](/guides/access-and-roles/)." | |
| 71 | + | "notes": "`base_permission` is what every member gets on each of its repositories: `write` until an owner changes it with `PATCH /workspaces/{workspace}` or `PUT /workspaces/{workspace}/base_permission`. See [Access and roles](/guides/access-and-roles/). A new workspace is free, and each person owns at most one free workspace: while you own one, this answers `402` with `payment_required` and says which, until it is on the plan or deleted. See [One free workspace per person](/guides/usage-and-billing/#one-free-workspace-per-person)." | |
| 72 | 72 | }, | |
| 73 | 73 | "update_workspace": { | |
| 74 | 74 | "params": { | |
| 402 | 402 | "redeemed_at": null, | |
| 403 | 403 | "revoked_at": null | |
| 404 | 404 | }, | |
| 405 | − | "notes": "`kind` is `workspace` when the address already has a g1t account (`charged_to` is then `none`), and `account` when accepting makes one. Both answers look alike to the caller on purpose: the invite is emailed either way." | |
| 405 | + | "notes": "`kind` is `workspace` when the address already has a g1t account (`charged_to` is then `none`), and `account` when accepting makes one. Both answers look alike to the caller on purpose: the invite is emailed either way. A free workspace cannot invite anyone: `402` with `payment_required`, until it starts the g1t plan." | |
| 406 | 406 | }, | |
| 407 | 407 | "revoke_workspace_invite": { | |
| 408 | 408 | "response": { | |
| 4164 | 4164 | "expires_at": "2026-10-12T17:00:00.000Z" | |
| 4165 | 4165 | } | |
| 4166 | 4166 | }, | |
| 4167 | − | "notes": "`invitee` is a username or an email address. A member of the workspace answers `{\"result\": \"granted\", \"collaborator\": {…}}` with the role already given, shown as list_collaborators shows a person. Anyone else answers `{\"result\": \"invited\", \"invitation\": {…}}`: the invitation is emailed and waits 7 days, and the role is theirs once they accept it. An email address without an account gets an invitation with `email` set and `invitee` null, and an invite that makes the account and accepts in one step. Refused with `404` when no account has that username, `409` when they already have a role of their own or a pending invitation (change it with update_collaborator), and `403` without the Admin role, from an agent's or a workspace's token, or when the person does not meet what the workspace asks of everyone with access. The `repo.collaborator_added` webhook event is sent once they have the role. See [Access and roles](/guides/access-and-roles/)." | |
| 4167 | + | "notes": "`invitee` is a username or an email address. A member of the workspace answers `{\"result\": \"granted\", \"collaborator\": {…}}` with the role already given, shown as list_collaborators shows a person. Anyone else answers `{\"result\": \"invited\", \"invitation\": {…}}`: the invitation is emailed and waits 7 days, and the role is theirs once they accept it. An email address without an account gets an invitation with `email` set and `invitee` null, and an invite that makes the account and accepts in one step. Refused with `404` when no account has that username, `409` when they already have a role of their own or a pending invitation (change it with update_collaborator), and `403` without the Admin role, from an agent's or a workspace's token, or when the person does not meet what the workspace asks of everyone with access. On a free workspace, inviting anyone who is not a member answers `402` with `payment_required` until it starts the g1t plan. The `repo.collaborator_added` webhook event is sent once they have the role. See [Access and roles](/guides/access-and-roles/)." | |
| 4168 | 4168 | }, | |
| 4169 | 4169 | "update_collaborator": { | |
| 4170 | 4170 | "params": { | |
| 5961 | 5961 | "amount_micros": 6200000 | |
| 5962 | 5962 | } | |
| 5963 | 5963 | ], | |
| 5964 | − | "created_at": "2026-10-01T00:05:00.000Z" | |
| 5964 | + | "created_at": "2026-10-01T00:05:00.000Z", | |
| 5965 | + | "fee_micros": 1600000, | |
| 5966 | + | "tax_micros": 3510000 | |
| 5965 | 5967 | } | |
| 5966 | 5968 | ], | |
| 5967 | 5969 | "upcoming": { | |
| 6000 | 6002 | "last4": "4242", | |
| 6001 | 6003 | "exp_month": 12, | |
| 6002 | 6004 | "exp_year": 2028 | |
| 6003 | − | } | |
| 6005 | + | }, | |
| 6006 | + | "tax_location": true, | |
| 6007 | + | "tax_address_needed_at": null, | |
| 6008 | + | "tax_id_status": "unavailable", | |
| 6009 | + | "tax_exempt": "none" | |
| 6004 | 6010 | }, | |
| 6005 | − | "notes": "Owners change these on the workspace's billing page. `payment_method` is null until a card or other way to pay is saved." | |
| 6011 | + | "notes": "Owners change these on the workspace's billing page. `payment_method` is null until a card or other way to pay is saved. Tax is worked out from the address: `tax_location` is false until it has at least a country (and in the US a ZIP code), and `tax_address_needed_at` is set while a charge waits for one." | |
| 6006 | 6012 | }, | |
| 6007 | 6013 | "list_pinned_projects": { | |
| 6008 | 6014 | "params": { |
| 163 | 163 | granted invites, or else one of yours (see | |
| 164 | 164 | [invites](/guides/authentication/#invites)), and works for 30 days. | |
| 165 | 165 | ||
| 166 | + | On a free workspace, only the first row works: its members can be given a | |
| 167 | + | role, but nobody else can be invited until the workspace starts the g1t | |
| 168 | + | plan. **Add people** says **Start the plan to invite people** above the | |
| 169 | + | form, and an invitation is refused with `402` (`payment_required`). An | |
| 170 | + | invitation sent before cannot be accepted until then. See | |
| 171 | + | [who a free workspace can add](/guides/usage-and-billing/#who-a-free-workspace-can-add). | |
| 172 | + | ||
| 166 | 173 | To change someone's role, pick another beside their name. To take it away, | |
| 167 | 174 | choose **Remove**. Removing takes away only the role given on this | |
| 168 | 175 | repository: an owner's Admin, a member's base permission and what their |
| 37 | 37 | deployments and semantic search need [the g1t plan](#the-g1t-plan), or a | |
| 38 | 38 | [card check](#no-card-no-compute) for the trial and the open-source pool. | |
| 39 | 39 | ||
| 40 | + | ### One free workspace per person | |
| 41 | + | ||
| 42 | + | Each person can own **one free workspace**: a workspace that is not on the | |
| 43 | + | g1t plan, an enterprise's terms or a full discount. A new workspace starts | |
| 44 | + | free, so while you own a free workspace, creating another is refused with | |
| 45 | + | the way forward: | |
| 46 | + | ||
| 47 | + | 1. Start the plan on the free workspace you have | |
| 48 | + | ([Start or end the plan](#start-or-end-the-plan)), or | |
| 49 | + | 2. delete it, if you no longer use it | |
| 50 | + | ([Delete a workspace](/guides/workspaces/)). | |
| 51 | + | ||
| 52 | + | Then create the new one. If you owned several free workspaces before this | |
| 53 | + | rule, you keep them all, but you cannot create another until each of them | |
| 54 | + | is on the plan or deleted. Workspaces you belong to without owning them do | |
| 55 | + | not count. The site's **New workspace** page says so before you start; | |
| 56 | + | `POST /workspaces` and the MCP `workspace` tool's `create` action answer | |
| 57 | + | `402` (`payment_required`) with the same message. | |
| 58 | + | ||
| 59 | + | ### Who a free workspace can add | |
| 60 | + | ||
| 61 | + | A free workspace keeps the people already in it, but **cannot add anyone** | |
| 62 | + | until it starts the plan: | |
| 63 | + | ||
| 64 | + | - no new members, by username or by email invite; | |
| 65 | + | - no outside collaborators on its repositories, and no invitations to them; | |
| 66 | + | - an invite or invitation sent before cannot be accepted until then (it | |
| 67 | + | waits, and works once the plan is on). | |
| 68 | + | ||
| 69 | + | Its members can still be given a role on its repositories, and put on its | |
| 70 | + | [teams](/guides/teams/). On the **People** page and a repository's | |
| 71 | + | **Settings → Access**, an owner sees **Start the plan to invite people** | |
| 72 | + | with a button to the plan. Through the API and MCP, adding a member, | |
| 73 | + | inviting, adding an outside collaborator and accepting are refused with | |
| 74 | + | `402` (`payment_required`). g1t's own agent, `@g1t`, works in every | |
| 75 | + | workspace and never counts as someone added. | |
| 76 | + | ||
| 40 | 77 | Your own machines are. Workflow jobs on | |
| 41 | 78 | [self-hosted runners](/guides/self-hosted-runners/) cost nothing, on every | |
| 42 | 79 | plan, the free one included, and need no card; their minutes show on usage | |
| 46 | 83 | ## The g1t plan | |
| 47 | 84 | ||
| 48 | 85 | One plan, **$20 a month per workspace**, however many people and agents | |
| 49 | − | are in it. It is never priced per person. | |
| 86 | + | are in it. It is never priced per person. Like every price on g1t, it | |
| 87 | + | excludes tax ([Tax](#tax)), and paid by card it carries Stripe's | |
| 88 | + | [card processing fee](#card-processing-fee) as its own line, $0.91 a month | |
| 89 | + | on $20. | |
| 50 | 90 | ||
| 51 | 91 | - **$10 of usage each month** at cost plus 20%, used first. | |
| 52 | 92 | - **Everyone in the workspace** at one price, never per person. | |
| 514 | 554 | asks. A bank transfer page gives the account details, and the amount | |
| 515 | 555 | counts when the money arrives. | |
| 516 | 556 | ||
| 557 | + | By card, the [card processing fee](#card-processing-fee) is its own line | |
| 558 | + | (the card shows it for $100, $500 and $1,000); a bank transfer has none. | |
| 559 | + | [Tax](#tax) is added where it applies. What you prepay is credited in full: | |
| 560 | + | neither the fee nor the tax comes from it. | |
| 561 | + | ||
| 517 | 562 | **Prepaid balance** shows what is paid in advance and not used yet. | |
| 518 | 563 | ||
| 519 | 564 | ### Caps | |
| 585 | 630 | amount from $10 to $1,000. | |
| 586 | 631 | 3. Choose **Buy AI credit**, and pay on Stripe's page. | |
| 587 | 632 | ||
| 588 | − | Stripe's card fee (2.9% + $0.30) is its own line on that page, **Card | |
| 589 | − | processing fee**, so the credit you get is the amount you chose. Invoiced | |
| 590 | − | billing never carries it. The credit is added once Stripe says the payment | |
| 591 | − | was made, whether or not you come back to g1t, and **expires 1 year after | |
| 592 | − | purchase**. The card is kept for auto-reload. | |
| 633 | + | The [card processing fee](#card-processing-fee) is its own line on that | |
| 634 | + | page, and shown on Billing before you go there (on $25, *Card processing | |
| 635 | + | fee $1.06, plus tax where it applies*), so the credit you get is the amount | |
| 636 | + | you chose. [Tax](#tax) is added on top where it applies. The credit is | |
| 637 | + | added once Stripe says the payment was made, whether or not you come back | |
| 638 | + | to g1t, and **expires 1 year after purchase**. The card is kept for | |
| 639 | + | auto-reload, which charges the credit, its card fee and its tax together. | |
| 593 | 640 | ||
| 594 | 641 | ### Auto-reload | |
| 595 | 642 | ||
| 658 | 705 | charged at once with no minimum. See | |
| 659 | 706 | [deleting a workspace](/guides/workspaces/#what-billing-needs). | |
| 660 | 707 | ||
| 661 | − | Each is charged to the card on file. The Billing page lists them, with | |
| 662 | − | links to view each on Stripe and download its PDF. | |
| 708 | + | Each is charged to the card on file, with the | |
| 709 | + | [card processing fee](#card-processing-fee) and [tax](#tax) as lines of | |
| 710 | + | their own. The Billing page lists them, with links to view each on Stripe | |
| 711 | + | and download its PDF; each one's amount is the usage, with the fee and | |
| 712 | + | tax under it. | |
| 713 | + | ||
| 714 | + | ## Tax | |
| 715 | + | ||
| 716 | + | Every price on g1t excludes tax. Stripe adds sales tax, VAT or GST where it | |
| 717 | + | applies (Stripe Tax), worked out from the workspace's billing address, and | |
| 718 | + | shows it as its own line before you pay and on every receipt and invoice. | |
| 719 | + | The plan, add-ons, prepaying, AI credit, auto-reload and invoices are all | |
| 720 | + | taxed the same way, as software as a service for business use. | |
| 721 | + | ||
| 722 | + | - **The address.** Stripe's payment pages always ask for a billing | |
| 723 | + | address, and save it as the workspace's **Invoice details**. A card check | |
| 724 | + | saves the card's billing address there too, if there is none yet. You | |
| 725 | + | can change it under [Your card and billing | |
| 726 | + | details](#your-card-and-billing-details). | |
| 727 | + | - **A business tax ID.** Add it on Stripe's page or under **Invoice | |
| 728 | + | details** (the kind, such as EU VAT, and the number). Stripe checks it | |
| 729 | + | (**Verified by Stripe**, or **Stripe is checking it**) and applies it | |
| 730 | + | where the law says so, such as a reverse charge. | |
| 731 | + | - **No address, no charge.** Where Stripe has nothing to work tax out | |
| 732 | + | from, g1t does not charge the card. Billing shows **Add a billing | |
| 733 | + | address**, the owners are emailed once, and the charge goes through once | |
| 734 | + | the address is saved. Nothing is lost, and work is not stopped for it. | |
| 735 | + | In the United States the ZIP code is needed; elsewhere the country. | |
| 736 | + | - **Tax exempt.** If your organisation is exempt, write to | |
| 737 | + | support@g1t.sh with the certificate; Billing then says **Tax exempt**. | |
| 738 | + | - **Refunds** give the tax back in proportion. | |
| 739 | + | ||
| 740 | + | Tax is never part of your balance or usage: the statement shows it as its | |
| 741 | + | own line, beside the payment it came with. | |
| 742 | + | ||
| 743 | + | ## Card processing fee | |
| 744 | + | ||
| 745 | + | Paying by card adds Stripe's fee, **2.9% + $0.30**, as its own line, | |
| 746 | + | **Card processing fee**, worked out so that what is left after Stripe's | |
| 747 | + | fee is exactly what you paid for: $0.91 on the $20 plan, $1.06 on $25 of | |
| 748 | + | AI credit. It is shown before you pay, on every card payment: | |
| 749 | + | ||
| 750 | + | | Payment | Card fee | | |
| 751 | + | | --- | --- | | |
| 752 | + | | The plan and add-ons, each month | Yes, a monthly line | | |
| 753 | + | | Prepaying by card, and AI credit | Yes | | |
| 754 | + | | Auto-reload, and invoices charged to the card | Yes | | |
| 755 | + | | Prepaying by bank transfer | No | | |
| 756 | + | | An enterprise's invoices | No | | |
| 757 | + | ||
| 758 | + | Tax applies to the fee as to what it is paid with. The fee pays Stripe, not | |
| 759 | + | g1t: usage is still charged at cost plus 20%, and models at the provider's | |
| 760 | + | price plus the agent rate. The statement shows card fees as their own line, | |
| 761 | + | never from your balance. | |
| 663 | 762 | ||
| 664 | 763 | ### The minimum charge | |
| 665 | 764 | ||
| 691 | 790 | printed on every invoice: the invoice email, company name, billing | |
| 692 | 791 | address, tax ID (its kind and number), a purchase order, and the invoice | |
| 693 | 792 | language. An owner edits them here and chooses **Save invoice details**. | |
| 793 | + | [Tax](#tax) is worked out from the address, so the card says when there | |
| 794 | + | is none yet (in the US it needs the ZIP code), shows Stripe's check of | |
| 795 | + | the tax ID, and says **Tax exempt** or **Reverse charge** when that | |
| 796 | + | applies. | |
| 694 | 797 | - **Invoices** lists every invoice Stripe sent (the plan, add-ons, AI | |
| 695 | 798 | credit and month-end usage), each with **View** and **PDF**. | |
| 696 | 799 | - **Add-ons** lists what can be turned on beside the plan, such as the | |
| 746 | 849 | enterprise is **invoiced**: when each month closes, one invoice goes to | |
| 747 | 850 | the enterprise's billing address, with a line for each workspace, due in | |
| 748 | 851 | 30 days and paid by card or bank transfer. If it goes overdue, the | |
| 749 | − | workspaces' work stops until it is paid. | |
| 852 | + | workspaces' work stops until it is paid. [Tax](#tax) is added from the | |
| 853 | + | enterprise's billing address, which g1t keeps on its Stripe customer, and | |
| 854 | + | an invoice never carries the card processing fee. | |
| 750 | 855 | - **A discount**: a percentage off every usage charge, from a few percent | |
| 751 | 856 | to 100%, with a reason, sometimes until a date. Prices themselves stay | |
| 752 | 857 | the public ones. The statement shows every line at its price and the | |
| 844 | 949 | | Payments | Card payments and invoices paid. | | |
| 845 | 950 | | Credits from g1t | Credit g1t added (promotional, goodwill or a refund), and what of it expired or was withdrawn. See [Credits from g1t](#credits-from-g1t). | | |
| 846 | 951 | | Refunds | Money given back to your card. | | |
| 952 | + | | Tax | Tax paid with that day's payments. Not a charge, and not from your balance. | | |
| 953 | + | | Card processing fees | Card fees paid with that day's payments. Not a charge, and not from your balance. | | |
| 954 | + | ||
| 955 | + | Payments are what reached your balance; the tax and card fee paid with | |
| 956 | + | them are the two lines below them, and the totals say what they came to. | |
| 847 | 957 | ||
| 848 | 958 | - **Covered.** Below the totals, what paid for usage before it was | |
| 849 | 959 | charged, each with its amount: |
| 32 | 32 | You can belong to up to ten workspaces. `GET /user`, or the `account` tool's `whoami` action, lists the | |
| 33 | 33 | ones you belong to. | |
| 34 | 34 | ||
| 35 | + | A new workspace is free, and **each person can own one free workspace**. | |
| 36 | + | While you own a free workspace, the page shows **You already own a free | |
| 37 | + | workspace** in place of the form, with **Start the plan** on it; the API | |
| 38 | + | answers `402` (`payment_required`). Start the plan on it, or delete it, | |
| 39 | + | then create the new one. Several free workspaces from before are kept, but | |
| 40 | + | each needs the plan (or deleting) before you can create another. See | |
| 41 | + | [one free workspace per person](/guides/usage-and-billing/#one-free-workspace-per-person). | |
| 42 | + | ||
| 35 | 43 | Usernames and workspaces share one set of names, so a name means the same | |
| 36 | 44 | thing wherever it appears. Your username is reserved for you: only you can | |
| 37 | 45 | create a workspace with that name, and nobody can register a username that | |
| 295 | 303 | To give someone a role on one repository without making them a member, | |
| 296 | 304 | add them as an [outside collaborator](/guides/access-and-roles/#outside-collaborators). | |
| 297 | 305 | ||
| 306 | + | **A free workspace cannot add people.** Until it starts the g1t plan, it | |
| 307 | + | cannot add members, send invites, or invite outside collaborators, and an | |
| 308 | + | invite sent before waits until the plan is on. Its members stay. People | |
| 309 | + | shows **Start the plan to invite people** with the button in place of the | |
| 310 | + | form, and the API and MCP answer `402` (`payment_required`). See | |
| 311 | + | [who a free workspace can add](/guides/usage-and-billing/#who-a-free-workspace-can-add). | |
| 312 | + | ||
| 298 | 313 | ## The workspace's page | |
| 299 | 314 | ||
| 300 | 315 | A workspace's own page, `g1t.sh/<workspace>`, has its icon, name, address |
| 489 | 489 | | --- | --- | --- | --- | | |
| 490 | 490 | | [`list_collaborators`](/reference/api/access/list-collaborators/) | Everyone with a role on it, with the role, where it comes from (`owner`, `base` or `direct`) and whether they are members; the base permission; and, with the Admin role, pending invitations. Needs the Write role. | `repo` | `access:read` | | |
| 491 | 491 | | [`get_permission`](/reference/api/access/get-collaborator-permission/) | Someone's role, where it comes from, and what it lets them do. Needs the Write role, or to be about yourself. | `repo`, `username` | `access:read` | | |
| 492 | − | | [`add_collaborator`](/reference/api/access/add-collaborator/) | Give someone a role by username or email address. A member gets it at once; anyone else is invited, and becomes an outside collaborator on accepting. Needs the Admin role. | `repo`, `invitee`, `role` | `access:admin` | | |
| 492 | + | | [`add_collaborator`](/reference/api/access/add-collaborator/) | Give someone a role by username or email address. A member gets it at once; anyone else is invited, and becomes an outside collaborator on accepting. Needs the Admin role. On a free workspace, only members: inviting anyone else is refused with `402` until it starts the plan. | `repo`, `invitee`, `role` | `access:admin` | | |
| 493 | 493 | | [`update_collaborator`](/reference/api/access/update-collaborator/) | Change someone's direct role, or their pending invitation's. Needs the Admin role. | `repo`, `username`, `role` | `access:admin` | | |
| 494 | 494 | | [`remove_collaborator`](/reference/api/access/remove-collaborator/) | Take away someone's direct role. Needs the Admin role, or to be your own. | `repo`, `username` | `access:admin` | | |
| 495 | 495 | | [`list_invitations`](/reference/api/access/list-repo-invitations/) | Its pending invitations. Needs the Admin role. | `repo` | `access:read` | | |
| 532 | 532 | ||
| 533 | 533 | | Action | What it does | Required | Scope | | |
| 534 | 534 | | --- | --- | --- | --- | | |
| 535 | − | | [`create`](/reference/api/workspaces/create-workspace/) | Create a workspace. | `slug` | `workspace:admin` | | |
| 535 | + | | [`create`](/reference/api/workspaces/create-workspace/) | Create a workspace. A new one is free, and each person owns at most one free workspace: refused with `402` while you own one, until it is on the plan or deleted. See [one free workspace per person](/guides/usage-and-billing/#one-free-workspace-per-person). | `slug` | `workspace:admin` | | |
| 536 | 536 | | [`update`](/reference/api/workspaces/update-workspace/) | Change its display name and description, and with the `access:admin` scope too, its `base_permission`. Only the fields given change; the slug never does. Owners only. | `workspace` | `workspace:admin` | | |
| 537 | 537 | | [`delete`](/reference/api/workspaces/delete-workspace/) | Delete an empty workspace whose billing is settled; `confirm` is its slug. Owners only. See [deleting a workspace](/guides/workspaces/#delete-a-workspace). | `workspace`, `confirm` | `workspace:admin` | | |
| 538 | 538 | | [`list_invites`](/reference/api/invites/list-workspace-invites/) | A workspace's invites. Owners only. | `workspace` | `workspace:read` | | |
| 539 | − | | [`invite_member`](/reference/api/invites/invite-member/) | Invite an address into a workspace, with an invite bound to it. Owners only. | `workspace`, `email` | `workspace:admin` | | |
| 539 | + | | [`invite_member`](/reference/api/invites/invite-member/) | Invite an address into a workspace, with an invite bound to it. Owners only. A free workspace cannot invite: refused with `402` until it starts the plan. | `workspace`, `email` | `workspace:admin` | | |
| 540 | 540 | | [`revoke_invite`](/reference/api/invites/revoke-workspace-invite/) | Revoke a workspace's pending invite. Owners only. | `workspace`, `id` | `workspace:admin` | | |
| 541 | 541 | | [`list_integrations`](/reference/api/integrations/list-integrations/) | The workspace's connections. Secrets are never returned. Members only. | `workspace` | `workspace:read` | | |
| 542 | 542 | | [`connect_integration`](/reference/api/integrations/connect-integration/) | Connect a model provider (Anthropic, OpenAI, Gemini, or a compatible endpoint), Sentry, Datadog, a webhook, Jira or Linear, with `config` and `secret`. Owners only. | `workspace`, `provider` | `workspace:admin` | |
Binary or large file; its contents are not shown.
| 201 | 201 | return back("billing-email"); | |
| 202 | 202 | } | |
| 203 | 203 | ||
| 204 | + | if (intent === "billing-address") { | |
| 205 | + | // Where Stripe Tax places the enterprise: an invoice is not sent without it. | |
| 206 | + | const values = fields(form, "line1", "line2", "city", "state", "postalCode", "country", "taxIdType", "taxId"); | |
| 207 | + | if (subject.kind !== "enterprise") return failed("top", "Only an enterprise's address is set here; a workspace's owners set their own."); | |
| 208 | + | if (!subject.billingEmail) return failed("address", "Set where its invoices go first: that makes its Stripe customer.", values); | |
| 209 | + | const country = values.country.trim().toUpperCase(); | |
| 210 | + | if (!/^[A-Z]{2}$/.test(country)) return failed("address", "The country is two letters, such as US or DE.", values); | |
| 211 | + | if (country === "US" && !values.postalCode.trim()) return failed("address", "In the US, Stripe Tax needs the ZIP code.", values); | |
| 212 | + | const taxIdType = values.taxIdType.trim(); | |
| 213 | + | const taxId = values.taxId.trim(); | |
| 214 | + | if (Boolean(taxIdType) !== Boolean(taxId)) return failed("address", "Give the tax ID's kind and its number together, or neither.", values); | |
| 215 | + | const address = { | |
| 216 | + | line1: values.line1.trim(), | |
| 217 | + | line2: values.line2.trim(), | |
| 218 | + | city: values.city.trim(), | |
| 219 | + | state: values.state.trim(), | |
| 220 | + | postalCode: values.postalCode.trim(), | |
| 221 | + | country, | |
| 222 | + | }; | |
| 223 | + | const result = await admin.enterpriseAddress(subject.accountId, address, taxIdType || null, taxId || null, staff.email); | |
| 224 | + | if (!result.ok) return failed("address", result.error.message, values); | |
| 225 | + | return back("billing-address"); | |
| 226 | + | } | |
| 227 | + | ||
| 204 | 228 | if (intent === "invoice") { | |
| 205 | 229 | if (subject.kind !== "enterprise") return failed("top", "Only an enterprise is invoiced from sudo."); | |
| 206 | 230 | if (!confirmed) { |
| 33 | 33 | "reset-stale": "Billing reset. The workspace starts again as a new customer; the costs analysis did not finish, so press Run the analysis now on Costs & margin.", | |
| 34 | 34 | created: "Enterprise created.", | |
| 35 | 35 | "billing-email": "Saved where the enterprise's invoices go.", | |
| 36 | + | "billing-address": "Billing address saved on its Stripe customer. Its invoices are taxed from it.", | |
| 36 | 37 | sales: "Sales record saved.", | |
| 37 | 38 | note: "Note added.", | |
| 38 | 39 | payment: "Payment recorded. It is on the workspace's statement and counts toward its limit.", |
| 257 | 257 | <Field label="And at least, $"> | |
| 258 | 258 | <Input name="anomalyFloor" inputMode="decimal" defaultValue={dollarsField(report.settings.anomalyFloorMicros)} /> | |
| 259 | 259 | </Field> | |
| 260 | − | <Field label="Card fee on AI credit bought by card"> | |
| 260 | + | <Field label="Card fee on card payments"> | |
| 261 | 261 | <label className="flex h-[38px] items-center gap-2 text-sm text-fg-soft"> | |
| 262 | − | <input type="checkbox" name="cardFee" defaultChecked={report.settings.cardFee ?? true} className="accent-[var(--g1t-merged)]" /> Passed on as its own line | |
| 262 | + | <input type="checkbox" name="cardFee" defaultChecked={report.settings.cardFee ?? true} className="accent-[var(--g1t-merged)]" /> Its own line; never on invoiced billing or bank transfers | |
| 263 | 263 | </label> | |
| 264 | 264 | </Field> | |
| 265 | 265 | <div className="sm:col-span-2 lg:col-span-4"> |
| 353 | 353 | Every credit | |
| 354 | 354 | </Link> | |
| 355 | 355 | </p> | |
| 356 | + | <dl className="mt-3 grid grid-cols-2 gap-3 rounded-lg border border-line bg-surface px-4 py-3 text-sm sm:px-5"> | |
| 357 | + | <div> | |
| 358 | + | <dt className="text-xs text-muted">Tax collected</dt> | |
| 359 | + | <dd className="tabular text-fg">{usd(o.taxCollectedMicros ?? 0, { cents: true })}</dd> | |
| 360 | + | <dd className="text-xs text-faint">Owed to tax authorities; never in money in or margin. Filed from Stripe Tax.</dd> | |
| 361 | + | </div> | |
| 362 | + | <div> | |
| 363 | + | <dt className="text-xs text-muted">Card fees passed on</dt> | |
| 364 | + | <dd className="tabular text-fg">{usd(o.cardFeesMicros ?? 0, { cents: true })}</dd> | |
| 365 | + | <dd className="text-xs text-faint">Paid Stripe's card fees; not revenue either.</dd> | |
| 366 | + | </div> | |
| 367 | + | </dl> | |
| 356 | 368 | </> | |
| 357 | 369 | ); | |
| 358 | 370 | } |
| 1 | − | import { ArrowLeft, ExternalLink, Mail, Plus, Send, Trash2 } from "lucide-react"; | |
| 1 | + | import { ArrowLeft, ExternalLink, Mail, MapPin, Plus, Send, Trash2 } from "lucide-react"; | |
| 2 | 2 | import { data, Link, redirect, useLocation } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import { type AdminOwner, type EnterpriseInvoice, type Limit, httpStatus } from "@g1t/contracts"; | |
| 157 | 157 | sent={sent} | |
| 158 | 158 | pathname={pathname} | |
| 159 | 159 | error={error("invoices")} | |
| 160 | + | addressError={error("address")} | |
| 160 | 161 | /> | |
| 161 | 162 | <TermsForm terms={account.terms} pathname={pathname} error={error("terms")} /> | |
| 162 | 163 | <AllowancesForm | |
| 306 | 307 | sent, | |
| 307 | 308 | pathname, | |
| 308 | 309 | error, | |
| 310 | + | addressError, | |
| 309 | 311 | }: { | |
| 310 | 312 | email: string | null; | |
| 311 | 313 | invoices: EnterpriseInvoice[]; | |
| 312 | 314 | sent: EnterpriseInvoice | null; | |
| 313 | 315 | pathname: string; | |
| 314 | 316 | error: SectionError; | |
| 317 | + | addressError: SectionError; | |
| 315 | 318 | }) { | |
| 316 | 319 | return ( | |
| 317 | 320 | <Section id="invoices" title="Invoices" description="One Stripe invoice for every workspace it pays for, net 30, emailed by Stripe."> | |
| 334 | 337 | </Button> | |
| 335 | 338 | </form> | |
| 336 | 339 | ||
| 340 | + | <form method="post" action={`${pathname}#invoices`} className="space-y-2 border-t border-line pt-4"> | |
| 341 | + | <input type="hidden" name="intent" value="billing-address" /> | |
| 342 | + | <p className="text-sm text-muted"> | |
| 343 | + | Billing address, saved on its Stripe customer. Stripe Tax works its invoices' tax out from it, so no invoice goes out without | |
| 344 | + | one. Leave the tax ID blank to keep the one there is. | |
| 345 | + | </p> | |
| 346 | + | {addressError && <Notice tone="error">{addressError.error}</Notice>} | |
| 347 | + | <div className="grid gap-2 sm:grid-cols-2"> | |
| 348 | + | <Field label="Street"> | |
| 349 | + | <Input name="line1" defaultValue={addressError?.values?.line1 ?? ""} autoComplete="off" /> | |
| 350 | + | </Field> | |
| 351 | + | <Field label="Suite, floor"> | |
| 352 | + | <Input name="line2" defaultValue={addressError?.values?.line2 ?? ""} autoComplete="off" /> | |
| 353 | + | </Field> | |
| 354 | + | <Field label="City"> | |
| 355 | + | <Input name="city" defaultValue={addressError?.values?.city ?? ""} autoComplete="off" /> | |
| 356 | + | </Field> | |
| 357 | + | <Field label="State or region"> | |
| 358 | + | <Input name="state" defaultValue={addressError?.values?.state ?? ""} autoComplete="off" /> | |
| 359 | + | </Field> | |
| 360 | + | <Field label="Postal code" hint="Needed in the US"> | |
| 361 | + | <Input name="postalCode" defaultValue={addressError?.values?.postalCode ?? ""} autoComplete="off" /> | |
| 362 | + | </Field> | |
| 363 | + | <Field label="Country" hint="Two letters, such as US"> | |
| 364 | + | <Input name="country" required maxLength={2} defaultValue={addressError?.values?.country ?? ""} className="uppercase" autoComplete="off" /> | |
| 365 | + | </Field> | |
| 366 | + | <Field label="Tax ID kind" hint="Stripe's name, such as eu_vat or us_ein"> | |
| 367 | + | <Input name="taxIdType" defaultValue={addressError?.values?.taxIdType ?? ""} autoComplete="off" /> | |
| 368 | + | </Field> | |
| 369 | + | <Field label="Tax ID"> | |
| 370 | + | <Input name="taxId" defaultValue={addressError?.values?.taxId ?? ""} autoComplete="off" /> | |
| 371 | + | </Field> | |
| 372 | + | </div> | |
| 373 | + | <Button type="submit" variant="quiet"> | |
| 374 | + | <MapPin size={14} /> | |
| 375 | + | Save address | |
| 376 | + | </Button> | |
| 377 | + | </form> | |
| 378 | + | ||
| 337 | 379 | <form method="post" action={`${pathname}#review`} className="flex flex-col gap-2 border-t border-line pt-4 sm:flex-row sm:items-center sm:justify-between"> | |
| 338 | 380 | <input type="hidden" name="intent" value="invoice" /> | |
| 339 | 381 | <p className="text-sm text-muted">An invoice goes out on its own when each month closes. Send one now for what it owes so far.</p> |
| 7 | 7 | * an `intent`. | |
| 8 | 8 | */ | |
| 9 | 9 | import { ArrowUpRight, Bell, Bot, CreditCard, FileText, Mail, Plus, ReceiptText, Sparkles } from "lucide-react"; | |
| 10 | − | import type { ReactNode } from "react"; | |
| 10 | + | import { type ReactNode, useState } from "react"; | |
| 11 | 11 | import { Form, Link } from "react-router"; | |
| 12 | 12 | ||
| 13 | 13 | import type { AiCredit, BillingDetails, FeatureState, Limit, UsageReport } from "@g1t/contracts"; | |
| 14 | 14 | ||
| 15 | − | import { type PlanStatus, dollars, wholeDollars } from "../lib/billing"; | |
| 15 | + | import { PLUS_TAX, type PlanStatus, cardFeeCents, dollars, feeAndTax, wholeDollars } from "../lib/billing"; | |
| 16 | 16 | import { money } from "../lib/usage"; | |
| 17 | 17 | import { Card } from "./billing"; | |
| 18 | 18 | import { ErrorText, SubmitButton } from "./ui"; | |
| 102 | 102 | <span className="text-2xl font-semibold tabular-nums tracking-tight">${((plan?.monthlyCents ?? 2000) / 100).toFixed(0)}</span> | |
| 103 | 103 | <span className="text-sm text-muted"> / month</span> | |
| 104 | 104 | <span className="block text-xs text-faint">with {wholeDollars(report?.included?.of ?? 10_000_000)} of usage included</span> | |
| 105 | + | {status.kind !== "comped" && status.kind !== "enterprise" && ( | |
| 106 | + | <span className="block text-xs text-faint"> | |
| 107 | + | {plan?.cardFeeCents ? `+ ${dollars(plan.cardFeeCents * 10_000)} card processing fee, ` : ""} | |
| 108 | + | {plan?.cardFeeCents ? PLUS_TAX : "Plus tax where it applies"} | |
| 109 | + | </span> | |
| 110 | + | )} | |
| 105 | 111 | </p> | |
| 106 | 112 | } | |
| 107 | 113 | > | |
| 203 | 209 | export function AiCreditCard({ credit, owner, enabled, staff, error }: { credit: AiCredit; owner: boolean; enabled: boolean; staff: boolean; error?: string }) { | |
| 204 | 210 | const rate = credit.agentRateMicros; | |
| 205 | 211 | const fee = credit.cardFee; | |
| 206 | − | const feeText = fee.on ? `Stripe's card fee (${(fee.percentMicros / 10_000).toFixed(1)}% + ${dollars(fee.fixedCents * 10_000)}) is its own line at checkout.` : "No card fee."; | |
| 212 | + | // What is chosen, so the fee shows before Stripe's page does. | |
| 213 | + | const [chosenCents, setChosenCents] = useState(2_500); | |
| 214 | + | const chosenFee = cardFeeCents(chosenCents, fee); | |
| 215 | + | const stripeFee = `Stripe's ${(fee.percentMicros / 10_000).toFixed(1)}% + ${dollars(fee.fixedCents * 10_000)}`; | |
| 216 | + | const feeText = !fee.on | |
| 217 | + | ? `${feeAndTax(0)}.` | |
| 218 | + | : chosenCents > 0 | |
| 219 | + | ? `On $${(chosenCents / 100).toLocaleString("en-US")}: ${feeAndTax(chosenFee).replace(/^C/, "c")} (the fee is ${stripeFee}, its own line at checkout).` | |
| 220 | + | : `A card processing fee (${stripeFee}) is its own line at checkout, ${PLUS_TAX}.`; | |
| 221 | + | const choose = (form: HTMLFormElement) => { | |
| 222 | + | const data = new FormData(form); | |
| 223 | + | const amount = String(data.get("amount") ?? ""); | |
| 224 | + | const dollarsChosen = amount === "custom" ? Number(String(data.get("custom") ?? "").replace(/[$,\s]/g, "")) : Number(amount); | |
| 225 | + | setChosenCents(Number.isFinite(dollarsChosen) && dollarsChosen > 0 ? Math.round(dollarsChosen * 100) : 0); | |
| 226 | + | }; | |
| 207 | 227 | return ( | |
| 208 | 228 | <Card | |
| 209 | 229 | id="ai-credit" | |
| 235 | 255 | {!credit.freeViaDiscount && !credit.postpaid && ( | |
| 236 | 256 | <> | |
| 237 | 257 | {credit.canBuy && owner && enabled ? ( | |
| 238 | − | <Form method="post" className="mt-4"> | |
| 258 | + | <Form method="post" className="mt-4" onChange={(event) => choose(event.currentTarget)}> | |
| 239 | 259 | <input type="hidden" name="intent" value="buy-ai-credit" /> | |
| 240 | 260 | <fieldset className="flex flex-wrap items-center gap-2"> | |
| 241 | 261 | <legend className="mb-2 text-xs text-muted">Buy AI credit</legend> | |
| 453 | 473 | rows.push({ | |
| 454 | 474 | key: "security", | |
| 455 | 475 | name: security.plan.title, | |
| 456 | − | about: "Custom patterns, validity checks, code scanning and dependency review on private repositories.", | |
| 476 | + | about: `Custom patterns, validity checks, code scanning and dependency review on private repositories. ${ | |
| 477 | + | security.plan.cardFeeCents ? `Plus a ${dollars(security.plan.cardFeeCents * 10_000)} card processing fee a month, and tax where it applies.` : "Plus tax where it applies." | |
| 478 | + | }`, | |
| 457 | 479 | price: `$${(security.plan.monthlyCents / 100).toFixed(security.plan.monthlyCents % 100 ? 2 : 0)} / month`, | |
| 458 | 480 | on: security.on, | |
| 459 | 481 | label: security.included ? "Included" : status === "canceling" ? "Ends at the period's end" : security.on ? "On" : "Off", | |
| 532 | 554 | ["za_vat", "South African VAT"], | |
| 533 | 555 | ]; | |
| 534 | 556 | ||
| 557 | + | /** What Stripe's check of a tax ID found. */ | |
| 558 | + | const TAX_ID_STATUS: Record<string, string> = { | |
| 559 | + | verified: "Verified by Stripe.", | |
| 560 | + | pending: "Stripe is checking it.", | |
| 561 | + | unverified: "Stripe could not verify it. Check the number.", | |
| 562 | + | unavailable: "Stripe cannot check this kind of ID.", | |
| 563 | + | }; | |
| 564 | + | ||
| 535 | 565 | const LANGUAGES: [string, string][] = [ | |
| 536 | 566 | ["", "Automatic"], | |
| 537 | 567 | ["en", "English"], | |
| 550 | 580 | const a = details?.address; | |
| 551 | 581 | const disabled = !owner || !enabled; | |
| 552 | 582 | return ( | |
| 553 | − | <Card id="details" icon={<ReceiptText size={16} />} title="Invoice details" about="Who invoices are for. Kept on the workspace's Stripe customer and printed on every invoice."> | |
| 583 | + | <Card | |
| 584 | + | id="details" | |
| 585 | + | icon={<ReceiptText size={16} />} | |
| 586 | + | title="Invoice details" | |
| 587 | + | about="Who invoices are for. Kept on the workspace's Stripe customer and printed on every invoice. Tax is worked out from the address." | |
| 588 | + | > | |
| 589 | + | {details?.customer && !details.taxLocation && ( | |
| 590 | + | <p className={`mt-3 rounded-lg border px-3 py-2 text-sm ${details.taxAddressNeededAt ? "border-warn/40 bg-warn/5" : "border-line bg-bg/40 text-muted"}`}> | |
| 591 | + | {details.taxAddressNeededAt | |
| 592 | + | ? "Add the billing address: Stripe needs at least the country (and in the US the ZIP code) to work out tax, so g1t is not charging the card until it is here." | |
| 593 | + | : "No billing address yet. Stripe needs at least the country (and in the US the ZIP code) to work out tax before g1t charges the card."} | |
| 594 | + | </p> | |
| 595 | + | )} | |
| 596 | + | {(details?.taxExempt === "exempt" || details?.taxExempt === "reverse") && ( | |
| 597 | + | <p className="mt-3 text-xs text-muted"> | |
| 598 | + | {details.taxExempt === "exempt" ? "Tax exempt: no tax is added." : "Reverse charge: you account for the tax yourself, and invoices say so."} | |
| 599 | + | </p> | |
| 600 | + | )} | |
| 554 | 601 | <Form method="post" className="mt-4 grid gap-3 text-sm sm:grid-cols-2"> | |
| 555 | 602 | <input type="hidden" name="intent" value="details" /> | |
| 556 | 603 | <fieldset disabled={disabled} className="contents"> | |
| 585 | 632 | </select> | |
| 586 | 633 | <input name="taxId" defaultValue={details?.taxId ?? ""} aria-label="Tax ID" className={FIELD} /> | |
| 587 | 634 | </span> | |
| 635 | + | {details?.taxId && details.taxIdStatus && ( | |
| 636 | + | <span className="mt-1 block text-xs text-faint">{TAX_ID_STATUS[details.taxIdStatus] ?? `Stripe's check: ${details.taxIdStatus}`}</span> | |
| 637 | + | )} | |
| 588 | 638 | </label> | |
| 589 | 639 | <label className="block"> | |
| 590 | 640 | <span className="text-xs text-muted">Purchase order</span> |
| 13 | 13 | import { | |
| 14 | 14 | CAPS, | |
| 15 | 15 | CREDIT_KIND, | |
| 16 | + | PLUS_TAX, | |
| 16 | 17 | PREPAY, | |
| 17 | 18 | type PlanStatus, | |
| 18 | 19 | alertText, | |
| 19 | 20 | alertTone, | |
| 21 | + | cardFeeCents, | |
| 20 | 22 | creditLine, | |
| 21 | 23 | dollars, | |
| 22 | 24 | gigabytes, | |
| 232 | 234 | <span className="text-2xl font-semibold tabular-nums tracking-tight">${((plan?.monthlyCents ?? 2000) / 100).toFixed(0)}</span> | |
| 233 | 235 | <span className="text-sm text-muted"> / month</span> | |
| 234 | 236 | <span className="block text-xs text-faint">per workspace, never per seat</span> | |
| 237 | + | <span className="block text-xs text-faint"> | |
| 238 | + | {plan?.cardFeeCents ? `+ ${dollars(plan.cardFeeCents * 10_000)} card processing fee, ${PLUS_TAX}` : "Plus tax where it applies"} | |
| 239 | + | </span> | |
| 235 | 240 | </p> | |
| 236 | 241 | } | |
| 237 | 242 | > | |
| 644 | 649 | ); | |
| 645 | 650 | } | |
| 646 | 651 | ||
| 647 | − | export function PrepayCard({ prepaidMicros, owner, live, error }: { prepaidMicros: number; owner: boolean; live: boolean; error?: string }) { | |
| 652 | + | export function PrepayCard({ | |
| 653 | + | prepaidMicros, | |
| 654 | + | owner, | |
| 655 | + | live, | |
| 656 | + | cardFee, | |
| 657 | + | error, | |
| 658 | + | }: { | |
| 659 | + | prepaidMicros: number; | |
| 660 | + | owner: boolean; | |
| 661 | + | live: boolean; | |
| 662 | + | /** The card fee as billing charges it, to show it before paying. */ | |
| 663 | + | cardFee?: { on: boolean; percentMicros: number; fixedCents: number } | null; | |
| 664 | + | error?: string; | |
| 665 | + | }) { | |
| 666 | + | const fees = PREPAY.presets.map((amount) => `${dollars(cardFeeCents(amount * 100, cardFee) * 10_000)} on ${wholeDollars(amount * 1_000_000)}`); | |
| 648 | 667 | return ( | |
| 649 | 668 | <Card | |
| 650 | 669 | id="prepay" | |
| 685 | 704 | By bank transfer (from {wholeDollars(PREPAY.bankFrom * 1_000_000)}; counted when it arrives) | |
| 686 | 705 | </label> | |
| 687 | 706 | </RadioGroup> | |
| 707 | + | <p className="text-xs text-faint"> | |
| 708 | + | {cardFee?.on ? `By card, a card processing fee is its own line (${fees.join(", ")}); a bank transfer has none. ` : ""} | |
| 709 | + | Tax is added where it applies, from your billing address; what you prepay is credited in full. | |
| 710 | + | </p> | |
| 688 | 711 | {!live && <p className="text-xs text-faint">Test mode: card 4242 4242 4242 4242, any future date and code.</p>} | |
| 689 | 712 | <ErrorText>{error}</ErrorText> | |
| 690 | 713 | </Form> |
| 1 | + | /** | |
| 2 | + | * What a free workspace may not do, and the way forward: a free workspace | |
| 3 | + | * adds no one (members, invites, outside collaborators) until it starts the | |
| 4 | + | * plan, and a person owns at most one free workspace. Identity refuses both | |
| 5 | + | * either way; these say so before anyone tries. | |
| 6 | + | */ | |
| 7 | + | import { Sparkles } from "lucide-react"; | |
| 8 | + | ||
| 9 | + | import { ButtonLink } from "./ui"; | |
| 10 | + | ||
| 11 | + | /** Where a workspace's plan is started. */ | |
| 12 | + | export function planHref(workspace: string): string { | |
| 13 | + | return `/${workspace}/-/billing#plan`; | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | /** "Start the plan to invite people", with the button, for a free workspace's owners. */ | |
| 17 | + | export function StartPlanToInvite({ | |
| 18 | + | workspace, | |
| 19 | + | owner, | |
| 20 | + | outside = false, | |
| 21 | + | }: { | |
| 22 | + | workspace: string; | |
| 23 | + | owner: boolean; | |
| 24 | + | /** On a repository's Access page: members can still be given roles. */ | |
| 25 | + | outside?: boolean; | |
| 26 | + | }) { | |
| 27 | + | return ( | |
| 28 | + | <div role="note" className="rounded-xl border border-accent/30 bg-accent/5 p-4"> | |
| 29 | + | <div className="flex flex-col gap-3 sm:flex-row sm:items-center"> | |
| 30 | + | <Sparkles size={16} className="hidden shrink-0 text-accent sm:block" aria-hidden /> | |
| 31 | + | <div className="min-w-0 grow"> | |
| 32 | + | <p className="text-sm font-medium">Start the plan to invite people</p> | |
| 33 | + | <p className="mt-1 text-sm text-muted"> | |
| 34 | + | {workspace} is a free workspace, so it cannot add people | |
| 35 | + | {outside ? " from outside it. Its members can still be given a role here." : ". Its members stay as they are."}{" "} | |
| 36 | + | {owner ? "The plan is for everyone in the workspace at one price, never per person." : "An owner can start it."} | |
| 37 | + | </p> | |
| 38 | + | </div> | |
| 39 | + | {owner && ( | |
| 40 | + | <span className="shrink-0"> | |
| 41 | + | <ButtonLink to={planHref(workspace)}>Start the plan</ButtonLink> | |
| 42 | + | </span> | |
| 43 | + | )} | |
| 44 | + | </div> | |
| 45 | + | </div> | |
| 46 | + | ); | |
| 47 | + | } |
| 141 | 141 | </dl> | |
| 142 | 142 | )} | |
| 143 | 143 | ||
| 144 | + | {((totals.taxMicros ?? 0) !== 0 || (totals.cardFeeMicros ?? 0) !== 0) && ( | |
| 145 | + | <p className="mt-2 text-xs text-faint"> | |
| 146 | + | Paid with this month's payments on top of what they credited: tax {dollars(totals.taxMicros ?? 0)}, card processing fees{" "} | |
| 147 | + | {dollars(totals.cardFeeMicros ?? 0)}. Prices exclude tax; neither comes from your balance. | |
| 148 | + | </p> | |
| 149 | + | )} | |
| 144 | 150 | {((totals.covered?.length ?? 0) > 0 || (totals.carriedMicros ?? 0) > 0) && ( | |
| 145 | 151 | <ul className="mt-2 space-y-1 rounded-xl border border-line bg-surface px-4 py-3 text-sm"> | |
| 146 | 152 | {totals.covered?.map((paid) => ( | |
| 174 | 180 | <span className="font-mono tabular-nums text-faint">{charge(g.chargedMicros)}</span> | |
| 175 | 181 | </div> | |
| 176 | 182 | <ul className="divide-y divide-line"> | |
| 177 | − | {g.lines.map((line) => ( | |
| 183 | + | {g.lines.map((line) => | |
| 184 | + | line.kind === "Tax" || line.kind === "Card processing fees" ? ( | |
| 185 | + | // Paid with the day's payments, on top of what reached the balance: never charged. | |
| 186 | + | <li key={line.kind} className="flex items-center gap-3 px-4 py-3 text-sm"> | |
| 187 | + | <span className="w-3.5 shrink-0" /> | |
| 188 | + | <span className="grow truncate text-muted"> | |
| 189 | + | {line.kind} <span className="text-xs text-faint">· paid with the payment, not from the balance</span> | |
| 190 | + | </span> | |
| 191 | + | <span className="w-24 shrink-0 text-right font-mono tabular-nums text-muted">{dollars(line.passedMicros ?? 0)}</span> | |
| 192 | + | </li> | |
| 193 | + | ) : ( | |
| 178 | 194 | <StatementLineRow | |
| 179 | 195 | key={line.kind} | |
| 180 | 196 | slug={slug} | |
| 186 | 202 | day={group === "day" ? g.key : null} | |
| 187 | 203 | project={group === "project" ? g.key : null} | |
| 188 | 204 | /> | |
| 189 | − | ))} | |
| 205 | + | ), | |
| 206 | + | )} | |
| 190 | 207 | {(g.discountMicros ?? 0) > 0 && ( | |
| 191 | 208 | <li className="flex items-center gap-3 px-4 py-3 text-sm"> | |
| 192 | 209 | <span className="w-3.5 shrink-0" /> |
| 8 | 8 | alertText, | |
| 9 | 9 | alertTone, | |
| 10 | 10 | cardCheckResult, | |
| 11 | + | cardFeeCents, | |
| 12 | + | feeAndTax, | |
| 11 | 13 | creditLine, | |
| 12 | 14 | dollars, | |
| 13 | 15 | needsAttention, | |
| 35 | 37 | assert.equal(creditLine({ ...grant, state: "revoked", leftMicros: 0 }, now), "$25.00 credit, withdrawn"); | |
| 36 | 38 | }); | |
| 37 | 39 | ||
| 40 | + | test("the card fee shown before paying is the one billing charges, and prices exclude tax", () => { | |
| 41 | + | const fee = { on: true, percentMicros: 29_000, fixedCents: 30 }; | |
| 42 | + | // The same figures as billing's own tests (ai.rs, tax.rs). | |
| 43 | + | assert.equal(cardFeeCents(2_500, fee), 106); | |
| 44 | + | assert.equal(cardFeeCents(2_000, fee), 91); | |
| 45 | + | assert.equal(cardFeeCents(2_000, { ...fee, on: false }), 0); | |
| 46 | + | assert.equal(cardFeeCents(0, fee), 0); | |
| 47 | + | assert.equal(cardFeeCents(2_000, null), 0); | |
| 48 | + | assert.equal(feeAndTax(59), "Card processing fee $0.59, plus tax where it applies"); | |
| 49 | + | assert.equal(feeAndTax(0), "Plus tax where it applies"); | |
| 50 | + | }); | |
| 51 | + | ||
| 38 | 52 | test("money reads as dollars", () => { | |
| 39 | 53 | assert.equal(dollars(9_500_000), "$9.50"); | |
| 40 | 54 | assert.equal(dollars(-1_250_000), "−$1.25"); | |
| 305 | 319 | assert.equal(parsed.value.address.country, "DE"); | |
| 306 | 320 | form.set("taxId", ""); | |
| 307 | 321 | assert.equal(parseInvoiceDetails(form).ok, false); | |
| 322 | + | // Tax is worked out from the address: in the US, the ZIP code is needed. | |
| 323 | + | form.set("taxIdType", ""); | |
| 324 | + | form.set("country", "us"); | |
| 325 | + | assert.equal(parseInvoiceDetails(form).ok, false); | |
| 326 | + | form.set("postalCode", "94107"); | |
| 327 | + | assert.ok(parseInvoiceDetails(form).ok); | |
| 308 | 328 | }); |
| 23 | 23 | return `${sign}$${abs.toLocaleString("en-US", { minimumFractionDigits: digits, maximumFractionDigits: digits })}`; | |
| 24 | 24 | } | |
| 25 | 25 | ||
| 26 | + | /** Prices on g1t exclude tax; Stripe adds it at checkout from the billing address. */ | |
| 27 | + | export const PLUS_TAX = "plus tax where it applies"; | |
| 28 | + | ||
| 29 | + | /** | |
| 30 | + | * The card processing fee on a card payment of `cents`, as billing works it | |
| 31 | + | * out (`ai::card_fee_cents`): Stripe's percent and fixed fee grossed up, so | |
| 32 | + | * what is left after Stripe's fee is the amount, rounded up to the cent. | |
| 33 | + | * 0 when the fee is off. | |
| 34 | + | */ | |
| 35 | + | export function cardFeeCents(cents: number, fee: { on: boolean; percentMicros: number; fixedCents: number } | null | undefined): number { | |
| 36 | + | if (!fee?.on || !(cents > 0)) return 0; | |
| 37 | + | const rate = fee.percentMicros / MICROS_PER_DOLLAR; | |
| 38 | + | if (!(rate >= 0 && rate < 0.5)) return 0; | |
| 39 | + | return Math.max(0, Math.ceil((cents + fee.fixedCents) / (1 - rate)) - cents); | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | /** "Card processing fee $1.06, plus tax where it applies", as shown before paying. */ | |
| 43 | + | export function feeAndTax(feeCents: number): string { | |
| 44 | + | return feeCents > 0 ? `Card processing fee ${dollars(feeCents * 10_000)}, ${PLUS_TAX}` : `Plus tax where it applies`; | |
| 45 | + | } | |
| 46 | + | ||
| 26 | 47 | /** A form's dollar amount as micros, or null when it is empty or not a number. */ | |
| 27 | 48 | export function readDollars(value: FormDataEntryValue | null | undefined): number | null { | |
| 28 | 49 | const text = String(value ?? "").replace(/[$,\s]/g, ""); | |
| 428 | 449 | if (email && !/^[^\s@]+@[^\s@]+$/.test(email)) return { ok: false, error: "That is not an email address." }; | |
| 429 | 450 | const country = text("country").toUpperCase(); | |
| 430 | 451 | if (country && !/^[A-Z]{2}$/.test(country)) return { ok: false, error: "The country is two letters, such as US or DE." }; | |
| 452 | + | // Stripe Tax places a US customer by ZIP code: without it, tax cannot be worked out. | |
| 453 | + | if (country === "US" && !text("postalCode")) return { ok: false, error: "Add the ZIP code: in the US, tax is worked out from it." }; | |
| 431 | 454 | const taxIdType = text("taxIdType"); | |
| 432 | 455 | const taxId = text("taxId"); | |
| 433 | 456 | if (Boolean(taxIdType) !== Boolean(taxId)) return { ok: false, error: "Give the tax ID's kind and its number together." }; |
| 92 | 92 | }, | |
| 93 | 93 | { | |
| 94 | 94 | title: "No seats, ever", | |
| 95 | − | body: "Add as many people and agents as you like. A workspace pays one flat price for the plan, and for what it uses past what the plan includes.", | |
| 95 | + | body: "Once a workspace is on the plan, add as many people and agents as you like. It pays one flat price for the plan, and for what it uses past what the plan includes.", | |
| 96 | + | }, | |
| 97 | + | { | |
| 98 | + | title: "Prices exclude tax", | |
| 99 | + | body: "Every price here is before tax. Stripe adds sales tax, VAT or GST where it applies, worked out from the billing address, and shows it as its own line before you pay and on every receipt and invoice. A valid business tax ID is applied where the law says so.", | |
| 100 | + | }, | |
| 101 | + | { | |
| 102 | + | title: "Card fees are passed on, nothing more", | |
| 103 | + | body: "Paying by card adds Stripe's card processing fee as its own line, shown before you pay, so the 20% is never spent on fees. A bank transfer or invoiced billing has no card fee.", | |
| 96 | 104 | }, | |
| 97 | 105 | ]; | |
| 98 | 106 | ||
| 157 | 165 | note: "Custom patterns, validity checks, code scanning, dependency review and the security overview on private repositories are the Security and quality activation. On public repositories they are free.", | |
| 158 | 166 | }, | |
| 159 | 167 | { what: "Single sign-on", free: "On every plan, once it is built", plan: "On every plan, once it is built" }, | |
| 160 | − | { what: "Members", free: "Unlimited", plan: "Unlimited" }, | |
| 161 | 168 | { | |
| 169 | + | what: "Members", | |
| 170 | + | free: "Only the people already in it: a free workspace cannot add members, invite people or invite outside collaborators", | |
| 171 | + | plan: "Unlimited, never per seat", | |
| 172 | + | note: "Each person can own one free workspace. More workspaces need the plan.", | |
| 173 | + | }, | |
| 174 | + | { | |
| 162 | 175 | what: "Usage past what is included", | |
| 163 | 176 | free: "Not possible: a free workspace never runs up a bill", | |
| 164 | 177 | plan: "Charged at cost plus 20%, up to a spend limit you set. No quotas: only your limit stops anything.", | |
| 182 | 195 | <h1 className="mt-2 text-3xl font-semibold tracking-tight sm:text-4xl">One plan, and usage at cost plus 20%</h1> | |
| 183 | 196 | <p className="mt-3 max-w-2xl text-muted"> | |
| 184 | 197 | The forge is free for everyone. Work that runs on g1t's machines is metered at what it costs g1t, plus 20%. The | |
| 185 | − | numbers on this page are the live price book g1t charges from. | |
| 198 | + | numbers on this page are the live price book g1t charges from. Prices exclude tax, which is added where it applies. | |
| 186 | 199 | </p> | |
| 187 | 200 | {free && ( | |
| 188 | 201 | <div className="mt-5 rounded-xl border border-accent/30 bg-accent/5 px-4 py-3 text-sm"> | |
| 197 | 210 | <p> | |
| 198 | 211 | <span className="text-3xl font-semibold tabular-nums">${price}</span>{" "} | |
| 199 | 212 | <span className="text-sm text-muted">a month per workspace</span> | |
| 213 | + | <span className="block text-right text-xs text-faint"> | |
| 214 | + | {plan.cardFeeCents ? `+ $${(plan.cardFeeCents / 100).toFixed(2)} card processing fee, plus tax where it applies` : "Plus tax where it applies"} | |
| 215 | + | </span> | |
| 200 | 216 | </p> | |
| 201 | 217 | </div> | |
| 202 | 218 | <ul className="mt-4 space-y-1.5 text-sm text-muted"> | |
| 237 | 253 | <p> | |
| 238 | 254 | <span className="text-3xl font-semibold tabular-nums">${(securityPlan.monthlyCents / 100).toFixed(securityPlan.monthlyCents % 100 ? 2 : 0)}</span>{" "} | |
| 239 | 255 | <span className="text-sm text-muted">a month per workspace</span> | |
| 256 | + | <span className="block text-right text-xs text-faint"> | |
| 257 | + | {securityPlan.cardFeeCents | |
| 258 | + | ? `+ $${(securityPlan.cardFeeCents / 100).toFixed(2)} card processing fee, plus tax where it applies` | |
| 259 | + | : "Plus tax where it applies"} | |
| 260 | + | </span> | |
| 240 | 261 | </p> | |
| 241 | 262 | </div> | |
| 242 | 263 | <p className="mt-2 text-sm text-muted"> | |
| 490 | 511 | <tr> | |
| 491 | 512 | <td className="px-4 py-3"> | |
| 492 | 513 | <p className="font-medium">Card processing fee</p> | |
| 493 | − | <p className="text-xs text-faint">On AI credit bought by card, as its own line at checkout; never on invoices</p> | |
| 514 | + | <p className="text-xs text-faint"> | |
| 515 | + | On every card payment, as its own line shown before you pay; never on bank transfers or invoiced billing | |
| 516 | + | </p> | |
| 494 | 517 | </td> | |
| 495 | 518 | <td className="px-4 py-3 text-muted">Stripe's fee</td> | |
| 496 | 519 | <td className="hidden px-4 py-3 tabular-nums sm:table-cell">—</td> |
| 17 | 17 | import type { Route } from "./+types/settings-access"; | |
| 18 | 18 | import { RoleSelect, RolesTable } from "../../components/access"; | |
| 19 | 19 | import { RepoSettingsHeading } from "../../components/repo-settings-heading"; | |
| 20 | + | import { StartPlanToInvite } from "../../components/start-plan"; | |
| 20 | 21 | import { SettingsSection as Section } from "../../components/settings-section"; | |
| 21 | 22 | import { Avatar, Button, ErrorText, Field, Input, SubmitButton } from "../../components/ui"; | |
| 22 | 23 | import { Badge } from "../../components/ui/badge"; | |
| 32 | 33 | } from "../../components/ui/alert-dialog"; | |
| 33 | 34 | import { page } from "../../lib/meta"; | |
| 34 | 35 | import { refusal, requireInsider } from "../../lib/access.server"; | |
| 35 | − | import { identity } from "../../lib/services.server"; | |
| 36 | + | import { billing, identity } from "../../lib/services.server"; | |
| 36 | 37 | import { assertSameOrigin, requireUser, roleIn, unwrap } from "../../lib/session.server"; | |
| 37 | 38 | ||
| 38 | 39 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 46 | 47 | const manage = access.can.manage_access; | |
| 47 | 48 | // The workspace's teams, for Add team: only Admins add one, and a | |
| 48 | 49 | // failure there leaves the rest of the page as it is. | |
| 49 | − | const [found, teams] = await Promise.all([ | |
| 50 | + | const [found, teams, free] = await Promise.all([ | |
| 50 | 51 | identity.repoAccess(params.owner, params.repo, viewer), | |
| 51 | 52 | manage ? identity.listTeams(viewer, params.owner).catch(() => null) : Promise.resolve(null), | |
| 53 | + | // A free workspace invites no one from outside; identity refuses it | |
| 54 | + | // either way, so a failure here only hides the note. | |
| 55 | + | manage ? billing.freeWorkspaces([params.owner]).catch(() => [] as string[]) : Promise.resolve([] as string[]), | |
| 52 | 56 | ]); | |
| 53 | 57 | return { | |
| 54 | 58 | access: unwrap(found), | |
| 55 | 59 | teams: teams?.ok ? teams.value : ([] as Team[]), | |
| 56 | 60 | manage, | |
| 61 | + | free: free.includes(params.owner.toLowerCase()), | |
| 57 | 62 | // Owners change the base permission, on the workspace's People page. | |
| 58 | 63 | owner: roleIn(viewer, params.owner) === "owner", | |
| 59 | 64 | }; | |
| 143 | 148 | }; | |
| 144 | 149 | ||
| 145 | 150 | export default function RepoAccessSettings({ loaderData, actionData, params }: Route.ComponentProps) { | |
| 146 | − | const { access, manage, owner, teams } = loaderData; | |
| 151 | + | const { access, manage, owner, teams, free } = loaderData; | |
| 147 | 152 | const base = `/${params.owner}/${params.repo}`; | |
| 148 | 153 | const full = `${params.owner}/${params.repo}`; | |
| 149 | 154 | const pending = access.invitations.filter((invitation) => invitation.status === "pending"); | |
| 195 | 200 | title="Add people" | |
| 196 | 201 | about="A member of the workspace gets the role at once. Anyone else gets an invitation, by email, and has the role once they accept it." | |
| 197 | 202 | > | |
| 203 | + | {free && <StartPlanToInvite workspace={params.owner} owner={owner} outside />} | |
| 198 | 204 | <AddForm result={result} /> | |
| 199 | 205 | </Section> | |
| 200 | 206 | )} |
| 316 | 316 | {notice && <p className="mb-6 rounded-lg border border-accent/30 bg-accent/5 px-4 py-2.5 text-sm">{notice}</p>} | |
| 317 | 317 | {problem && <p className="mb-6 rounded-lg border border-danger/40 bg-danger/5 px-4 py-2.5 text-sm">{problem}</p>} | |
| 318 | 318 | ||
| 319 | + | <Suspense fallback={null}> | |
| 320 | + | <Await resolve={details}> | |
| 321 | + | {(loaded) => | |
| 322 | + | loaded?.taxAddressNeededAt ? ( | |
| 323 | + | <div role="alert" className="mb-6 rounded-xl border border-warn/40 bg-warn/5 p-4 text-sm"> | |
| 324 | + | <p className="font-medium">Add a billing address</p> | |
| 325 | + | <p className="mt-1 text-muted"> | |
| 326 | + | Stripe needs it to work out tax, so g1t did not charge the card. Nothing is lost: the charge goes through once the | |
| 327 | + | address is saved.{" "} | |
| 328 | + | {owner ? ( | |
| 329 | + | <a href="#details" className="text-fg underline underline-offset-2"> | |
| 330 | + | Add it under Invoice details | |
| 331 | + | </a> | |
| 332 | + | ) : ( | |
| 333 | + | "An owner adds it under Invoice details." | |
| 334 | + | )} | |
| 335 | + | </p> | |
| 336 | + | </div> | |
| 337 | + | ) : null | |
| 338 | + | } | |
| 339 | + | </Await> | |
| 340 | + | </Suspense> | |
| 319 | 341 | {entitlements && <SpikeBanner slug={slug} entitlements={entitlements} owner={owner} />} | |
| 320 | 342 | {err("spike") && <p className="mb-6 text-sm text-danger">{err("spike")}</p>} | |
| 321 | 343 | <Alerts alerts={entitlements?.alerts ?? []} /> | |
| 335 | 357 | <SpendLimitCard limit={limit} owner={owner} error={err("limit")} /> | |
| 336 | 358 | <BudgetAlerts limit={limit} owner={owner} error={err("budget")} /> | |
| 337 | 359 | <RaiseCard requests={requests} owner={owner} error={err("raise")} /> | |
| 338 | − | <PrepayCard prepaidMicros={prepaid} owner={owner} live={status.live || !staff} error={err("prepay")} /> | |
| 360 | + | <PrepayCard prepaidMicros={prepaid} owner={owner} live={status.live || !staff} cardFee={ai?.cardFee ?? null} error={err("prepay")} /> | |
| 339 | 361 | </> | |
| 340 | 362 | )} | |
| 341 | 363 | ||
| 395 | 417 | Each month closes with an itemised invoice. No card is charged less than{" "} | |
| 396 | 418 | {dollars(entitlements?.minChargeMicros ?? 5 * MICROS_PER_DOLLAR, 0)}; less carries over. | |
| 397 | 419 | </li> | |
| 398 | − | <li>No seats: add as many people and agents as you like.</li> | |
| 420 | + | <li> | |
| 421 | + | Prices exclude tax. Stripe adds tax where it applies, worked out from the billing address under Invoice details, and it | |
| 422 | + | is its own line on every receipt and invoice. | |
| 423 | + | </li> | |
| 424 | + | <li> | |
| 425 | + | Card payments carry Stripe's card processing fee as their own line, shown before you pay. Bank transfers and invoiced | |
| 426 | + | billing have none. | |
| 427 | + | </li> | |
| 428 | + | <li>No seats: add as many people and agents as you like, once the workspace is on the plan.</li> | |
| 399 | 429 | </ul> | |
| 400 | 430 | <div className="mt-4 space-y-1.5 border-t border-line pt-4 text-sm"> | |
| 401 | 431 | <Link to={`/${slug}/-/usage`} className="flex items-center gap-2 text-muted hover:text-fg"> | |
| 419 | 449 | return ( | |
| 420 | 450 | <section className="mb-10"> | |
| 421 | 451 | <h2 className="font-medium">Usage invoices</h2> | |
| 422 | − | <p className="mt-1 text-sm text-muted">One when each month closes, and one each time g1t charges the card near your limit.</p> | |
| 452 | + | <p className="mt-1 text-sm text-muted"> | |
| 453 | + | One when each month closes, and one each time g1t charges the card near your limit. Amounts are for usage; the card fee | |
| 454 | + | and tax are their own lines. | |
| 455 | + | </p> | |
| 423 | 456 | <ul className="mt-4 divide-y divide-line overflow-hidden rounded-xl border border-line"> | |
| 424 | 457 | {invoices.map((invoice) => ( | |
| 425 | 458 | <li key={invoice.invoiceId} className="px-4 py-3 text-sm"> | |
| 453 | 486 | <span className="font-mono tabular-nums">{dollars(line.amountMicros)}</span> | |
| 454 | 487 | </li> | |
| 455 | 488 | ))} | |
| 489 | + | {(invoice.feeMicros ?? 0) > 0 && ( | |
| 490 | + | <li className="flex justify-between gap-4"> | |
| 491 | + | <span>Card processing fee</span> | |
| 492 | + | <span className="font-mono tabular-nums">{dollars(invoice.feeMicros ?? 0)}</span> | |
| 493 | + | </li> | |
| 494 | + | )} | |
| 495 | + | {(invoice.taxMicros ?? 0) > 0 && ( | |
| 496 | + | <li className="flex justify-between gap-4"> | |
| 497 | + | <span>Tax</span> | |
| 498 | + | <span className="font-mono tabular-nums">{dollars(invoice.taxMicros ?? 0)}</span> | |
| 499 | + | </li> | |
| 500 | + | )} | |
| 456 | 501 | </ul> | |
| 457 | 502 | </li> | |
| 458 | 503 | ))} |
| 1 | − | import { Form, redirect } from "react-router"; | |
| 1 | + | import { Form, Link, redirect } from "react-router"; | |
| 2 | 2 | ||
| 3 | 3 | import type { Route } from "./+types/new"; | |
| 4 | 4 | import { page } from "../../lib/meta"; | |
| 5 | − | import { ErrorText, Field, Input, SubmitButton } from "../../components/ui"; | |
| 6 | − | import { identity } from "../../lib/services.server"; | |
| 5 | + | import { planHref } from "../../components/start-plan"; | |
| 6 | + | import { ButtonLink, ErrorText, Field, Input, SubmitButton } from "../../components/ui"; | |
| 7 | + | import { billing, identity } from "../../lib/services.server"; | |
| 7 | 8 | import { assertSameOrigin, nextPath, requireUser } from "../../lib/session.server"; | |
| 8 | 9 | ||
| 9 | 10 | export function meta(args: Route.MetaArgs) { | |
| 10 | 11 | return page(args, { title: "New workspace · g1t" }); | |
| 11 | 12 | } | |
| 12 | 13 | ||
| 13 | − | export function loader({ request, context }: Route.LoaderArgs) { | |
| 14 | + | export async function loader({ request, context }: Route.LoaderArgs) { | |
| 14 | 15 | const user = requireUser(context, request); | |
| 15 | − | return { user, first: (user.workspaces ?? []).length === 0 }; | |
| 16 | + | const owned = (user.workspaces ?? []).filter((membership) => membership.role === "owner").map((membership) => membership.slug); | |
| 17 | + | // A person owns at most one free workspace; identity refuses a second | |
| 18 | + | // either way, so a failure here only leaves the form up. | |
| 19 | + | const free = owned.length > 0 ? await billing.freeWorkspaces(owned).catch(() => [] as string[]) : []; | |
| 20 | + | return { user, first: (user.workspaces ?? []).length === 0, free }; | |
| 16 | 21 | } | |
| 17 | 22 | ||
| 18 | 23 | export async function action({ request, context }: Route.ActionArgs) { | |
| 34 | 39 | loaderData, | |
| 35 | 40 | actionData, | |
| 36 | 41 | }: Route.ComponentProps) { | |
| 37 | − | const { user, first } = loaderData; | |
| 42 | + | const { user, first, free } = loaderData; | |
| 38 | 43 | return ( | |
| 39 | 44 | <main className="mx-auto max-w-lg px-4 py-12"> | |
| 40 | 45 | <h1 className="text-xl font-semibold"> | |
| 47 | 52 | address: <span className="font-mono text-fg">g1t.sh/workspace/repo</span>. | |
| 48 | 53 | Use one for yourself, and one for each team or company you work with. | |
| 49 | 54 | </p> | |
| 50 | − | {!user.verified && ( | |
| 51 | − | <p className="mt-4 rounded-md border border-warn/30 bg-warn/10 px-3 py-2 text-sm"> | |
| 52 | − | Confirm your email address first. We sent you a link. | |
| 53 | − | </p> | |
| 55 | + | {free.length > 0 ? ( | |
| 56 | + | <OneFreeWorkspace free={free} /> | |
| 57 | + | ) : ( | |
| 58 | + | <> | |
| 59 | + | {!user.verified && ( | |
| 60 | + | <p className="mt-4 rounded-md border border-warn/30 bg-warn/10 px-3 py-2 text-sm"> | |
| 61 | + | Confirm your email address first. We sent you a link. | |
| 62 | + | </p> | |
| 63 | + | )} | |
| 64 | + | <Form method="post" className="mt-8 space-y-4"> | |
| 65 | + | <Field | |
| 66 | + | label="Name in URLs" | |
| 67 | + | hint="Lowercase letters, digits and single hyphens. An owner can change it later; old addresses redirect for 90 days." | |
| 68 | + | > | |
| 69 | + | <div className="flex items-center gap-2 font-mono text-sm"> | |
| 70 | + | <span className="text-muted">g1t.sh/</span> | |
| 71 | + | <Input | |
| 72 | + | name="slug" | |
| 73 | + | required | |
| 74 | + | autoFocus | |
| 75 | + | maxLength={39} | |
| 76 | + | defaultValue={first ? user.username : ""} | |
| 77 | + | pattern="[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9]))*" | |
| 78 | + | /> | |
| 79 | + | </div> | |
| 80 | + | </Field> | |
| 81 | + | <Field label="Display name (optional)"> | |
| 82 | + | <Input name="displayName" maxLength={80} /> | |
| 83 | + | </Field> | |
| 84 | + | <p className="text-xs text-faint"> | |
| 85 | + | A new workspace is free. Each person can own one free workspace; more need the plan on the ones you have. | |
| 86 | + | </p> | |
| 87 | + | <ErrorText>{actionData?.error}</ErrorText> | |
| 88 | + | <SubmitButton pending="Creating…">Create workspace</SubmitButton> | |
| 89 | + | </Form> | |
| 90 | + | </> | |
| 54 | 91 | )} | |
| 55 | − | <Form method="post" className="mt-8 space-y-4"> | |
| 56 | − | <Field | |
| 57 | − | label="Name in URLs" | |
| 58 | − | hint="Lowercase letters, digits and single hyphens. An owner can change it later; old addresses redirect for 90 days." | |
| 59 | − | > | |
| 60 | − | <div className="flex items-center gap-2 font-mono text-sm"> | |
| 61 | − | <span className="text-muted">g1t.sh/</span> | |
| 62 | − | <Input | |
| 63 | − | name="slug" | |
| 64 | − | required | |
| 65 | − | autoFocus | |
| 66 | − | maxLength={39} | |
| 67 | − | defaultValue={first ? user.username : ""} | |
| 68 | − | pattern="[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9]))*" | |
| 69 | − | /> | |
| 70 | − | </div> | |
| 71 | − | </Field> | |
| 72 | − | <Field label="Display name (optional)"> | |
| 73 | − | <Input name="displayName" maxLength={80} /> | |
| 74 | − | </Field> | |
| 75 | − | <ErrorText>{actionData?.error}</ErrorText> | |
| 76 | − | <SubmitButton pending="Creating…">Create workspace</SubmitButton> | |
| 77 | − | </Form> | |
| 78 | 92 | </main> | |
| 79 | 93 | ); | |
| 80 | 94 | } | |
| 95 | + | ||
| 96 | + | /** Why there is no form: the person owns a free workspace already. */ | |
| 97 | + | function OneFreeWorkspace({ free }: { free: string[] }) { | |
| 98 | + | const [first] = free; | |
| 99 | + | return ( | |
| 100 | + | <section className="mt-8 rounded-xl border border-line bg-surface p-5"> | |
| 101 | + | <h2 className="font-medium">You already own a free workspace</h2> | |
| 102 | + | <p className="mt-2 text-sm text-muted"> | |
| 103 | + | Each person can own one workspace that is not on the g1t plan.{" "} | |
| 104 | + | {free.length === 1 ? ( | |
| 105 | + | <> | |
| 106 | + | Yours is <span className="font-mono text-fg">{first}</span>. | |
| 107 | + | </> | |
| 108 | + | ) : ( | |
| 109 | + | <> | |
| 110 | + | You own {free.length} from before ( | |
| 111 | + | {free.map((slug, i) => ( | |
| 112 | + | <span key={slug}> | |
| 113 | + | {i > 0 && ", "} | |
| 114 | + | <span className="font-mono text-fg">{slug}</span> | |
| 115 | + | </span> | |
| 116 | + | ))} | |
| 117 | + | ), and keep them all. | |
| 118 | + | </> | |
| 119 | + | )}{" "} | |
| 120 | + | A new workspace starts free, so to make one, start the plan on {free.length === 1 ? "it" : "each of them"}, or delete{" "} | |
| 121 | + | {free.length === 1 ? "it" : "the ones"} you no longer use. | |
| 122 | + | </p> | |
| 123 | + | <div className="mt-4 flex flex-wrap items-center gap-3"> | |
| 124 | + | <ButtonLink to={planHref(first)}>Start the plan on {first}</ButtonLink> | |
| 125 | + | <Link to={`/${first}/-/settings`} className="text-sm text-muted hover:text-fg"> | |
| 126 | + | Workspace settings | |
| 127 | + | </Link> | |
| 128 | + | </div> | |
| 129 | + | <p className="mt-4 text-xs text-faint"> | |
| 130 | + | The plan is one price for everyone in the workspace, never per person.{" "} | |
| 131 | + | <Link to="/pricing" className="underline underline-offset-2 hover:text-fg"> | |
| 132 | + | Pricing | |
| 133 | + | </Link> | |
| 134 | + | </p> | |
| 135 | + | </section> | |
| 136 | + | ); | |
| 137 | + | } |
| 17 | 17 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select"; | |
| 18 | 18 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../components/ui/tabs"; | |
| 19 | 19 | import { inviteLink, inviteState, moreInvitesMailto } from "../../lib/invites"; | |
| 20 | − | import { identity } from "../../lib/services.server"; | |
| 20 | + | import { billing, identity } from "../../lib/services.server"; | |
| 21 | + | import { StartPlanToInvite } from "../../components/start-plan"; | |
| 21 | 22 | import { | |
| 22 | 23 | assertSameOrigin, | |
| 23 | 24 | getViewer, | |
| 37 | 38 | // someone a repository is shared with, gets nothing here. | |
| 38 | 39 | if (!role) throw data(null, { status: 404 }); | |
| 39 | 40 | const owner = role === "owner"; | |
| 40 | − | const [members, invites, workspace, outside, teams] = await Promise.all([ | |
| 41 | + | const [members, invites, workspace, outside, teams, free] = await Promise.all([ | |
| 41 | 42 | identity.listMembers(params.owner, viewer), | |
| 42 | 43 | owner ? identity.workspaceInvites(params.owner, viewer).catch(() => null) : null, | |
| 43 | 44 | identity.getWorkspace(params.owner), | |
| 44 | 45 | owner ? identity.outsideCollaborators(viewer, params.owner).catch(() => null) : null, | |
| 45 | 46 | // Each member's teams, as the viewer may see them. | |
| 46 | 47 | identity.teamMemberships(viewer, params.owner).catch(() => null), | |
| 48 | + | // A free workspace adds no one until it starts the plan; identity | |
| 49 | + | // refuses it either way, so a failure here only hides the note. | |
| 50 | + | billing.freeWorkspaces([params.owner]).catch(() => [] as string[]), | |
| 47 | 51 | ]); | |
| 48 | 52 | return { | |
| 49 | 53 | role, | |
| 54 | + | free: free.includes(params.owner.toLowerCase()), | |
| 50 | 55 | members: unwrap(members), | |
| 51 | 56 | invites: invites?.ok ? invites.value : [], | |
| 52 | 57 | base: workspace?.basePermission ?? DEFAULT_BASE_PERMISSION, | |
| 93 | 98 | }; | |
| 94 | 99 | ||
| 95 | 100 | export default function WorkspacePeople({ loaderData, actionData, params }: Route.ComponentProps) { | |
| 96 | − | const { role, members, invites, origin, base, outside, teams } = loaderData; | |
| 101 | + | const { role, members, invites, origin, base, outside, teams, free } = loaderData; | |
| 97 | 102 | const owner = role === "owner"; | |
| 98 | 103 | const pending = invites.filter((invite) => invite.status === "pending"); | |
| 99 | 104 | const [search, setSearch] = useSearchParams(); | |
| 144 | 149 | </li> | |
| 145 | 150 | ))} | |
| 146 | 151 | </ul> | |
| 147 | − | {owner && ( | |
| 152 | + | {owner && free && ( | |
| 153 | + | <div className="mt-6"> | |
| 154 | + | <StartPlanToInvite workspace={params.owner} owner={owner} /> | |
| 155 | + | </div> | |
| 156 | + | )} | |
| 157 | + | {owner && !free && ( | |
| 148 | 158 | // Empty again once the person is on the list; kept as typed when it failed. | |
| 149 | 159 | <Form | |
| 150 | 160 | method="post" | |
| 248 | 258 | <OutsideCollaborators | |
| 249 | 259 | people={outside} | |
| 250 | 260 | slug={params.owner} | |
| 261 | + | free={free} | |
| 251 | 262 | error={actionData && "converting" in actionData && actionData.converting ? actionData.error : null} | |
| 252 | 263 | /> | |
| 253 | 264 | </TabsContent> | |
| 322 | 333 | function OutsideCollaborators({ | |
| 323 | 334 | people, | |
| 324 | 335 | slug, | |
| 336 | + | free, | |
| 325 | 337 | error, | |
| 326 | 338 | }: { | |
| 327 | 339 | people: Route.ComponentProps["loaderData"]["outside"]; | |
| 328 | 340 | slug: string; | |
| 341 | + | free: boolean; | |
| 329 | 342 | error: string | null | undefined; | |
| 330 | 343 | }) { | |
| 331 | 344 | return ( | |
| 334 | 347 | People given a role on one or more of {slug}'s repositories without being members. They see only those | |
| 335 | 348 | repositories. Change or take away a role on the repository's Access settings. | |
| 336 | 349 | </p> | |
| 350 | + | {free && ( | |
| 351 | + | <div className="mb-4"> | |
| 352 | + | <StartPlanToInvite workspace={slug} owner /> | |
| 353 | + | </div> | |
| 354 | + | )} | |
| 337 | 355 | {people.length === 0 ? ( | |
| 338 | 356 | <div className="rounded-xl border border-dashed border-line px-6 py-10 text-center"> | |
| 339 | 357 | <Users size={18} className="mx-auto text-faint" /> | |
| 352 | 370 | </Link> | |
| 353 | 371 | {person.name && <span className="ml-2 text-sm text-muted">{person.name}</span>} | |
| 354 | 372 | </div> | |
| 355 | − | <Form method="post"> | |
| 356 | − | <input type="hidden" name="action" value="convert" /> | |
| 357 | − | <input type="hidden" name="member" value={person.username} /> | |
| 358 | − | <SubmitButton variant="quiet" match={{ action: "convert", member: person.username }} pending="Converting…"> | |
| 359 | − | Convert to member | |
| 360 | − | </SubmitButton> | |
| 361 | − | </Form> | |
| 373 | + | {!free && ( | |
| 374 | + | <Form method="post"> | |
| 375 | + | <input type="hidden" name="action" value="convert" /> | |
| 376 | + | <input type="hidden" name="member" value={person.username} /> | |
| 377 | + | <SubmitButton variant="quiet" match={{ action: "convert", member: person.username }} pending="Converting…"> | |
| 378 | + | Convert to member | |
| 379 | + | </SubmitButton> | |
| 380 | + | </Form> | |
| 381 | + | )} | |
| 362 | 382 | </div> | |
| 363 | 383 | <ul className="mt-2 flex flex-wrap gap-1.5 pl-10"> | |
| 364 | 384 | {person.repos.map((grant) => ( |
| 14 | 14 | const month = new URL(request.url).searchParams.get("month"); | |
| 15 | 15 | const statement = await billing.statement(params.owner, viewer, month, "day"); | |
| 16 | 16 | if (!statement.ok) throw data(null, { status: 404 }); | |
| 17 | − | const kinds = [...new Set(statement.value.groups.flatMap((group) => group.lines.map((line) => line.kind)))]; | |
| 17 | + | // Tax and card fees are paid with payments, not ledger entries: a row a day each. | |
| 18 | + | const passed = new Set(["Tax", "Card processing fees"]); | |
| 19 | + | const kinds = [...new Set(statement.value.groups.flatMap((group) => group.lines.map((line) => line.kind)))].filter((kind) => !passed.has(kind)); | |
| 18 | 20 | const rows: string[][] = [ | |
| 19 | 21 | [ | |
| 20 | 22 | "date", | |
| 47 | 49 | if (entries.value.length < 50) break; | |
| 48 | 50 | } | |
| 49 | 51 | } | |
| 52 | + | for (const group of statement.value.groups) { | |
| 53 | + | for (const line of group.lines.filter((l) => passed.has(l.kind))) { | |
| 54 | + | const amount = ((line.passedMicros ?? 0) / MICROS_PER_DOLLAR).toFixed(6); | |
| 55 | + | rows.push([group.key, line.kind, "Paid with the day's payments, not from the balance", "", "", "", "", "", "", amount, "", "", "", ""]); | |
| 56 | + | } | |
| 57 | + | } | |
| 50 | 58 | const body = [rows[0], ...rows.slice(1).sort((a, b) => a[0].localeCompare(b[0]))].map((r) => r.map(cell).join(",")).join("\r\n"); | |
| 51 | 59 | return new Response(`${body}\r\n`, { | |
| 52 | 60 | headers: { |
| 117 | 117 | what the plan's included usage, a trial, the open-source pool or g1t paid. | |
| 118 | 118 | g1t's own (comped) workspaces are valued at cost plus the margin. | |
| 119 | 119 | - **Cash** = what workspaces paid: `-amount_micros`, and the plan's price. | |
| 120 | + | Never tax or card fees: a payment credits the balance, and | |
| 121 | + | `plan_payments`, without them (see [Tax and the card fee](#tax-and-the-card-fee)). | |
| 120 | 122 | - **Given away** = the part of the cost that went on usage g1t paid for | |
| 121 | 123 | itself on purpose, by why: | |
| 122 | 124 | - **comped**: all of a comped workspace's cost, every bucket; | |
| 499 | 501 | line `<run>/agent` (later `<run>/agent/<tokens>`), with `quantity` the | |
| 500 | 502 | tokens. Runs on a workspace's own provider have no session here and are not | |
| 501 | 503 | charged the rate. **Card fee switch:** sudo → Costs → Guardrails → *Card fee | |
| 502 | − | on AI credit bought by card* (`cost_settings.card_fee`, `on`/`off`). | |
| 504 | + | on card payments* (`cost_settings.card_fee`, `on`/`off`, on by default). It | |
| 505 | + | covers every card payment now, not only AI credit: see | |
| 506 | + | [Tax and the card fee](#tax-and-the-card-fee). | |
| 503 | 507 | ||
| 504 | 508 | ### Budgets | |
| 505 | 509 | ||
| 711 | 715 | came back from; renaming a workspace (the customer's name). Nothing a page | |
| 712 | 716 | view reads. | |
| 713 | 717 | ||
| 718 | + | ## Tax and the card fee | |
| 719 | + | ||
| 720 | + | Owner decision 2026-10-08. Code: `services/billing/src/tax.rs` (what is | |
| 721 | + | kept, the address hold), `stripe.rs` (every request's tax fields), | |
| 722 | + | `invoices.rs`, `webhooks.rs`, `ai.rs`; migration | |
| 723 | + | `0041_tax_and_card_fees.sql`. | |
| 724 | + | ||
| 725 | + | ### What Stripe is asked | |
| 726 | + | ||
| 727 | + | Every price excludes tax (`tax_behavior=exclusive`) and carries tax code | |
| 728 | + | `txcd_10103001` (software as a service, business use; the card fee too, | |
| 729 | + | since a fee for paying for a sale follows the sale). Fields are valid for | |
| 730 | + | `2025-02-24.acacia`. | |
| 731 | + | ||
| 732 | + | | Request | Tax | Card fee | | |
| 733 | + | | --- | --- | --- | | |
| 734 | + | | Checkout, plan or Security (`subscription_fields`) | `automatic_tax[enabled]`, `billing_address_collection=required`, `tax_id_collection[enabled]`, with a customer `customer_update[address]=auto` and `[name]=auto`; the subscription keeps automatic tax for every renewal | A second recurring line, *Card processing fee* | | |
| 735 | + | | Subscription on a saved card (`saved_subscription_fields`) | `automatic_tax[enabled]`; a customer Stripe Tax cannot place fails, and the person is sent to Checkout, which asks for the address | `items[1]`, the `card_fee` product | | |
| 736 | + | | Checkout, prepay (`prepay_fields`) | As above | By card only; none by bank transfer | | |
| 737 | + | | Checkout, AI credit (`credit_fields`) | As above | Its own line | | |
| 738 | + | | Checkout, card check (`card_check_fields`) | Setup mode: nothing charged, nothing taxed. `billing_address_collection=required`, and the card's address is copied onto a customer with none (`fill_address`) | — | | |
| 739 | + | | Auto-reload (`charge_saved`) | `POST /tax/calculations` first (credit and fee as lines), the PaymentIntent for the total, then `POST /tax/transactions/create_from_calculation` with the PaymentIntent as reference; a refund reverses its share (`create_reversal`, `mode=partial`) | In the calculation and the amount | | |
| 740 | + | | Workspace invoice, month close and threshold (`invoice_workspace`) | `automatic_tax[enabled]` on the draft; each item `tax_behavior`, `tax_code` | An item *Card processing fee*, when the default payment method is a card | | |
| 741 | + | | Enterprise invoice (`invoice_enterprise`) | The same | Never | | |
| 742 | + | | Products (`Stripe::product`) | Made with `tax_code`; one found without it is given it | The `card_fee` product, `metadata[g1t]=card_fee` | | |
| 743 | + | ||
| 744 | + | ### What is kept | |
| 745 | + | ||
| 746 | + | A payment credits the balance with what it paid for, never its tax or fee: | |
| 747 | + | prepay credits the page's `amount_subtotal` less the fee line | |
| 748 | + | (`credit_prepayment`), a workspace invoice `amount_paid − tax − fee` | |
| 749 | + | (`credit_invoice`), AI credit its credit amount, and `plan_payments` the | |
| 750 | + | plan's invoice less its tax and *Card processing fee* lines | |
| 751 | + | (`stripe::invoice_split`). Each payment's tax and fee are rows in | |
| 752 | + | `tax_and_fees` (`<reference>/tax`, `<reference>/card_fee`; the enterprise's | |
| 753 | + | account id in `workspace` for its invoices), with the PaymentIntent, so a | |
| 754 | + | refund (`charge.refunded`) gives back the balance, tax and fee in | |
| 755 | + | proportion (`tax::refund_split`, negative rows under `refund/<charge>/…`). | |
| 756 | + | `workspace_invoices.fee_micros` and `tax_micros` sit beside each usage | |
| 757 | + | invoice. | |
| 758 | + | ||
| 759 | + | - **Statement.** *Tax* and *Card processing fees* are their own lines per | |
| 760 | + | day (`StatementLine.passed_micros`), never in `charged_micros`; totals | |
| 761 | + | `tax_micros`, `card_fee_micros`. The CSV has a row a day for each. | |
| 762 | + | - **Margin.** Cash never holds them, so margin is untouched. sudo → Costs | |
| 763 | + | shows **Tax collected** and **Card fees passed on** for the range | |
| 764 | + | (`OverallMargin.tax_collected_micros`, `card_fees_micros`). Tax is owed to | |
| 765 | + | the authorities: file it from Stripe Tax's reports, never from g1t's. | |
| 766 | + | ||
| 767 | + | ### No address | |
| 768 | + | ||
| 769 | + | Stripe Tax needs a country (in the US a ZIP code, in Canada a postal code | |
| 770 | + | or province: `stripe::address_places_customer`). Before a workspace | |
| 771 | + | invoice is drafted, g1t checks the customer; without an address, or when | |
| 772 | + | Stripe leaves the draft at `requires_location_inputs` or refuses with | |
| 773 | + | `customer_tax_location_invalid`, nothing is charged: | |
| 774 | + | `accounts.tax_address_needed_at` is set, the owners are emailed once | |
| 775 | + | (`notify_owners`), and Billing shows **Add a billing address**. Saving | |
| 776 | + | Invoice details with an address Stripe Tax can use clears it, as does a | |
| 777 | + | charge that goes through. Work is not stopped for it; the limits still | |
| 778 | + | apply. Auto-reload without an address fails like a declined card (turned | |
| 779 | + | off, owners told). An enterprise's invoice is not sent without an address: | |
| 780 | + | sudo → the enterprise → Invoices → **Billing address** (`admin_enterprise_address`, | |
| 781 | + | with its tax ID; audited `billing_address`). | |
| 782 | + | ||
| 783 | + | ### The card fee | |
| 784 | + | ||
| 785 | + | `card_fee_cents` grosses Stripe's fee up so the amount paid for is left | |
| 786 | + | after it: `(amount + 30¢) / (1 − 2.9%)`, rounded up; $0.91 on $20, $1.06 | |
| 787 | + | on $25. It is worked out on the amount before tax, so Stripe's fee on the | |
| 788 | + | tax itself (a few cents) is g1t's. It is shown before paying: the plan card | |
| 789 | + | and pricing page (`Plan.card_fee_cents`), AI credit (*Card processing fee | |
| 790 | + | $1.06, plus tax where it applies*), Prepay. Never on a bank transfer or an | |
| 791 | + | enterprise's (`send_invoice`) invoice. Meters stay at cost + 20% and models | |
| 792 | + | at the provider's price plus the agent rate (price versions in migration | |
| 793 | + | 0040); the fee is passed through, not margin. | |
| 794 | + | ||
| 795 | + | ### Tax-exempt customers and tax IDs | |
| 796 | + | ||
| 797 | + | g1t never sets `tax_exempt`. For a customer who sends an exemption | |
| 798 | + | certificate, set it in Stripe's dashboard (Customers → the customer → Tax | |
| 799 | + | status: Exempt, or Reverse charge); Billing then says so. Tax IDs come | |
| 800 | + | from Checkout (`tax_id_collection`) or Invoice details (`set_billing_details`, | |
| 801 | + | validated against Stripe's types in `details::TAX_ID_TYPES`); Stripe checks | |
| 802 | + | EU VAT numbers and Stripe Tax applies a reverse charge where it should. | |
| 803 | + | Billing shows Stripe's `verification.status`. | |
| 804 | + | ||
| 805 | + | ### In Stripe's dashboard (not done by g1t) | |
| 806 | + | ||
| 807 | + | 1. **Settings → Tax → Get started**: turn on Stripe Tax in live and test | |
| 808 | + | mode. | |
| 809 | + | 2. **Origin address**: Flagon, Inc.'s head office address. | |
| 810 | + | 3. **Default tax code**: Software as a service, business use | |
| 811 | + | (`txcd_10103001`); **default tax behavior**: exclusive. | |
| 812 | + | 4. **Registrations**: add each jurisdiction where Flagon is registered to | |
| 813 | + | collect (its home state at least; then states as thresholds are | |
| 814 | + | crossed, which Stripe Tax's monitoring flags; the EU's OSS and the UK | |
| 815 | + | if selling there). Stripe collects only where a registration exists. | |
| 816 | + | 5. **Customer portal**: tax ID and address updates are already allowed | |
| 817 | + | (`portal_configuration`). | |
| 818 | + | 6. Refund an invoice through a **credit note**, so its tax is reversed in | |
| 819 | + | Stripe Tax. | |
| 820 | + | ||
| 821 | + | ## Free workspaces | |
| 822 | + | ||
| 823 | + | Owner decision 2026-10-08: one free workspace per person, and a free | |
| 824 | + | workspace adds no one. Billing answers one question, `free_workspaces` | |
| 825 | + | (`credits.rs`): which of the given workspaces are on no paid plan | |
| 826 | + | (`plan_kind_for` is `Free`). The plan, an enterprise's terms and a 100% | |
| 827 | + | discount (flagon-io) count as paid; with payments off nothing is free. | |
| 828 | + | Identity asks it (`services/identity/src/paid.rs`) and refuses with | |
| 829 | + | `payment_required`: | |
| 830 | + | ||
| 831 | + | | Where | Refused when | | |
| 832 | + | | --- | --- | | |
| 833 | + | | `create_workspace` | The person owns any free workspace. Several from before are kept (grandfathered); none can be added until each is paid for or deleted. | | |
| 834 | + | | `add_member`, `invite_member` | The workspace is free. | | |
| 835 | + | | `add_collaborator` | Someone outside a free workspace (a username who is not a member, or an address). Members' roles are fine. | | |
| 836 | + | | `accept_invite`, `respond_repo_invitation` | The invite's workspace (or repository's) is free now: it waits. A sign-up with such an invite makes the account without joining. | | |
| 837 | + | ||
| 838 | + | `@g1t` is never counted as someone added. If billing cannot be asked, the | |
| 839 | + | change is refused for now ("try again"), never let through. The site says | |
| 840 | + | so first (New workspace, People, a repository's Access, from the same RPC); | |
| 841 | + | the API and MCP pass identity's refusal on as `402`. | |
| 842 | + | ||
| 714 | 843 | ## The Security and quality activation | |
| 715 | 844 | ||
| 716 | 845 | A second monthly subscription a workspace can hold beside the plan |
| 989 | 989 | stops work until paid. The owner's own spend limit always means stop. | |
| 990 | 990 | Test-mode payments never lower exposure or raise trust. | |
| 991 | 991 | ||
| 992 | + | ### Tax, card fees and free workspaces (built 2026-10-08) | |
| 993 | + | ||
| 994 | + | > **2026-10-08, decided:** Stripe Tax everywhere ("so I don't fuck up on | |
| 995 | + | > taxes"); Stripe's card fee passed to the customer with the 20% markup | |
| 996 | + | > kept; one free workspace per person; no invites on free workspaces. | |
| 997 | + | ||
| 998 | + | - **Stripe Tax on every payment.** `automatic_tax` on every Checkout page | |
| 999 | + | (plan, Security and quality, prepaying, AI credit), every subscription | |
| 1000 | + | and every invoice g1t makes (month close, threshold, enterprise), and a | |
| 1001 | + | tax calculation and transaction for auto-reload's off-session charge. | |
| 1002 | + | Tax code `txcd_10103001` (SaaS, business use), every price | |
| 1003 | + | `tax_behavior=exclusive`. Checkout always collects the billing address | |
| 1004 | + | and tax ID and saves them on the customer. Prices on g1t are shown | |
| 1005 | + | excluding tax. Tax is never revenue: balances and plan payments are | |
| 1006 | + | credited without it, it is kept in `tax_and_fees`, shown as its own | |
| 1007 | + | statement line, and as **Tax collected** on sudo's Costs. Without an | |
| 1008 | + | address g1t does not charge: the owners are asked for one. | |
| 1009 | + | - **The card fee** (2.9% + $0.30, grossed up) is its own line on every | |
| 1010 | + | card payment, the plan's and Security's as a monthly item; never on a | |
| 1011 | + | bank transfer or an enterprise's invoice. On by default | |
| 1012 | + | (`cost_settings.card_fee`). Not revenue either. Meters stay at cost + | |
| 1013 | + | 20%; models at the provider's price plus the agent rate. | |
| 1014 | + | - **One free workspace per person.** Identity asks billing | |
| 1015 | + | (`free_workspaces`) before creating one; a second is refused with the | |
| 1016 | + | way forward. Those who own several from before keep them. | |
| 1017 | + | - **A free workspace adds no one**: no members, invites or outside | |
| 1018 | + | collaborators until it starts the plan; its members stay; @g1t never | |
| 1019 | + | counts. Enforced in identity for every path (site, API, MCP). | |
| 1020 | + | - Details: docs/BILLING_OPERATIONS.md, *Tax and the card fee*. | |
| 1021 | + | ||
| 992 | 1022 | ### Promo codes (planned) | |
| 993 | 1023 | ||
| 994 | 1024 | Staff credits (promotional, goodwill, refund; `services/billing/src/grants.rs`, |