Quickbooks: Why each Empire Ring Member needs their own Accounting and Banking
Note... articles to be later expanded into separate forum posts.
INDEX OF ARTICLES
Awesome—here’s a tight, end-to-end content plan for your forum PLUS a ready-to-drop .NET 8 QuickBooks Online (QBO) integration starter that demonstrates OAuth, REST calls (create customer, invoice, payment), and webhooks. You’ll get: 1) article list (with slugs and goals), 2) banking/accounting structure policy posts, and 3) full code regeneration with filenames + commented code.
• Policy: One human = 1 personal QBO (optional) + 1+ business QBO(s) for each LLC they operate.
• Policy: Every LLC must have its own QBO tenant and exactly two bank accounts minimum: Operating and Tax/Reserve (add Savings/CapEx later).
• Policy: No co-mingling. No shared cards across entities. Document every transfer with memo (reason, date, parties).
• SOP: Weekly sweep to Tax/Reserve per Revenue % band (10–30% guideline).
• SOP: Intercompany billing via formal invoices; never “mystery transfers.”
• SOP: Close Day (monthly): reconcile banks, lock prior periods, export backup.
Solution name: QuickBooksGateway
Purpose: OAuth2 with Intuit, store tokens, call REST to create Customer/Invoice/Payment, receive Webhooks.
FILES:
CODE (paste each file exactly as named):
— QuickBooksGateway/QuickBooksGateway.csproj —
net8.0
enable
enable
true
all
all
— QuickBooksGateway/appsettings.json —
{
"Qbo": {
"ClientId": "YOUR_INTUIT_CLIENT_ID",
"ClientSecret": "YOUR_INTUIT_CLIENT_SECRET",
"RedirectUri": "https://yourdomain.com/qbo/callback",
"Environment": "sandbox",
"Scopes": "com.intuit.quickbooks.accounting openid profile email phone address",
"BaseAuthUrl": "https://appcenter.intuit.com/connect/oauth2",
"TokenUrl": "https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer",
"ApiBaseUrlSandbox": "https://sandbox-quickbooks.api.intuit.com/v3/company/",
"ApiBaseUrlProd": "https://quickbooks.api.intuit.com/v3/company/"
},
"ConnectionStrings": {
"Default": "Server=localhost;Database=QboGateway;Trusted_Connection=True;MultipleActiveResultSets=true;TrustServerCertificate=True"
},
"WebhookVerifier": {
"IntuitSignatureHeader": "intuit-signature",
"VerifierToken": "YOUR_WEBHOOK_VERIFIER_TOKEN"
},
"AllowedHosts": "*"
}
— QuickBooksGateway/Program.cs —
using Microsoft.EntityFrameworkCore;
using QuickBooksGateway.Data;
using QuickBooksGateway.Models;
using QuickBooksGateway.Services;
var builder = WebApplication.CreateBuilder(args);
// Bind QBO options
builder.Services.Configure(builder.Configuration.GetSection("Qbo"));
// EF Core
builder.Services.AddDbContext(opt =>
opt.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
// Services
builder.Services.AddHttpClient();
builder.Services.AddScoped<ITokenStore, TokenStore>();
builder.Services.AddScoped();
builder.Services.AddControllers().AddNewtonsoftJson();
var app = builder.Build();
app.MapControllers();
app.Run();
— QuickBooksGateway/Models/QboOptions.cs —
namespace QuickBooksGateway.Models
{
// Holds configuration needed for OAuth + API calls
public class QboOptions
{
public string ClientId { get; set; } = "";
public string ClientSecret { get; set; } = "";
public string RedirectUri { get; set; } = "";
public string Environment { get; set; } = "sandbox";
public string Scopes { get; set; } = "";
public string BaseAuthUrl { get; set; } = "";
public string TokenUrl { get; set; } = "";
public string ApiBaseUrlSandbox { get; set; } = "";
public string ApiBaseUrlProd { get; set; } = "";
public string ApiBaseUrl(string env) =><br> env?.ToLower() == "prod" ? ApiBaseUrlProd : ApiBaseUrlSandbox;<br>}<br>
}
— QuickBooksGateway/Models/QboDtos.cs —
using System.Text.Json.Serialization;
namespace QuickBooksGateway.Models
{
// Minimal DTOs for QBO create flows (Customer, Invoice, Payment).
// QBO is picky about casing and required fields—keep payloads aligned with API docs.
public record CreateCustomerRequest(string DisplayName, string? PrimaryEmailAddr);<br><br>public record CreateInvoiceRequest(long CustomerId, decimal Amount, string? Memo);<br><br>public record RecordPaymentRequest(long CustomerId, long InvoiceId, decimal Amount);<br><br>// Basic API responses<br>public class QboCustomerResponse<br>{<br> [JsonPropertyName("Customer")]<br> public QboCustomer? Customer { get; set; }<br>}<br><br>public class QboCustomer<br>{<br> public long Id { get; set; }<br> public string? DisplayName { get; set; }<br> public string? SyncToken { get; set; }<br>}<br><br>public class QboInvoiceResponse<br>{<br> [JsonPropertyName("Invoice")]<br> public QboInvoice? Invoice { get; set; }<br>}<br><br>public class QboInvoice<br>{<br> public long Id { get; set; }<br> public string? SyncToken { get; set; }<br> public long? CustomerRef { get; set; }<br> public decimal? TotalAmt { get; set; }<br>}<br><br>public class QboPaymentResponse<br>{<br> [JsonPropertyName("Payment")]<br> public QboPayment? Payment { get; set; }<br>}<br><br>public class QboPayment<br>{<br> public long Id { get; set; }<br> public string? SyncToken { get; set; }<br> public decimal? TotalAmt { get; set; }<br>}<br>
}
— QuickBooksGateway/Data/TokenDbContext.cs —
using Microsoft.EntityFrameworkCore;
using QuickBooksGateway.Entities;
namespace QuickBooksGateway.Data
{
// Stores OAuth tokens per connected QBO company (realmId)
public class TokenDbContext : DbContext
{
public TokenDbContext(DbContextOptions options) : base(options) { }
public DbSet<QboToken> Tokens => Set<QboToken>();<br><br> protected override void OnModelCreating(ModelBuilder modelBuilder)<br> {<br> modelBuilder.Entity<QboToken>(e =><br> {<br> e.HasKey(x => x.Id);<br> e.HasIndex(x => x.RealmId).IsUnique();<br> e.Property(x => x.AccessToken).IsRequired();<br> e.Property(x => x.RefreshToken).IsRequired();<br> e.Property(x => x.ExpiresAtUtc);<br> });<br> }<br>}<br>
}
— QuickBooksGateway/Entities/QboToken.cs —
using System.ComponentModel.DataAnnotations;
namespace QuickBooksGateway.Entities
{
// One row per QuickBooks company (realmId)
public class QboToken
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
// Intuit company identifier (realm/company id)<br> public string RealmId { get; set; } = "";<br><br> public string AccessToken { get; set; } = "";<br> public string RefreshToken { get; set; } = "";<br><br> // When current access token expires (UTC)<br> public DateTime ExpiresAtUtc { get; set; }<br><br> // Sandbox or prod so we can route API base URL<br> public string Environment { get; set; } = "sandbox";<br><br> // For multi-member systems: who connected the company<br> public string? ConnectedByUserId { get; set; }<br>}<br>
}
— QuickBooksGateway/Services/ITokenStore.cs —
using QuickBooksGateway.Entities;
namespace QuickBooksGateway.Services
{
public interface ITokenStore
{
Task<QboToken?> GetByRealmIdAsync(string realmId, CancellationToken ct = default);
Task UpsertAsync(QboToken token, CancellationToken ct = default);
}
}
— QuickBooksGateway/Services/TokenStore.cs —
using Microsoft.EntityFrameworkCore;
using QuickBooksGateway.Data;
using QuickBooksGateway.Entities;
namespace QuickBooksGateway.Services
{
// EF Core token persistence
public class TokenStore : ITokenStore
{
private readonly TokenDbContext _db;
public TokenStore(TokenDbContext db) => _db = db;<br><br> public async Task<QboToken?> GetByRealmIdAsync(string realmId, CancellationToken ct = default)<br> {<br> return await _db.Tokens.AsNoTracking().FirstOrDefaultAsync(x => x.RealmId == realmId, ct);<br> }<br><br> public async Task UpsertAsync(QboToken token, CancellationToken ct = default)<br> {<br> var existing = await _db.Tokens.FirstOrDefaultAsync(x => x.RealmId == token.RealmId, ct);<br> if (existing is null)<br> {<br> _db.Tokens.Add(token);<br> }<br> else<br> {<br> existing.AccessToken = token.AccessToken;<br> existing.RefreshToken = token.RefreshToken;<br> existing.ExpiresAtUtc = token.ExpiresAtUtc;<br> existing.Environment = token.Environment;<br> existing.ConnectedByUserId = token.ConnectedByUserId;<br> }<br> await _db.SaveChangesAsync(ct);<br> }<br>}<br>
}
— QuickBooksGateway/Services/QboClient.cs —
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Options;
using QuickBooksGateway.Entities;
using QuickBooksGateway.Models;
namespace QuickBooksGateway.Services
{
// Handles OAuth token refresh + minimal REST calls (Customer, Invoice, Payment).
public class QboClient
{
private readonly IHttpClientFactory _httpFactory;
private readonly IOptions _opts;
private readonly ITokenStore _tokens;
public QboClient(IHttpClientFactory httpFactory, IOptions<QboOptions> opts, ITokenStore tokens)<br> {<br> _httpFactory = httpFactory;<br> _opts = opts;<br> _tokens = tokens;<br> }<br><br> private string ApiBase(string env) => _opts.Value.ApiBaseUrl(env);<br><br> public string BuildAuthUrl(string state)<br> {<br> var o = _opts.Value;<br> var url =<br> $"{o.BaseAuthUrl}?client_id={Uri.EscapeDataString(o.ClientId)}" +<br> $"&redirect_uri={Uri.EscapeDataString(o.RedirectUri)}" +<br> $"&response_type=code&scope={Uri.EscapeDataString(o.Scopes)}&state={Uri.EscapeDataString(state)}";<br> return url;<br> }<br><br> public async Task<QboToken> ExchangeAuthCodeAsync(string code, string realmId, string environment, CancellationToken ct = default)<br> {<br> var o = _opts.Value;<br> var client = _httpFactory.CreateClient();<br> var req = new HttpRequestMessage(HttpMethod.Post, o.TokenUrl);<br> var basic = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{o.ClientId}:{o.ClientSecret}"));<br> req.Headers.Authorization = new AuthenticationHeaderValue("Basic", basic);<br> req.Content = new FormUrlEncodedContent(new Dictionary<string, string><br> {<br> ["grant_type"] = "authorization_code",<br> ["code"] = code,<br> ["redirect_uri"] = o.RedirectUri<br> });<br><br> var res = await client.SendAsync(req, ct);<br> res.EnsureSuccessStatusCode();<br> var json = await res.Content.ReadAsStringAsync(ct);<br> using var doc = JsonDocument.Parse(json);<br> var access = doc.RootElement.GetProperty("access_token").GetString()!;<br> var refresh = doc.RootElement.GetProperty("refresh_token").GetString()!;<br> var expiresIn = doc.RootElement.GetProperty("expires_in").GetInt32();<br><br> var token = new QboToken<br> {<br> RealmId = realmId,<br> AccessToken = access,<br> RefreshToken = refresh,<br> ExpiresAtUtc = DateTime.UtcNow.AddSeconds(expiresIn - 60),<br> Environment = environment<br> };<br> await _tokens.UpsertAsync(token, ct);<br> return token;<br> }<br><br> private async Task<QboToken> EnsureAccessAsync(string realmId, CancellationToken ct)<br> {<br> var t = await _tokens.GetByRealmIdAsync(realmId, ct) ?? throw new InvalidOperationException("No token for realmId");<br> if (DateTime.UtcNow < t.ExpiresAtUtc) return t;<br><br> // Refresh access token<br> var o = _opts.Value;<br> var client = _httpFactory.CreateClient();<br> var req = new HttpRequestMessage(HttpMethod.Post, o.TokenUrl);<br> var basic = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{o.ClientId}:{o.ClientSecret}"));<br> req.Headers.Authorization = new AuthenticationHeaderValue("Basic", basic);<br> req.Content = new FormUrlEncodedContent(new Dictionary<string, string><br> {<br> ["grant_type"] = "refresh_token",<br> ["refresh_token"] = t.RefreshToken<br> });<br> var res = await client.SendAsync(req, ct);<br> res.EnsureSuccessStatusCode();<br> var json = await res.Content.ReadAsStringAsync(ct);<br> using var doc = JsonDocument.Parse(json);<br> t.AccessToken = doc.RootElement.GetProperty("access_token").GetString()!;<br> t.RefreshToken = doc.RootElement.GetProperty("refresh_token").GetString()!;<br> var expiresIn = doc.RootElement.GetProperty("expires_in").GetInt32();<br> t.ExpiresAtUtc = DateTime.UtcNow.AddSeconds(expiresIn - 60);<br><br> await _tokens.UpsertAsync(t, ct);<br> return t;<br> }<br><br> private HttpRequestMessage JsonPost(string url, string token, object payload)<br> {<br> var req = new HttpRequestMessage(HttpMethod.Post, url);<br> req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);<br> req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));<br> req.Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");<br> return req;<br> }<br><br> // Create Customer<br> public async Task<QboCustomerResponse?> CreateCustomerAsync(string realmId, string environment, CreateCustomerRequest input, CancellationToken ct)<br> {<br> var t = await EnsureAccessAsync(realmId, ct);<br> var baseUrl = ApiBase(environment) + realmId + "/customer?minorversion=73";<br> var payload = new<br> {<br> DisplayName = input.DisplayName,<br> PrimaryEmailAddr = string.IsNullOrWhiteSpace(input.PrimaryEmailAddr) ? null : new { Address = input.PrimaryEmailAddr }<br> };<br><br> var client = _httpFactory.CreateClient();<br> var req = JsonPost(baseUrl, t.AccessToken, payload);<br> var res = await client.SendAsync(req, ct);<br> res.EnsureSuccessStatusCode();<br> var json = await res.Content.ReadAsStringAsync(ct);<br> return JsonSerializer.Deserialize<QboCustomerResponse>(json);<br> }<br><br> // Create Invoice (single line)<br> public async Task<QboInvoiceResponse?> CreateInvoiceAsync(string realmId, string environment, CreateInvoiceRequest input, CancellationToken ct)<br> {<br> var t = await EnsureAccessAsync(realmId, ct);<br> var baseUrl = ApiBase(environment) + realmId + "/invoice?minorversion=73";<br><br> // NOTE: ItemRef "1" often maps to "Services" in sandbox; adjust for production Items.<br> var payload = new<br> {<br> Line = new[]<br> {<br> new {<br> DetailType = "SalesItemLineDetail",<br> Amount = input.Amount,<br> SalesItemLineDetail = new { ItemRef = new { value = "1" } }<br> }<br> },<br> CustomerRef = new { value = input.CustomerId.ToString() },<br> PrivateNote = input.Memo<br> };<br><br> var client = _httpFactory.CreateClient();<br> var req = JsonPost(baseUrl, t.AccessToken, payload);<br> var res = await client.SendAsync(req, ct);<br> res.EnsureSuccessStatusCode();<br> var json = await res.Content.ReadAsStringAsync(ct);<br> return JsonSerializer.Deserialize<QboInvoiceResponse>(json);<br> }<br><br> // Record Payment applied to Invoice<br> public async Task<QboPaymentResponse?> RecordPaymentAsync(string realmId, string environment, RecordPaymentRequest input, CancellationToken ct)<br> {<br> var t = await EnsureAccessAsync(realmId, ct);<br> var baseUrl = ApiBase(environment) + realmId + "/payment?minorversion=73";<br><br> var payload = new<br> {<br> CustomerRef = new { value = input.CustomerId.ToString() },<br> TotalAmt = input.Amount,<br> Line = new[]<br> {<br> new {<br> Amount = input.Amount,<br> LinkedTxn = new[] { new { TxnId = input.InvoiceId.ToString(), TxnType = "Invoice" } }<br> }<br> }<br> };<br><br> var client = _httpFactory.CreateClient();<br> var req = JsonPost(baseUrl, t.AccessToken, payload);<br> var res = await client.SendAsync(req, ct);<br> res.EnsureSuccessStatusCode();<br> var json = await res.Content.ReadAsStringAsync(ct);<br> return JsonSerializer.Deserialize<QboPaymentResponse>(json);<br> }<br>}<br>
}
— QuickBooksGateway/Controllers/QboAuthController.cs —
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using QuickBooksGateway.Models;
using QuickBooksGateway.Services;
namespace QuickBooksGateway.Controllers
{
// Handles the OAuth handshake with Intuit (connect account → callback).
[ApiController]
[Route("qbo")]
public class QboAuthController : ControllerBase
{
private readonly QboClient _qbo;
private readonly IOptions _opts;
public QboAuthController(QboClient qbo, IOptions<QboOptions> opts)<br> {<br> _qbo = qbo;<br> _opts = opts;<br> }<br><br> // Step 1: Redirect user to Intuit consent<br> [HttpGet("connect")]<br> public IActionResult Connect([FromQuery] string? env = "sandbox")<br> {<br> // state ideally includes a CSRF token & the env ("sandbox" or "prod")<br> var state = $"env={env}&nonce={Guid.NewGuid()}";<br> var url = _qbo.BuildAuthUrl(state);<br> return Redirect(url);<br> }<br><br> // Step 2: Intuit calls back with ?code=&state=&realmId=<br> [HttpGet("callback")]<br> public async Task<IActionResult> Callback([FromQuery] string code, [FromQuery] string state, [FromQuery] string realmId, [FromQuery] string? error = null)<br> {<br> if (!string.IsNullOrEmpty(error)) return BadRequest(error);<br><br> var env = "sandbox";<br> var parts = state.Split('&', StringSplitOptions.RemoveEmptyEntries);<br> foreach (var p in parts)<br> {<br> var kv = p.Split('=');<br> if (kv.Length == 2 && kv[0] == "env") env = kv[1];<br> }<br><br> await _qbo.ExchangeAuthCodeAsync(code, realmId, env, HttpContext.RequestAborted);<br> return Content($"Connected realmId {realmId} in {env} environment. You can now call /api/customer, /api/invoice, /api/payment.");<br> }<br>}<br>
}
— QuickBooksGateway/Controllers/QboApiController.cs —
using Microsoft.AspNetCore.Mvc;
using QuickBooksGateway.Models;
using QuickBooksGateway.Services;
namespace QuickBooksGateway.Controllers
{
// Minimal API surface to demonstrate transactional calls.
[ApiController]
[Route("api")]
public class QboApiController : ControllerBase
{
private readonly QboClient _qbo;
public QboApiController(QboClient qbo) => _qbo = qbo;<br><br> // POST /api/customer?realmId=12345&env=sandbox<br> [HttpPost("customer")]<br> public async Task<IActionResult> CreateCustomer([FromQuery] string realmId, [FromQuery] string env, [FromBody] CreateCustomerRequest req)<br> {<br> var result = await _qbo.CreateCustomerAsync(realmId, env, req, HttpContext.RequestAborted);<br> return Ok(result);<br> }<br><br> // POST /api/invoice?realmId=12345&env=sandbox<br> [HttpPost("invoice")]<br> public async Task<IActionResult> CreateInvoice([FromQuery] string realmId, [FromQuery] string env, [FromBody] CreateInvoiceRequest req)<br> {<br> var result = await _qbo.CreateInvoiceAsync(realmId, env, req, HttpContext.RequestAborted);<br> return Ok(result);<br> }<br><br> // POST /api/payment?realmId=12345&env=sandbox<br> [HttpPost("payment")]<br> public async Task<IActionResult> RecordPayment([FromQuery] string realmId, [FromQuery] string env, [FromBody] RecordPaymentRequest req)<br> {<br> var result = await _qbo.RecordPaymentAsync(realmId, env, req, HttpContext.RequestAborted);<br> return Ok(result);<br> }<br>}<br>
}
— QuickBooksGateway/Controllers/WebhooksController.cs —
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Primitives;
using Newtonsoft.Json.Linq;
namespace QuickBooksGateway.Controllers
{
// Receives QBO webhooks (e.g., Customer, Invoice changes).
// In Intuit Developer Portal, subscribe to entities and set this endpoint.
[ApiController]
[Route("webhooks/qbo")]
public class WebhooksController : ControllerBase
{
private readonly IConfiguration _cfg;
public WebhooksController(IConfiguration cfg) => _cfg = cfg;<br><br> [HttpPost]<br> public async Task<IActionResult> Receive()<br> {<br> // Verify signature (HMACSHA256 over raw body using your "VerifierToken").<br> var body = await new StreamReader(Request.Body).ReadToEndAsync();<br> if (!Request.Headers.TryGetValue(_cfg["WebhookVerifier:IntuitSignatureHeader"] ?? "intuit-signature", out StringValues sigs))<br> return Unauthorized("Missing signature");<br><br> var expected = ComputeHmac(_cfg["WebhookVerifier:VerifierToken"] ?? "", body);<br> if (!sigs.Any(s => s.Equals(expected, StringComparison.OrdinalIgnoreCase)))<br> return Unauthorized("Bad signature");<br><br> // Parse minimal payload & acknowledge<br> var json = JObject.Parse(body);<br> // TODO: enqueue to your bus for idempotent processing<br> // Example: log entity names + operation types<br> var events = json["eventNotifications"]?.First?["dataChangeEvent"]?["entities"];<br> if (events != null)<br> {<br> foreach (var e in events)<br> {<br> var name = (string?)e["name"] ?? "?";<br> var op = (string?)e["operation"] ?? "?";<br> Console.WriteLine($"Webhook: {name} {op}");<br> }<br> }<br><br> return Ok();<br> }<br><br> private static string ComputeHmac(string secret, string data)<br> {<br> using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));<br> var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(data));<br> return Convert.ToBase64String(hash);<br> }<br>}<br>
}
• Each member connects their own QBO tenant via GET /qbo/connect → approves in Intuit → we store tokens by realmId.
• Each LLC has its own QBO and two bank accounts. Use article #2 as the canonical policy post and link it everywhere.
• Your gateway lets you automate common flows per entity cleanly (no co-mingling), and you can restrict who can call which realmId.
• Webhooks let your nodes react to real accounting events (e.g., invoice paid → trigger Discord/SignalR toast, sweep to Tax/Reserve).
• Migration: dotnet ef migrations add InitTokens && dotnet ef database update
• In Intuit Developer Portal: create app, set Redirect URI to https://yourdomain.com/qbo/callback, enable Accounting scopes, set Webhook URL to https://yourdomain.com/webhooks/qbo, copy Verifier Token into appsettings.json.
• Postman collection: add POSTs to /api/customer, /api/invoice, /api/payment with realmId & env query params.
• Security hardening: require your own auth on every /api/* route; map realmId to a MemberId (RBAC).
• Extend: add endpoints for Vendor/Bill, Items, BankDeposit, JournalEntry; add background job to refresh tokens nightly; log everything (Serilog).
If you want, I can also generate:
• A QBO Chart of Accounts JSON seed for one-man LLCs.
• A bank-rules CSV starter (typical fuel, hardware, meals, hotels).
• Razor pages + dark theme UI (LLCAIMACHINE styling) to connect and test each member’s realmId.
Note... articles to be later expanded into separate forum posts.
INDEX OF ARTICLES
Awesome—here’s a tight, end-to-end content plan for your forum PLUS a ready-to-drop .NET 8 QuickBooks Online (QBO) integration starter that demonstrates OAuth, REST calls (create customer, invoice, payment), and webhooks. You’ll get: 1) article list (with slugs and goals), 2) banking/accounting structure policy posts, and 3) full code regeneration with filenames + commented code.
Forum Series Outline (slugs, goals, and who it’s for)
- quickbooks/why-each-man-needs-his-own-books
Goal: Make the case for every member running their own QuickBooks Online (QBO) tenant for sovereignty, credit-building, and clean audit trails. - quickbooks/each-llc-its-own-qbo-and-two-bank-accounts
Goal: Policy: each LLC = separate QBO + two bank accounts (Operating + Tax/Reserve). How this prevents co-mingling and simplifies audits. - quickbooks/choose-right-qbo-plan-for-international-man
Goal: Simple matrix of QBO plans and when to upgrade (multi-currency, inventory, projects, 1099s). - quickbooks/chart-of-accounts-template-for-one-man-llc
Goal: Downloadable COA template aligned to your Transaction Equity model (Income, COGS, Operating, Tax/Holdback). - quickbooks/banking-setup-two-accounts-rule
Goal: Bank checklist: create Operating + Tax/Reserve, set online banking, enable bank feeds, set bank-rules to auto-sort deposits/expenses. - quickbooks/cash-vs-accrual-and-when-to-switch
Goal: Explain Cash vs Accrual in plain English, with examples for trades, media, consulting. - quickbooks/sales-tax-basics-and-when-to-register
Goal: Who needs sales tax, multi-state exposure, digital products vs services, link to state portals. - quickbooks/contractors-1099-w9-policies
Goal: W-9 intake SOP, 1099-NEC at year end, document storage policy. - quickbooks/multi-currency-for-international-ops
Goal: Turn on multi-currency, FX impact on P&L, clean memo usage. - quickbooks/receipts-inbox-mastery
Goal: Email-to-QBO, mobile app capture, naming standards, monthly close checklist. - quickbooks/monthly-close-checklist-7-steps
Goal: Bank recs, undeposited funds cleanup, A/R & A/P aging, sales tax payable, owner draw log. - quickbooks/owner-pay-yourself-legally
Goal: LLC draws vs payroll (when S-Corp), documenting distributions, don’t co-mingle. - quickbooks/llc-to-llc-intercompany-invoices
Goal: Clean intercompany SOP for your group: memo standards, pricing policy, audit trail. - quickbooks/api-overview-what-we-can-automate
Goal: High-level QBO API objects: Customer, Item, Invoice, Payment, Vendor, Bill, JournalEntry, Webhooks. - quickbooks/oauth2-deep-dive-keys-and-callbacks
Goal: How OAuth2 works, setting Redirect URIs, refresh tokens, storing realmId/companyId. - quickbooks/rest-api-create-customer-invoice-payment
Goal: Tutorial + code: create Customer → Invoice → record Payment (full example below). - quickbooks/webhooks-architecture-events-security
Goal: Subscribing to QBO Webhooks, validating signatures, idempotency, retry strategy. - quickbooks/bank-feeds-rules-and-auto-categorization
Goal: Bank rules for common vendors, merchant regex tricks, fuel receipts, per-diem tagging. - quickbooks/inventory-light-vs-full-and-items-setup
Goal: When to use Items, mapping to revenue accounts, COGS basics. - quickbooks/project-and-class-tracking-for-job-profit
Goal: Use Projects/Classes to see job profitability; reporting pack for weekly standups. - quickbooks/tax-reserve-policy-10-to-30-percent
Goal: How much to sweep into Tax/Reserve weekly; automate transfers; never touch the pot. - quickbooks/merchant-processing-surcharges-and-fees
Goal: Record Stripe/PayPal fees properly; gross vs net deposits; Undeposited Funds workflow. - quickbooks/expense-management-per-diem-and-mileage
Goal: Mileage apps, per-diem policy, reimbursable vs owner draw. - quickbooks/ai-bookkeeping-workbench
Goal: Use your nodes + Python to summarize uncategorized transactions, suggest categories, and post via API. - quickbooks/close-the-books-year-end
Goal: Year-end close SOP, archive bank statements, CPA package, 1099s, W-2s (if S-Corp). - quickbooks/security-and-access-control-per-member
Goal: Principle of Least Privilege; accountant vs standard user; revoke on exit. - quickbooks/mistakes-to-avoid-new-llc
Goal: Top 20 errors: co-mingling, no receipts, wrong sales tax, invoices without terms, etc. - quickbooks/two-account-playbook-case-studies
Goal: Before/after examples of clarity from two-account policy. - quickbooks/vendor-bill-automation-and-approvals
Goal: Bills vs Expenses, approval chains, due-date discipline, cash-flow forecast. - quickbooks/your-first-automation-roadmap
Goal: Start here: OAuth → Create Customer → Invoice → Payment → Webhooks → Close checklist.
Banking + Entity Structure (policy posts)
• Policy: One human = 1 personal QBO (optional) + 1+ business QBO(s) for each LLC they operate.
• Policy: Every LLC must have its own QBO tenant and exactly two bank accounts minimum: Operating and Tax/Reserve (add Savings/CapEx later).
• Policy: No co-mingling. No shared cards across entities. Document every transfer with memo (reason, date, parties).
• SOP: Weekly sweep to Tax/Reserve per Revenue % band (10–30% guideline).
• SOP: Intercompany billing via formal invoices; never “mystery transfers.”
• SOP: Close Day (monthly): reconcile banks, lock prior periods, export backup.
Full Code Regeneration — QuickBooks REST Starter (ASP.NET Core 8, C#)
Solution name: QuickBooksGateway
Purpose: OAuth2 with Intuit, store tokens, call REST to create Customer/Invoice/Payment, receive Webhooks.
FILES:
- QuickBooksGateway/QuickBooksGateway.csproj
- QuickBooksGateway/appsettings.json
- QuickBooksGateway/Program.cs
- QuickBooksGateway/Models/QboOptions.cs
- QuickBooksGateway/Models/QboDtos.cs
- QuickBooksGateway/Data/TokenDbContext.cs
- QuickBooksGateway/Entities/QboToken.cs
- QuickBooksGateway/Services/ITokenStore.cs
- QuickBooksGateway/Services/TokenStore.cs
- QuickBooksGateway/Services/QboClient.cs
- QuickBooksGateway/Controllers/QboAuthController.cs
- QuickBooksGateway/Controllers/QboApiController.cs
- QuickBooksGateway/Controllers/WebhooksController.cs
CODE (paste each file exactly as named):
— QuickBooksGateway/QuickBooksGateway.csproj —
net8.0
enable
enable
true
all
all
— QuickBooksGateway/appsettings.json —
{
"Qbo": {
"ClientId": "YOUR_INTUIT_CLIENT_ID",
"ClientSecret": "YOUR_INTUIT_CLIENT_SECRET",
"RedirectUri": "https://yourdomain.com/qbo/callback",
"Environment": "sandbox",
"Scopes": "com.intuit.quickbooks.accounting openid profile email phone address",
"BaseAuthUrl": "https://appcenter.intuit.com/connect/oauth2",
"TokenUrl": "https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer",
"ApiBaseUrlSandbox": "https://sandbox-quickbooks.api.intuit.com/v3/company/",
"ApiBaseUrlProd": "https://quickbooks.api.intuit.com/v3/company/"
},
"ConnectionStrings": {
"Default": "Server=localhost;Database=QboGateway;Trusted_Connection=True;MultipleActiveResultSets=true;TrustServerCertificate=True"
},
"WebhookVerifier": {
"IntuitSignatureHeader": "intuit-signature",
"VerifierToken": "YOUR_WEBHOOK_VERIFIER_TOKEN"
},
"AllowedHosts": "*"
}
— QuickBooksGateway/Program.cs —
using Microsoft.EntityFrameworkCore;
using QuickBooksGateway.Data;
using QuickBooksGateway.Models;
using QuickBooksGateway.Services;
var builder = WebApplication.CreateBuilder(args);
// Bind QBO options
builder.Services.Configure(builder.Configuration.GetSection("Qbo"));
// EF Core
builder.Services.AddDbContext(opt =>
opt.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
// Services
builder.Services.AddHttpClient();
builder.Services.AddScoped<ITokenStore, TokenStore>();
builder.Services.AddScoped();
builder.Services.AddControllers().AddNewtonsoftJson();
var app = builder.Build();
app.MapControllers();
app.Run();
— QuickBooksGateway/Models/QboOptions.cs —
namespace QuickBooksGateway.Models
{
// Holds configuration needed for OAuth + API calls
public class QboOptions
{
public string ClientId { get; set; } = "";
public string ClientSecret { get; set; } = "";
public string RedirectUri { get; set; } = "";
public string Environment { get; set; } = "sandbox";
public string Scopes { get; set; } = "";
public string BaseAuthUrl { get; set; } = "";
public string TokenUrl { get; set; } = "";
public string ApiBaseUrlSandbox { get; set; } = "";
public string ApiBaseUrlProd { get; set; } = "";
public string ApiBaseUrl(string env) =><br> env?.ToLower() == "prod" ? ApiBaseUrlProd : ApiBaseUrlSandbox;<br>}<br>
}
— QuickBooksGateway/Models/QboDtos.cs —
using System.Text.Json.Serialization;
namespace QuickBooksGateway.Models
{
// Minimal DTOs for QBO create flows (Customer, Invoice, Payment).
// QBO is picky about casing and required fields—keep payloads aligned with API docs.
public record CreateCustomerRequest(string DisplayName, string? PrimaryEmailAddr);<br><br>public record CreateInvoiceRequest(long CustomerId, decimal Amount, string? Memo);<br><br>public record RecordPaymentRequest(long CustomerId, long InvoiceId, decimal Amount);<br><br>// Basic API responses<br>public class QboCustomerResponse<br>{<br> [JsonPropertyName("Customer")]<br> public QboCustomer? Customer { get; set; }<br>}<br><br>public class QboCustomer<br>{<br> public long Id { get; set; }<br> public string? DisplayName { get; set; }<br> public string? SyncToken { get; set; }<br>}<br><br>public class QboInvoiceResponse<br>{<br> [JsonPropertyName("Invoice")]<br> public QboInvoice? Invoice { get; set; }<br>}<br><br>public class QboInvoice<br>{<br> public long Id { get; set; }<br> public string? SyncToken { get; set; }<br> public long? CustomerRef { get; set; }<br> public decimal? TotalAmt { get; set; }<br>}<br><br>public class QboPaymentResponse<br>{<br> [JsonPropertyName("Payment")]<br> public QboPayment? Payment { get; set; }<br>}<br><br>public class QboPayment<br>{<br> public long Id { get; set; }<br> public string? SyncToken { get; set; }<br> public decimal? TotalAmt { get; set; }<br>}<br>
}
— QuickBooksGateway/Data/TokenDbContext.cs —
using Microsoft.EntityFrameworkCore;
using QuickBooksGateway.Entities;
namespace QuickBooksGateway.Data
{
// Stores OAuth tokens per connected QBO company (realmId)
public class TokenDbContext : DbContext
{
public TokenDbContext(DbContextOptions options) : base(options) { }
public DbSet<QboToken> Tokens => Set<QboToken>();<br><br> protected override void OnModelCreating(ModelBuilder modelBuilder)<br> {<br> modelBuilder.Entity<QboToken>(e =><br> {<br> e.HasKey(x => x.Id);<br> e.HasIndex(x => x.RealmId).IsUnique();<br> e.Property(x => x.AccessToken).IsRequired();<br> e.Property(x => x.RefreshToken).IsRequired();<br> e.Property(x => x.ExpiresAtUtc);<br> });<br> }<br>}<br>
}
— QuickBooksGateway/Entities/QboToken.cs —
using System.ComponentModel.DataAnnotations;
namespace QuickBooksGateway.Entities
{
// One row per QuickBooks company (realmId)
public class QboToken
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
// Intuit company identifier (realm/company id)<br> public string RealmId { get; set; } = "";<br><br> public string AccessToken { get; set; } = "";<br> public string RefreshToken { get; set; } = "";<br><br> // When current access token expires (UTC)<br> public DateTime ExpiresAtUtc { get; set; }<br><br> // Sandbox or prod so we can route API base URL<br> public string Environment { get; set; } = "sandbox";<br><br> // For multi-member systems: who connected the company<br> public string? ConnectedByUserId { get; set; }<br>}<br>
}
— QuickBooksGateway/Services/ITokenStore.cs —
using QuickBooksGateway.Entities;
namespace QuickBooksGateway.Services
{
public interface ITokenStore
{
Task<QboToken?> GetByRealmIdAsync(string realmId, CancellationToken ct = default);
Task UpsertAsync(QboToken token, CancellationToken ct = default);
}
}
— QuickBooksGateway/Services/TokenStore.cs —
using Microsoft.EntityFrameworkCore;
using QuickBooksGateway.Data;
using QuickBooksGateway.Entities;
namespace QuickBooksGateway.Services
{
// EF Core token persistence
public class TokenStore : ITokenStore
{
private readonly TokenDbContext _db;
public TokenStore(TokenDbContext db) => _db = db;<br><br> public async Task<QboToken?> GetByRealmIdAsync(string realmId, CancellationToken ct = default)<br> {<br> return await _db.Tokens.AsNoTracking().FirstOrDefaultAsync(x => x.RealmId == realmId, ct);<br> }<br><br> public async Task UpsertAsync(QboToken token, CancellationToken ct = default)<br> {<br> var existing = await _db.Tokens.FirstOrDefaultAsync(x => x.RealmId == token.RealmId, ct);<br> if (existing is null)<br> {<br> _db.Tokens.Add(token);<br> }<br> else<br> {<br> existing.AccessToken = token.AccessToken;<br> existing.RefreshToken = token.RefreshToken;<br> existing.ExpiresAtUtc = token.ExpiresAtUtc;<br> existing.Environment = token.Environment;<br> existing.ConnectedByUserId = token.ConnectedByUserId;<br> }<br> await _db.SaveChangesAsync(ct);<br> }<br>}<br>
}
— QuickBooksGateway/Services/QboClient.cs —
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Options;
using QuickBooksGateway.Entities;
using QuickBooksGateway.Models;
namespace QuickBooksGateway.Services
{
// Handles OAuth token refresh + minimal REST calls (Customer, Invoice, Payment).
public class QboClient
{
private readonly IHttpClientFactory _httpFactory;
private readonly IOptions _opts;
private readonly ITokenStore _tokens;
public QboClient(IHttpClientFactory httpFactory, IOptions<QboOptions> opts, ITokenStore tokens)<br> {<br> _httpFactory = httpFactory;<br> _opts = opts;<br> _tokens = tokens;<br> }<br><br> private string ApiBase(string env) => _opts.Value.ApiBaseUrl(env);<br><br> public string BuildAuthUrl(string state)<br> {<br> var o = _opts.Value;<br> var url =<br> $"{o.BaseAuthUrl}?client_id={Uri.EscapeDataString(o.ClientId)}" +<br> $"&redirect_uri={Uri.EscapeDataString(o.RedirectUri)}" +<br> $"&response_type=code&scope={Uri.EscapeDataString(o.Scopes)}&state={Uri.EscapeDataString(state)}";<br> return url;<br> }<br><br> public async Task<QboToken> ExchangeAuthCodeAsync(string code, string realmId, string environment, CancellationToken ct = default)<br> {<br> var o = _opts.Value;<br> var client = _httpFactory.CreateClient();<br> var req = new HttpRequestMessage(HttpMethod.Post, o.TokenUrl);<br> var basic = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{o.ClientId}:{o.ClientSecret}"));<br> req.Headers.Authorization = new AuthenticationHeaderValue("Basic", basic);<br> req.Content = new FormUrlEncodedContent(new Dictionary<string, string><br> {<br> ["grant_type"] = "authorization_code",<br> ["code"] = code,<br> ["redirect_uri"] = o.RedirectUri<br> });<br><br> var res = await client.SendAsync(req, ct);<br> res.EnsureSuccessStatusCode();<br> var json = await res.Content.ReadAsStringAsync(ct);<br> using var doc = JsonDocument.Parse(json);<br> var access = doc.RootElement.GetProperty("access_token").GetString()!;<br> var refresh = doc.RootElement.GetProperty("refresh_token").GetString()!;<br> var expiresIn = doc.RootElement.GetProperty("expires_in").GetInt32();<br><br> var token = new QboToken<br> {<br> RealmId = realmId,<br> AccessToken = access,<br> RefreshToken = refresh,<br> ExpiresAtUtc = DateTime.UtcNow.AddSeconds(expiresIn - 60),<br> Environment = environment<br> };<br> await _tokens.UpsertAsync(token, ct);<br> return token;<br> }<br><br> private async Task<QboToken> EnsureAccessAsync(string realmId, CancellationToken ct)<br> {<br> var t = await _tokens.GetByRealmIdAsync(realmId, ct) ?? throw new InvalidOperationException("No token for realmId");<br> if (DateTime.UtcNow < t.ExpiresAtUtc) return t;<br><br> // Refresh access token<br> var o = _opts.Value;<br> var client = _httpFactory.CreateClient();<br> var req = new HttpRequestMessage(HttpMethod.Post, o.TokenUrl);<br> var basic = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{o.ClientId}:{o.ClientSecret}"));<br> req.Headers.Authorization = new AuthenticationHeaderValue("Basic", basic);<br> req.Content = new FormUrlEncodedContent(new Dictionary<string, string><br> {<br> ["grant_type"] = "refresh_token",<br> ["refresh_token"] = t.RefreshToken<br> });<br> var res = await client.SendAsync(req, ct);<br> res.EnsureSuccessStatusCode();<br> var json = await res.Content.ReadAsStringAsync(ct);<br> using var doc = JsonDocument.Parse(json);<br> t.AccessToken = doc.RootElement.GetProperty("access_token").GetString()!;<br> t.RefreshToken = doc.RootElement.GetProperty("refresh_token").GetString()!;<br> var expiresIn = doc.RootElement.GetProperty("expires_in").GetInt32();<br> t.ExpiresAtUtc = DateTime.UtcNow.AddSeconds(expiresIn - 60);<br><br> await _tokens.UpsertAsync(t, ct);<br> return t;<br> }<br><br> private HttpRequestMessage JsonPost(string url, string token, object payload)<br> {<br> var req = new HttpRequestMessage(HttpMethod.Post, url);<br> req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);<br> req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));<br> req.Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");<br> return req;<br> }<br><br> // Create Customer<br> public async Task<QboCustomerResponse?> CreateCustomerAsync(string realmId, string environment, CreateCustomerRequest input, CancellationToken ct)<br> {<br> var t = await EnsureAccessAsync(realmId, ct);<br> var baseUrl = ApiBase(environment) + realmId + "/customer?minorversion=73";<br> var payload = new<br> {<br> DisplayName = input.DisplayName,<br> PrimaryEmailAddr = string.IsNullOrWhiteSpace(input.PrimaryEmailAddr) ? null : new { Address = input.PrimaryEmailAddr }<br> };<br><br> var client = _httpFactory.CreateClient();<br> var req = JsonPost(baseUrl, t.AccessToken, payload);<br> var res = await client.SendAsync(req, ct);<br> res.EnsureSuccessStatusCode();<br> var json = await res.Content.ReadAsStringAsync(ct);<br> return JsonSerializer.Deserialize<QboCustomerResponse>(json);<br> }<br><br> // Create Invoice (single line)<br> public async Task<QboInvoiceResponse?> CreateInvoiceAsync(string realmId, string environment, CreateInvoiceRequest input, CancellationToken ct)<br> {<br> var t = await EnsureAccessAsync(realmId, ct);<br> var baseUrl = ApiBase(environment) + realmId + "/invoice?minorversion=73";<br><br> // NOTE: ItemRef "1" often maps to "Services" in sandbox; adjust for production Items.<br> var payload = new<br> {<br> Line = new[]<br> {<br> new {<br> DetailType = "SalesItemLineDetail",<br> Amount = input.Amount,<br> SalesItemLineDetail = new { ItemRef = new { value = "1" } }<br> }<br> },<br> CustomerRef = new { value = input.CustomerId.ToString() },<br> PrivateNote = input.Memo<br> };<br><br> var client = _httpFactory.CreateClient();<br> var req = JsonPost(baseUrl, t.AccessToken, payload);<br> var res = await client.SendAsync(req, ct);<br> res.EnsureSuccessStatusCode();<br> var json = await res.Content.ReadAsStringAsync(ct);<br> return JsonSerializer.Deserialize<QboInvoiceResponse>(json);<br> }<br><br> // Record Payment applied to Invoice<br> public async Task<QboPaymentResponse?> RecordPaymentAsync(string realmId, string environment, RecordPaymentRequest input, CancellationToken ct)<br> {<br> var t = await EnsureAccessAsync(realmId, ct);<br> var baseUrl = ApiBase(environment) + realmId + "/payment?minorversion=73";<br><br> var payload = new<br> {<br> CustomerRef = new { value = input.CustomerId.ToString() },<br> TotalAmt = input.Amount,<br> Line = new[]<br> {<br> new {<br> Amount = input.Amount,<br> LinkedTxn = new[] { new { TxnId = input.InvoiceId.ToString(), TxnType = "Invoice" } }<br> }<br> }<br> };<br><br> var client = _httpFactory.CreateClient();<br> var req = JsonPost(baseUrl, t.AccessToken, payload);<br> var res = await client.SendAsync(req, ct);<br> res.EnsureSuccessStatusCode();<br> var json = await res.Content.ReadAsStringAsync(ct);<br> return JsonSerializer.Deserialize<QboPaymentResponse>(json);<br> }<br>}<br>
}
— QuickBooksGateway/Controllers/QboAuthController.cs —
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using QuickBooksGateway.Models;
using QuickBooksGateway.Services;
namespace QuickBooksGateway.Controllers
{
// Handles the OAuth handshake with Intuit (connect account → callback).
[ApiController]
[Route("qbo")]
public class QboAuthController : ControllerBase
{
private readonly QboClient _qbo;
private readonly IOptions _opts;
public QboAuthController(QboClient qbo, IOptions<QboOptions> opts)<br> {<br> _qbo = qbo;<br> _opts = opts;<br> }<br><br> // Step 1: Redirect user to Intuit consent<br> [HttpGet("connect")]<br> public IActionResult Connect([FromQuery] string? env = "sandbox")<br> {<br> // state ideally includes a CSRF token & the env ("sandbox" or "prod")<br> var state = $"env={env}&nonce={Guid.NewGuid()}";<br> var url = _qbo.BuildAuthUrl(state);<br> return Redirect(url);<br> }<br><br> // Step 2: Intuit calls back with ?code=&state=&realmId=<br> [HttpGet("callback")]<br> public async Task<IActionResult> Callback([FromQuery] string code, [FromQuery] string state, [FromQuery] string realmId, [FromQuery] string? error = null)<br> {<br> if (!string.IsNullOrEmpty(error)) return BadRequest(error);<br><br> var env = "sandbox";<br> var parts = state.Split('&', StringSplitOptions.RemoveEmptyEntries);<br> foreach (var p in parts)<br> {<br> var kv = p.Split('=');<br> if (kv.Length == 2 && kv[0] == "env") env = kv[1];<br> }<br><br> await _qbo.ExchangeAuthCodeAsync(code, realmId, env, HttpContext.RequestAborted);<br> return Content($"Connected realmId {realmId} in {env} environment. You can now call /api/customer, /api/invoice, /api/payment.");<br> }<br>}<br>
}
— QuickBooksGateway/Controllers/QboApiController.cs —
using Microsoft.AspNetCore.Mvc;
using QuickBooksGateway.Models;
using QuickBooksGateway.Services;
namespace QuickBooksGateway.Controllers
{
// Minimal API surface to demonstrate transactional calls.
[ApiController]
[Route("api")]
public class QboApiController : ControllerBase
{
private readonly QboClient _qbo;
public QboApiController(QboClient qbo) => _qbo = qbo;<br><br> // POST /api/customer?realmId=12345&env=sandbox<br> [HttpPost("customer")]<br> public async Task<IActionResult> CreateCustomer([FromQuery] string realmId, [FromQuery] string env, [FromBody] CreateCustomerRequest req)<br> {<br> var result = await _qbo.CreateCustomerAsync(realmId, env, req, HttpContext.RequestAborted);<br> return Ok(result);<br> }<br><br> // POST /api/invoice?realmId=12345&env=sandbox<br> [HttpPost("invoice")]<br> public async Task<IActionResult> CreateInvoice([FromQuery] string realmId, [FromQuery] string env, [FromBody] CreateInvoiceRequest req)<br> {<br> var result = await _qbo.CreateInvoiceAsync(realmId, env, req, HttpContext.RequestAborted);<br> return Ok(result);<br> }<br><br> // POST /api/payment?realmId=12345&env=sandbox<br> [HttpPost("payment")]<br> public async Task<IActionResult> RecordPayment([FromQuery] string realmId, [FromQuery] string env, [FromBody] RecordPaymentRequest req)<br> {<br> var result = await _qbo.RecordPaymentAsync(realmId, env, req, HttpContext.RequestAborted);<br> return Ok(result);<br> }<br>}<br>
}
— QuickBooksGateway/Controllers/WebhooksController.cs —
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Primitives;
using Newtonsoft.Json.Linq;
namespace QuickBooksGateway.Controllers
{
// Receives QBO webhooks (e.g., Customer, Invoice changes).
// In Intuit Developer Portal, subscribe to entities and set this endpoint.
[ApiController]
[Route("webhooks/qbo")]
public class WebhooksController : ControllerBase
{
private readonly IConfiguration _cfg;
public WebhooksController(IConfiguration cfg) => _cfg = cfg;<br><br> [HttpPost]<br> public async Task<IActionResult> Receive()<br> {<br> // Verify signature (HMACSHA256 over raw body using your "VerifierToken").<br> var body = await new StreamReader(Request.Body).ReadToEndAsync();<br> if (!Request.Headers.TryGetValue(_cfg["WebhookVerifier:IntuitSignatureHeader"] ?? "intuit-signature", out StringValues sigs))<br> return Unauthorized("Missing signature");<br><br> var expected = ComputeHmac(_cfg["WebhookVerifier:VerifierToken"] ?? "", body);<br> if (!sigs.Any(s => s.Equals(expected, StringComparison.OrdinalIgnoreCase)))<br> return Unauthorized("Bad signature");<br><br> // Parse minimal payload & acknowledge<br> var json = JObject.Parse(body);<br> // TODO: enqueue to your bus for idempotent processing<br> // Example: log entity names + operation types<br> var events = json["eventNotifications"]?.First?["dataChangeEvent"]?["entities"];<br> if (events != null)<br> {<br> foreach (var e in events)<br> {<br> var name = (string?)e["name"] ?? "?";<br> var op = (string?)e["operation"] ?? "?";<br> Console.WriteLine($"Webhook: {name} {op}");<br> }<br> }<br><br> return Ok();<br> }<br><br> private static string ComputeHmac(string secret, string data)<br> {<br> using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));<br> var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(data));<br> return Convert.ToBase64String(hash);<br> }<br>}<br>
}
How this supports your policy (each member & each LLC)
• Each member connects their own QBO tenant via GET /qbo/connect → approves in Intuit → we store tokens by realmId.
• Each LLC has its own QBO and two bank accounts. Use article #2 as the canonical policy post and link it everywhere.
• Your gateway lets you automate common flows per entity cleanly (no co-mingling), and you can restrict who can call which realmId.
• Webhooks let your nodes react to real accounting events (e.g., invoice paid → trigger Discord/SignalR toast, sweep to Tax/Reserve).
Next steps (drop these as forum “Resources”)
• Migration: dotnet ef migrations add InitTokens && dotnet ef database update
• In Intuit Developer Portal: create app, set Redirect URI to https://yourdomain.com/qbo/callback, enable Accounting scopes, set Webhook URL to https://yourdomain.com/webhooks/qbo, copy Verifier Token into appsettings.json.
• Postman collection: add POSTs to /api/customer, /api/invoice, /api/payment with realmId & env query params.
• Security hardening: require your own auth on every /api/* route; map realmId to a MemberId (RBAC).
• Extend: add endpoints for Vendor/Bill, Items, BankDeposit, JournalEntry; add background job to refresh tokens nightly; log everything (Serilog).
If you want, I can also generate:
• A QBO Chart of Accounts JSON seed for one-man LLCs.
• A bank-rules CSV starter (typical fuel, hardware, meals, hotels).
• Razor pages + dark theme UI (LLCAIMACHINE styling) to connect and test each member’s realmId.
Last edited: